diff --git a/AGENTS.md b/AGENTS.md index 57ac995..d5c5c88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index f241440..4a03115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; diff --git a/README.md b/README.md index c13ea7e..5d0424f 100644 --- a/README.md +++ b/README.md @@ -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_` 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: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d741c76..1bd62ff 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt index 6572c51..bb4a468 100644 --- a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt +++ b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt @@ -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 = {}, ) diff --git a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt index 1e9a62a..132024c 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt @@ -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() override fun onCreate( savedInstanceState: Bundle? ) { @@ -46,6 +49,7 @@ class MainActivity : ComponentActivity() { inbox = inbox, requestOutbox = requestOutbox, + appState = appState, ) } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/PercentMaxCalculator.kt b/android/app/src/main/java/com/labfytools/trainlog/data/PercentMaxCalculator.kt new file mode 100644 index 0000000..b3ee080 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/PercentMaxCalculator.kt @@ -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 } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt index 7d3edf4..45f7ef7 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt @@ -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 { diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt index f1e48e2..1cc0366 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt @@ -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 diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 877debb..af00982 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -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>() + val sources = mutableSetOf() + 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> { 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 = (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(canonical.rowId, primary)) + db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,'primary');", + arrayOf(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(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(item.optString("entry_id"), item.optInt("position", -1), item.optString("exercise_id"), + val expected = mutableListOf(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() + 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 { - val output = mutableListOf() + data class AggregateCandidate( + val temporal: TemporalCandidate, + val result: LatestExerciseMax, + ) + + val selected = linkedMapOf() 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() + 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 -> diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt new file mode 100644 index 0000000..3c28c2a --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt @@ -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() } + } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt new file mode 100644 index 0000000..6146f04 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt @@ -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() + 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) +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt index e0ef8a2..d7ad7f9 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt @@ -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(), ) { diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt index 30c7550..8985390 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt @@ -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(null) + val secondaryZoneIds = mutableStateOf(emptySet()) + val searchQuery = mutableStateOf("") + val filterZoneId = mutableStateOf(null) + val unclassifiedFilter = mutableStateOf(false) + val expandedKnowledgeIds = mutableStateOf(emptySet()) + val message = mutableStateOf(null) + val editedExerciseId = mutableStateOf(null) + var selectedExerciseId by mutableStateOf(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(null) } - var secondaryZoneIds by remember { mutableStateOf(emptySet()) } - var searchQuery by remember { mutableStateOf("") } - var filterZoneId by remember { mutableStateOf(null) } - var unclassifiedFilter by remember { mutableStateOf(false) } - var expandedKnowledgeIds by remember { mutableStateOf(emptySet()) } + 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( - null - ) - } - - var editedExercise by - remember { - mutableStateOf(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 } - 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("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}") - TrainlogInfo("Confiance : ${confidenceLabel(interpretation.confidence)}", colors.muted) + 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("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}") + 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 { diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt index 2ba7284..56b0bcc 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt @@ -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()) { diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt index 9520144..9cd2b72 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt @@ -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" - ) { - 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" - } 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 - }, - ) - } + TrainlogScreen("Accueil", scrollKey = "home") { + draftError?.let { TrainlogInfo(it, colors.error) } + TrainlogFrame("Séance") { + if (activeDraft != null) { + 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 { + 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) } } - - if (draftError != null) { - TrainlogFrame( - title = "BROUILLON" - ) { - TrainlogInfo( - text = draftError, - color = colors.error, - ) + latestSession?.let { session -> + TrainlogFrame("Dernière séance") { + TrainlogAction(formatStartedAt(session.startedAt), "${sessionTypeLabel(session.sessionType)} · ${session.exerciseCount} exercice(s)", { onOpenLatestSession(session.sessionId) }) } } - - 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, - ) + 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( - 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." - ) + 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) } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt new file mode 100644 index 0000000..580e665 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -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(null) + var catalogueScroll by mutableStateOf(0) + var detailScroll by mutableStateOf(0) + var message by mutableStateOf(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(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) } + } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt index 098387b..a427ce1 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt index 84478cd..306e2b1 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt @@ -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(null) + val message = mutableStateOf(null) + val warningAcknowledged = mutableStateOf(false) + val editingIndex = mutableStateOf(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(null) } - var message by remember { mutableStateOf(null) } - var warningAcknowledged by remember { mutableStateOf(false) } - var editingIndex by remember { mutableStateOf(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") { - 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("Objectif") { + TrainlogChoiceChips(SessionGeneratorFormController.goals.filter { it.first in options.goalIds }, goalId) { goalId = it } } - 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 }) - TrainlogInputField( - "Charge cible manuelle (vide = réévaluer)", - loadText, - onValueChange = { loadText = 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 = 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" diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index 2d56745..f14861f 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -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 { + 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)) } }, ) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt index eee9c40..876c75c 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 366f19c..e0e1142 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -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(null) - } - - var selectedSessionId by - remember { - mutableStateOf( - 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(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 - } - - CatalogInboxResult.FolderNotAuthorized, - CatalogInboxResult.FileNotFound, - is CatalogInboxResult.Error -> { - /* Nothing to import yet. */ - } + when (inbox.importPcCatalog()) { + is CatalogInboxResult.Imported -> catalogRevision++ + CatalogInboxResult.FolderNotAuthorized, CatalogInboxResult.FileNotFound, + is CatalogInboxResult.Error -> Unit } - } - - 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 - } + 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 -> 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 } } - when (screen) { - TrainlogScreenId.HOME -> { - val draftLoad = - remember( - draftRevision, - catalogRevision, - ) { - repository.loadActiveSessionDraft() - } + val routeStateHolder = rememberSaveableStateHolder() + val retainedRouteKeys = remember { ArrayDeque() } + val routeKey = navigation.route.stateKey() + LaunchedEffect(routeKey) { + retainedRouteKeys.remove(routeKey) + retainedRouteKeys.addLast(routeKey) + while (retainedRouteKeys.size > 16) routeStateHolder.removeState(retainedRouteKeys.removeFirst()) + } - HomeScreen( - activeDraft = - (draftLoad as? - ActiveDraftLoadResult.Loaded) - ?.draft, - draftError = - draftMessage - ?: (draftLoad as? - ActiveDraftLoadResult.Error) - ?.message - ?: (draftLoad as? - ActiveDraftLoadResult.Loaded) - ?.warning, - onSession = { - 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.Error -> { - draftMessage = draftLoad.message - } - } - }, - onGenerateSession = { - draftMessage = null - screen = TrainlogScreenId.SESSION_GENERATOR - }, - onDiscardDraft = { - when ( - val result = - repository - .discardActiveSessionDraft() - ) { - ActiveDraftMutationResult.Saved -> { - draftMessage = null - draftRevision += 1 - } - - 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() +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt index c98aa58..07a4ed6 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt @@ -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>, + 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 } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt index cfe604c..5aea737 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt @@ -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) + } } diff --git a/android/app/src/main/res/drawable/ic_back.xml b/android/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..18aacb9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/drawable/ic_equipment.xml b/android/app/src/main/res/drawable/ic_equipment.xml new file mode 100644 index 0000000..5183828 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_equipment.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/drawable/ic_exercises.xml b/android/app/src/main/res/drawable/ic_exercises.xml new file mode 100644 index 0000000..319024e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_exercises.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/drawable/ic_home.xml b/android/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000..59346ed --- /dev/null +++ b/android/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/drawable/ic_menu.xml b/android/app/src/main/res/drawable/ic_menu.xml new file mode 100644 index 0000000..f596001 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_menu.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/drawable/ic_sessions.xml b/android/app/src/main/res/drawable/ic_sessions.xml new file mode 100644 index 0000000..316f33a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_sessions.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/drawable/ic_settings.xml b/android/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..f0b93fe --- /dev/null +++ b/android/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/drawable/ic_statistics.xml b/android/app/src/main/res/drawable/ic_statistics.xml new file mode 100644 index 0000000..bdb8eaa --- /dev/null +++ b/android/app/src/main/res/drawable/ic_statistics.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/drawable/ic_sync.xml b/android/app/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..70be179 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_sync.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index f9d09e9..0e2577b 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -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")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt new file mode 100644 index 0000000..c36f249 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -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>> + 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>> { + 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) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 42b4949..b20f111 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -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)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/PercentMaxCalculatorTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/PercentMaxCalculatorTest.kt new file mode 100644 index 0000000..8ce115c --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/PercentMaxCalculatorTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt index d37f94b..099f5e4 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt @@ -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()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryTest.kt index 42d907d..98317c9 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryTest.kt @@ -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() diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 7e1f9e8..8932017 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -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 } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt new file mode 100644 index 0000000..de2dcc1 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/AppNavigationStateTest.kt @@ -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, + ) +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt index 86cfffd..05604a5 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt @@ -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", diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/TrainlogAppNavigationCallbackTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/TrainlogAppNavigationCallbackTest.kt new file mode 100644 index 0000000..a02abfb --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/TrainlogAppNavigationCallbackTest.kt @@ -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, + ), + ) + } +} diff --git a/docs/android.md b/docs/android.md index e38cc75..cb091ee 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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 1–32 and 1–64 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. diff --git a/docs/architecture.md b/docs/architecture.md index 47c1257..d3bd956 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index c07787d..5c8139f 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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. diff --git a/docs/database.md b/docs/database.md index ced0e2f..c01aea4 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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 diff --git a/docs/design/app_shell_v1/android_mockups.html b/docs/design/app_shell_v1/android_mockups.html new file mode 100644 index 0000000..f02da57 --- /dev/null +++ b/docs/design/app_shell_v1/android_mockups.html @@ -0,0 +1,73 @@ + + + + + +Trainlog — APP_SHELL_V1 — Maquettes Android + + + + +
+
APP_SHELL_V1 · DESIGN GATE

Trainlog, une seule application.

Mocha / Lavender. Navigation claire, informations utiles, actions à portée de main.

Maquettes de conception statiques — données fictives. Les actions dessinées ne modifient aucune donnée. Le document complet propose des contrôles interactifs de largeur et de texte. Police système du navigateur, approximation du rendu Android.

+
Chaque écran défile indépendamment.
+
+

01 · Accueil avec séance en cours

+
05:00▰ 82 %
TRAINLOG
+

Accueil

Séance en coursNormal
3 exercices · conservée sur cet appareil
Reprendre la séance
+
Mercredi 9 septembre
Entraînement · 6 exercices
+
Pec Fly
5 septembre
80 kg
Équipement : Rear Delt / Pec Fly
+
Programmer une séance
Préparer une proposition modifiable
Mensurations
Ajouter ou consulter un relevé
Dernière sync réussie · hier
+

Une reprise prioritaire ; la dernière séance et le MAX sont des faits enregistrés, sans nouveaux calculs de progression.

+ +

02 · Drawer, premier niveau

05:00▰ 82 %
TRAINLOG

Séances

Séance en cours
3 exercices

La rubrique courante reste évidente. Les sous-pages appartiennent au contenu ; aucun arbre à déplier sur téléphone.

+ +

03 · Séances, sans séance active

05:00▰ 82 %
TRAINLOG

Séances

Aucune séance en cours
Préparez une proposition ou démarrez une saisie manuelle.
Programmer une séance
Nouvelle séance manuelle
Consulter les séances
Entraînements et Tests max enregistrés
Mercredi 9 septembre
Entraînement · 6 exercices

L'état vide propose immédiatement deux actions valides. Aucun bouton « Reprendre » inactif ; aucun nouveau calendrier.

+ +

04 · Proposition et avertissement

05:00▰ 82 %
Programmer une séanceSéances

Votre proposition

30 min
Corps entier · durée estimée
! Exposition récente observée
L'historique montre du travail récent. Cela ne mesure pas votre récupération.
✓ Avertissement pris en compte
1. Pec Fly
Équipement : Rear Delt / Pec Fly
3 × 10 · repos 90 s
Charge cible : non proposée
2. Leg Press
Équipement : Leg Press
3 × 10 · repos 90 s
Charge cible : non proposée
Une proposition ne contient aucune série réalisée.
Accepter la proposition
Ouvrir la séance en cours pour saisir le travail réalisé.

Exemple visuel, pas une prescription. Les éléments importants restent visibles ; les sources et explications détaillées restent consultables.

+ +

05 · Exercice et équipement distincts

05:00▰ 82 %
Fiche exerciceExercices / Catalogue

Pec Fly

Séries · répétitions
Zone principale
Non renseignée
Rear Delt / Pec Fly
Catalogue fourni · Basic-Fit
Consulter les connaissances
Rôles, mouvements, confiance et sources
Voir les derniers MAX
Ouvrir la liste dans Statistiques

Les noms actuels restent affichés. La hiérarchie sépare déjà le mouvement enregistré du matériel ; aucun renommage n'est effectué.

+ +

06 · Saisie, objectifs et valeurs réelles

05:00▰ 82 %
Séance en coursExercice 1 / 3

Pec Fly

Équipement : Rear Delt / Pec Fly
Objectif prévu
3 × 10 · repos 90 s
1
10
40
2
9
40,
Chaque série conserve ses propres valeurs.
Brouillon conservé sur cet appareil
Valider l'exercice
La séance se termine depuis son récapitulatif.

Les cellules vides restent vides. Le texte partiel est conservé. Avec le clavier ouvert, contenu défilant et action remontent au-dessus de l'IME.

+ +

07 · Statistiques, transition V1

05:00▰ 82 %
TRAINLOG

Statistiques

Vos relevés et résultats enregistrés sur cet appareil.
Mensurations
Ajouter un relevé · derniers relevés
Capacités / MAX
Derniers résultats des Tests max

Deux destinations utiles aujourd'hui sur Android. Les futures analyses ne sont pas affichées comme des boutons vides.

+ +

08 · Catalogue d'équipements

05:00▰ 82 %
TRAINLOG

Équipements

+ Créer
Rear Delt / Pec Fly
Basic-Fit · Fourni
Leg Press
Basic-Fit · Fourni
Ma poulie
Personnel
Un équipement est le matériel utilisé. Chaque occurrence d'exercice peut préciser son équipement.

Une nouvelle entrée de navigation Android réutilise les lectures et créations existantes. Pas d'éditeur de définition fictif.

+ +

09 · Synchronisation

05:00▰ 82 %
TRAINLOG

Synchronisation

Dernière sync réussie
9 septembre · 18:42
Synchroniser maintenant
Android → PC, puis PC → Android
Téléchargements / Trainlog
Autorisé sur cet appareil
Relire le catalogue PC
Récupération manuelle
Les données à envoyer sont préparées automatiquement après vos enregistrements.

Même opération et mêmes états de reçu. Les trois directions restent disponibles sur le PC ; elles ne sont pas inventées sur Android.

+
+
Comment juger ces maquettes
  • Ouvrez le fichier localement, puis changez largeur et taille du texte. Le document complet définit le comportement Compose aux grandes tailles de police.
  • Les contours externes représentent la fenêtre du téléphone, pas des cadres à reproduire dans l'application.
  • Les petits pictogrammes SVG sont des substituts de design. La production utilisera des vecteurs Android cohérents, avec libellés et cibles de 48 dp.
  • Aucune donnée de ces exemples ne vient de votre base. Le scénario généré ne constitue pas une recommandation d'entraînement.

Proposition et contrats d'interaction · Grilles TUI aux quatre dimensions

+
+ + + diff --git a/docs/design/app_shell_v1/android_overview.png b/docs/design/app_shell_v1/android_overview.png new file mode 100644 index 0000000..b0fe556 Binary files /dev/null and b/docs/design/app_shell_v1/android_overview.png differ diff --git a/docs/design/app_shell_v1/proposal.md b/docs/design/app_shell_v1/proposal.md new file mode 100644 index 0000000..5929ca4 --- /dev/null +++ b/docs/design/app_shell_v1/proposal.md @@ -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 | +| 393–412 dp | Même architecture, davantage de texte visible ; pas de nouvelle colonne de navigation ; drawer 337–356 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 1–12 · 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 8–12 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 1–2 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. diff --git a/docs/design/app_shell_v1/review_and_validation.md b/docs/design/app_shell_v1/review_and_validation.md new file mode 100644 index 0000000..ce6e41e --- /dev/null +++ b/docs/design/app_shell_v1/review_and_validation.md @@ -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. diff --git a/docs/design/app_shell_v1/tui_mockups.html b/docs/design/app_shell_v1/tui_mockups.html new file mode 100644 index 0000000..d79d44d --- /dev/null +++ b/docs/design/app_shell_v1/tui_mockups.html @@ -0,0 +1,172 @@ +APP_SHELL_V1 — Maquettes TUI

Trainlog — shell Notcurses proposé

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.

+

120x35 — Séances effectuées

+
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

+
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

+
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

+
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

+
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é

+
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
+
+

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. Version texte · Proposition complète

diff --git a/docs/design/app_shell_v1/tui_mockups.txt b/docs/design/app_shell_v1/tui_mockups.txt new file mode 100644 index 0000000..d67b2b2 --- /dev/null +++ b/docs/design/app_shell_v1/tui_mockups.txt @@ -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 +``` + diff --git a/docs/domain/session_generation.md b/docs/domain/session_generation.md index b659f6c..b6b07b1 100644 --- a/docs/domain/session_generation.md +++ b/docs/domain/session_generation.md @@ -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 diff --git a/docs/reviews/app_shell_v1.md b/docs/reviews/app_shell_v1.md new file mode 100644 index 0000000..2a93f0f --- /dev/null +++ b/docs/reviews/app_shell_v1.md @@ -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. diff --git a/docs/reviews/app_shell_v1_android.md b/docs/reviews/app_shell_v1_android.md new file mode 100644 index 0000000..c1d3614 --- /dev/null +++ b/docs/reviews/app_shell_v1_android.md @@ -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. diff --git a/docs/reviews/exercise_alias_sync_canonicalization_audit.md b/docs/reviews/exercise_alias_sync_canonicalization_audit.md new file mode 100644 index 0000000..51919bb --- /dev/null +++ b/docs/reviews/exercise_alias_sync_canonicalization_audit.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 736fc58..f697ac3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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: diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index b93d454..66ab715 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -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 diff --git a/docs/tests.md b/docs/tests.md index b9e76b8..d6bacfb 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -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. diff --git a/docs/tui.md b/docs/tui.md index 22553ef..8f7c374 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -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 -q quit from the application shell +↑ ↓ list navigation +Enter open/activate +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 diff --git a/tests/test_body_zone_sync.py b/tests/test_body_zone_sync.py index 3eefa07..ab7ca38 100644 --- a/tests/test_body_zone_sync.py +++ b/tests/test_body_zone_sync.py @@ -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. diff --git a/tests/test_equipment_associations_exchange.py b/tests/test_equipment_associations_exchange.py index f87e988..67dd8cc 100644 --- a/tests/test_equipment_associations_exchange.py +++ b/tests/test_equipment_associations_exchange.py @@ -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' diff --git a/tests/test_exercise_alias_import.py b/tests/test_exercise_alias_import.py new file mode 100644 index 0000000..e742d0f --- /dev/null +++ b/tests/test_exercise_alias_import.py @@ -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() diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 0f6059b..85103ff 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -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")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 7b129c2..b3b0652 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -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")] diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py new file mode 100644 index 0000000..f18b184 --- /dev/null +++ b/tools/export_exercise_aliases.py @@ -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() diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 1e083c9..abe30d7 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -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: diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index e089b2d..b949aa7 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -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}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 5437f71..1581c9a 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -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")) diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index b3aedc9..9269602 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -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") diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 6a12372..487e3f0 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -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: diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py new file mode 100644 index 0000000..8a813c4 --- /dev/null +++ b/tools/import_exercise_aliases.py @@ -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() diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index 4fa3b23..fb60a97 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -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}") diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index b23628d..405db2e 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -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, diff --git a/tools/validate_app_shell_pty.py b/tools/validate_app_shell_pty.py new file mode 100644 index 0000000..c0d9b36 --- /dev/null +++ b/tools/validate_app_shell_pty.py @@ -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()) diff --git a/tui/include/trainlog/app_shell.h b/tui/include/trainlog/app_shell.h new file mode 100644 index 0000000..7ada350 --- /dev/null +++ b/tui/include/trainlog/app_shell.h @@ -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 +#include + +#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 diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 8460ef5..8a0b2df 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -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 */ diff --git a/tui/include/trainlog/measured_max.h b/tui/include/trainlog/measured_max.h index 10ae4b9..d9cdeba 100644 --- a/tui/include/trainlog/measured_max.h +++ b/tui/include/trainlog/measured_max.h @@ -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 diff --git a/tui/include/trainlog/terminal.h b/tui/include/trainlog/terminal.h index 0804229..33c5b08 100644 --- a/tui/include/trainlog/terminal.h +++ b/tui/include/trainlog/terminal.h @@ -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. */ diff --git a/tui/include/trainlog/theme.h b/tui/include/trainlog/theme.h index 06f5dca..668ee43 100644 --- a/tui/include/trainlog/theme.h +++ b/tui/include/trainlog/theme.h @@ -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 diff --git a/tui/meson.build b/tui/meson.build index df5695f..eeab6a9 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -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) diff --git a/tui/src/app_shell.c b/tui/src/app_shell.c new file mode 100644 index 0000000..9604d00 --- /dev/null +++ b/tui/src/app_shell.c @@ -0,0 +1,569 @@ +/** + * @file app_shell.c + * @brief Pure state machinery shared by the Notcurses application shell. + */ + +#include "trainlog/app_shell.h" + +#include +#include + +#include + +#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), ¤t); + 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)); +} diff --git a/tui/src/database.c b/tui/src/database.c index 3c3bdab..0e890fb 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -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); +} diff --git a/tui/src/measured_max.c b/tui/src/measured_max.c index c3e7427..4bad824 100644 --- a/tui/src/measured_max.c +++ b/tui/src/measured_max.c @@ -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; +} diff --git a/tui/src/sync.c b/tui/src/sync.c index 479ce53..c954798 100644 --- a/tui/src/sync.c +++ b/tui/src/sync.c @@ -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 || diff --git a/tui/src/sync_screen_action.c b/tui/src/sync_screen_action.c index d6e5f17..4928a65 100644 --- a/tui/src/sync_screen_action.c +++ b/tui/src/sync_screen_action.c @@ -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"; } diff --git a/tui/src/terminal.c b/tui/src/terminal.c index 56f03d1..a3530d6 100644 --- a/tui/src/terminal.c +++ b/tui/src/terminal.c @@ -11,6 +11,7 @@ #include #include +#include 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) { diff --git a/tui/src/tui.c b/tui/src/tui.c index d11e32a..ee1c8b2 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -24,6 +24,7 @@ #include #include "trainlog/terminal.h" +#include "trainlog/app_shell.h" #include "trainlog/bodyviz.h" #include "trainlog/body_analytics.h" @@ -51,17 +52,10 @@ #define MAX_SESSIONS 128U #define MAX_WEIGHT_POINTS 256U #define MAX_BODY_ZONES 16U +#define MAX_BODY_OBSERVATIONS 512U #define MAX_BODY_METRIC_POINTS 256U - -/* - * INVARIANT: this pointer is assigned only for the dynamic extent of one - * trainlog_tui_run() call and is cleared before Notcurses shutdown. The - * screen implementation predates explicit context parameters; the terminal - * object itself remains owned by the public run entry point. - */ -static TrainlogTerminal *tui_terminal; - +#define EXERCISE_GRAPH_POINTS 12U /* TRAINLOG_TUI_V02_POLISH */ /* TRAINLOG_TUI_PROFILED_EXERCISE_CREATION */ @@ -72,104 +66,6 @@ static TrainlogTerminal *tui_terminal; /* TRAINLOG_SYNC_PC_TO_ANDROID_DIAGNOSTICS */ /* TRAINLOG_SYNC_FULL_MTP_SILENCE */ -typedef enum DashboardAction { - DASHBOARD_NEW_SESSION = 0, - DASHBOARD_GENERATE_SESSION, - DASHBOARD_HISTORY, - DASHBOARD_EXERCISES, - DASHBOARD_EQUIPMENT, - DASHBOARD_BODY, - DASHBOARD_SYNC, - DASHBOARD_QUIT -} DashboardAction; - -/* CONTRACT: every primary navigation renderer and selector uses this single - * list/count pair so adding a workflow cannot desynchronize array bounds. */ -static const char *const primary_nav_labels[] = { - "0 Accueil", "1 Séance", "2 Historique", "3 Exercices", - "4 Équipements", "5 Corps", "6 Sync" -}; - -#define PRIMARY_NAV_COUNT \ - ((int)(sizeof(primary_nav_labels) / sizeof(primary_nav_labels[0]))) - -/* TRAINLOG_DASHBOARD_FORWARD_DECLARATIONS */ -static DashboardAction screen_dashboard( - TrainlogDatabase *database -); - -static void screen_exercises( - TrainlogDatabase *database -); - -static void screen_sync( - TrainlogDatabase *database -); -static void screen_equipment(TrainlogDatabase *database); -static void screen_equipment_detail(const TrainlogResolvedEquipment *equipment); -static void wait_key(void); -static void status_line(const char *text, TrainlogColorRole role); -static bool prompt_text(int row, const char *label, char *output, size_t output_size, bool allow_empty); -static bool prompt_int_value(int row, const char *label, int minimum, - int maximum, int default_value, int *output); - -static void dashboard_panel( - int top, - int left, - int bottom, - int right, - const char *label -); - -static void dashboard_ascii_header(void); - -static void draw_dashboard_body_graph( - TrainlogDatabase *database -); - -static void focused_panel( - int top, - int left, - int bottom, - int right, - const char *label, - bool active -); - -static void primary_top_navbar( - int active_page, - int selected_page, - bool focused -); - -static bool primary_top_nav_forward(int key); -static bool primary_top_nav_activate(int selected_page); - -static void section_ascii_header(const char *subtitle); - - -static void session_history_datetime( - const char *timestamp, - char output[17] -); - - -static void draw_shell(const char *heading, const char *footer) -{ - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_BOLD | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - trainlog_terminal_printf(tui_terminal, 1, 2, " %s ", heading); - trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_BOLD | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - - trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_MUTED)); - trainlog_terminal_printf(tui_terminal, trainlog_terminal_rows(tui_terminal) - 2, 2, "%-*s", trainlog_terminal_columns(tui_terminal) - 4, footer); - trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_MUTED)); -} - -/* WHY: one read-only catalogue view combines frozen supplied definitions with - * durable local ones; neither source is silently substituted for the other. */ static bool equipment_text_matches(const char *text, const char *query) { size_t index; @@ -188,34 +84,68 @@ static bool equipment_text_matches(const char *text, const char *query) } static size_t equipment_collect(TrainlogDatabase *database, const char *query, - TrainlogResolvedEquipment *output, size_t capacity) + TrainlogResolvedEquipment *output, size_t capacity, bool *truncated, + bool *failed) { - TrainlogCustomEquipment customs[128]; - const TrainlogEquipment *supplied_matches[128]; - size_t custom_count = 0U; + TrainlogCustomEquipment customs[64]; + const TrainlogEquipment **supplied_matches = NULL; + size_t supplied_capacity = trainlog_equipment_catalog_count(); size_t supplied_count; + size_t custom_offset = 0U; + bool custom_more = true; size_t count = 0U; size_t index; + if (truncated != NULL) *truncated = false; + if (failed != NULL) *failed = false; + if (supplied_capacity > 0U && query != NULL && query[0] != '\0') { + supplied_matches = calloc(supplied_capacity, sizeof(*supplied_matches)); + if (supplied_matches == NULL) { + if (failed != NULL) *failed = true; + return 0U; + } + } supplied_count = query != NULL && query[0] != '\0' - ? trainlog_equipment_catalog_search(query, supplied_matches, 128U) - : trainlog_equipment_catalog_count(); - if (supplied_count > 128U) supplied_count = 128U; - for (index = 0U; index < supplied_count && count < capacity; ++index) { + ? trainlog_equipment_catalog_search(query, supplied_matches, + supplied_capacity) : supplied_capacity; + for (index = 0U; index < supplied_count; ++index) { const TrainlogEquipment *item = query != NULL && query[0] != '\0' ? supplied_matches[index] : trainlog_equipment_catalog_at(index); - if (item != NULL && trainlog_database_resolve_equipment(database, - item->equipment_id, &output[count]) == TRAINLOG_STATUS_OK) ++count; - } - if (trainlog_database_list_custom_equipment(database, customs, 128U, - &custom_count) != TRAINLOG_STATUS_OK) return count; - if (custom_count > 128U) custom_count = 128U; - for (index = 0U; index < custom_count && count < capacity; ++index) { - if (equipment_text_matches(customs[index].display_name, query) || - equipment_text_matches(customs[index].label_name, query) || - equipment_text_matches(customs[index].equipment_type, query)) { - if (trainlog_database_resolve_equipment(database, - customs[index].equipment_id, &output[count]) == TRAINLOG_STATUS_OK) ++count; + if (item == NULL) continue; + if (count >= capacity) { + if (truncated != NULL) *truncated = true; + continue; } + if (trainlog_database_resolve_equipment(database, item->equipment_id, + &output[count]) == TRAINLOG_STATUS_OK) ++count; + } + free(supplied_matches); + while (custom_more) { + size_t custom_count = 0U; + if (trainlog_database_list_custom_equipment_page(database, + custom_offset, customs, 64U, &custom_count, + &custom_more) != TRAINLOG_STATUS_OK) { + if (failed != NULL) *failed = true; + return count; + } + for (index = 0U; index < custom_count; ++index) { + if (!equipment_text_matches(customs[index].display_name, query) && + !equipment_text_matches(customs[index].label_name, query) && + !equipment_text_matches(customs[index].equipment_type, query)) + continue; + if (count >= capacity) { + if (truncated != NULL) *truncated = true; + return count; + } + if (trainlog_database_resolve_equipment(database, + customs[index].equipment_id, + &output[count]) == TRAINLOG_STATUS_OK) ++count; + } + if (custom_count > SIZE_MAX - custom_offset || + (custom_more && custom_count == 0U)) { + if (failed != NULL) *failed = true; + return count; + } + custom_offset += custom_count; } return count; } @@ -227,315 +157,22 @@ static const char *equipment_origin_label(TrainlogEquipmentOrigin origin) return "inconnu"; } -static void generate_custom_equipment_uuid(char output[TRAINLOG_UUID_TEXT_LENGTH + 1U]) +static void generate_custom_equipment_uuid( + char output[TRAINLOG_UUID_TEXT_LENGTH + 1U]) { uuid_t value; - /* CONTRACT: equipment IDs are opaque alongside manifest slugs. A raw - * UUIDv4 avoids extending the frozen creator-prefix namespace. */ uuid_generate_random(value); uuid_unparse_lower(value, output); } -static void screen_equipment_detail(const TrainlogResolvedEquipment *equipment) +static const char *session_type_label(TrainlogSessionType type) { - if (equipment == NULL) return; - draw_shell("Fiche équipement", "Une touche pour revenir"); - trainlog_terminal_printf(tui_terminal, 4, 4, "%s", equipment->display_name); - trainlog_terminal_printf(tui_terminal, 6, 4, "Identifiant : %s", equipment->equipment_id); - trainlog_terminal_printf(tui_terminal, 7, 4, "Étiquette : %s", - equipment->label_name[0] != '\0' ? equipment->label_name : "—"); - trainlog_terminal_printf(tui_terminal, 8, 4, "Type : %s", - equipment->equipment_type[0] != '\0' ? equipment->equipment_type : "—"); - trainlog_terminal_printf(tui_terminal, 9, 4, "Charge : %s · Origine : %s", - equipment->load_semantics[0] != '\0' ? equipment->load_semantics : "—", - equipment_origin_label(equipment->origin)); - wait_key(); + return type == TRAINLOG_SESSION_MAX_TEST ? "Test de max" : "Entraînement"; } -static bool choose_equipment(TrainlogDatabase *database, char *output, size_t output_size) +static const char *session_type_history_label(TrainlogSessionType type) { - TrainlogResolvedEquipment items[256]; - char query[TRAINLOG_NAME_MAX + 1U] = ""; - size_t selected = 0U; - for (;;) { - size_t count = equipment_collect(database, query, items, 256U); - size_t visible = trainlog_terminal_rows(tui_terminal) > 7 - ? (size_t)(trainlog_terminal_rows(tui_terminal) - 7) : 1U; - size_t top = selected > 0U && selected - 1U >= visible - ? selected - visible : 0U; - size_t i; - int key; - if (selected > count) selected = count; - draw_shell("Choisir un équipement", "↑↓ naviguer Entrée choisir / rechercher x aucun Échap annuler"); - trainlog_terminal_printf(tui_terminal, 3, 4, "Recherche : %s", query[0] ? query : "—"); - if (selected == 0U) trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); - trainlog_terminal_printf(tui_terminal, 4, 4, " %-48s ", "Aucun équipement"); - if (selected == 0U) trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); - for (i = 0U; i < visible && top + i < count; ++i) { - size_t absolute = top + i; - if (absolute + 1U == selected) trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); - trainlog_terminal_printf(tui_terminal, 5 + (int)i, 4, "%-46.46s %s", - items[absolute].display_name, equipment_origin_label(items[absolute].origin)); - if (absolute + 1U == selected) trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); - } - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - if (key == 27) return false; - if (key == '/') { - draw_shell("Rechercher un équipement", "Entrée applique · Échap annule"); - if (prompt_text(4, "Recherche", query, sizeof(query), true)) selected = 0U; - } else if (key == 'x' || key == 'X') { - output[0] = '\0'; - return true; - } else if (key == TRAINLOG_KEY_UP && selected > 0U) --selected; - else if (key == TRAINLOG_KEY_DOWN && selected < count) ++selected; - else if (key == '\n' || key == TRAINLOG_KEY_ENTER) { - if (selected == 0U) output[0] = '\0'; - else (void)snprintf(output, output_size, "%s", items[selected - 1U].equipment_id); - return true; - } - } -} - -static void screen_equipment(TrainlogDatabase *database) -{ - char query[TRAINLOG_NAME_MAX + 1U] = ""; - size_t selected = 0U; - for (;;) { - TrainlogResolvedEquipment items[256]; - size_t total = equipment_collect(database, query, items, 256U); - size_t visible = trainlog_terminal_rows(tui_terminal) > 7 - ? (size_t)(trainlog_terminal_rows(tui_terminal) - 7) : 1U; - size_t top; - size_t i; - int key; - if (selected >= total && total > 0U) selected = total - 1U; - top = selected >= visible ? selected - visible + 1U : 0U; - draw_shell("TRAINLOG — Équipements", "↑↓ naviguer Entrée fiche / rechercher n créer b/Échap retour"); - trainlog_terminal_printf(tui_terminal, 3, 4, "Recherche : %s", query[0] ? query : "—"); - for (i = 0U; i < visible && top + i < total; ++i) { - size_t absolute = top + i; - if (absolute == selected) trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); - trainlog_terminal_printf(tui_terminal, 4 + (int)i, 4, "%-46.46s %s", - items[absolute].display_name, equipment_origin_label(items[absolute].origin)); - if (absolute == selected) trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); - } - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - if (key == 27 || key == 'b' || key == 'B') return; - if (key == TRAINLOG_KEY_UP && selected > 0U) --selected; - else if (key == TRAINLOG_KEY_DOWN && selected + 1U < total) ++selected; - else if (key == '/') { - draw_shell("Rechercher un équipement", "Entrée applique · Échap annule"); - if (prompt_text(4, "Recherche", query, sizeof(query), true)) selected = 0U; - } - else if (key == 'n' || key == 'N') { - TrainlogCustomEquipment equipment; - char generated[TRAINLOG_UUID_TEXT_LENGTH + 1U]; - int load_semantics = 2; - (void)memset(&equipment, 0, sizeof(equipment)); - draw_shell("Nouvel équipement", "Échap annule ; Entrée valide chaque champ"); - if (!prompt_text(4, "Nom convivial", equipment.display_name, sizeof(equipment.display_name), false) || - !prompt_text(6, "Nom étiquette (optionnel)", equipment.label_name, sizeof(equipment.label_name), true) || - !prompt_text(8, "Type", equipment.equipment_type, sizeof(equipment.equipment_type), false) || - !prompt_int_value(10, "Charge 1=aucune 2=externe 3=assistance", - 1, 3, 2, &load_semantics)) continue; - (void)snprintf(equipment.load_semantics, sizeof(equipment.load_semantics), "%s", - load_semantics == 1 ? "none" : - (load_semantics == 2 ? "external" : "assistance")); - generate_custom_equipment_uuid(generated); - (void)snprintf(equipment.equipment_id, sizeof(equipment.equipment_id), "%s", generated); - if (trainlog_database_create_custom_equipment(database, &equipment) != TRAINLOG_STATUS_OK) { - status_line("Équipement non créé : nom ou identifiant invalide/conflit.", TRAINLOG_COLOR_ERROR); wait_key(); - } - } else if ((key == '\n' || key == TRAINLOG_KEY_ENTER) && total > 0U) - screen_equipment_detail(&items[selected]); - } -} - -static void wait_key(void) -{ - trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_MUTED)); - trainlog_terminal_printf(tui_terminal, trainlog_terminal_rows(tui_terminal) - 2, 2, "Appuyez sur une touche pour continuer..."); - trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_MUTED)); - trainlog_terminal_render(tui_terminal); - (void)trainlog_terminal_get_key(tui_terminal); -} - -static void status_line(const char *text, TrainlogColorRole role) -{ - trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(role)); - trainlog_terminal_printf(tui_terminal, trainlog_terminal_rows(tui_terminal) - 3, 2, "%-*s", trainlog_terminal_columns(tui_terminal) - 4, text); - trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(role)); -} - -static bool prompt_text( - int row, - const char *label, - char *output, - size_t output_size, - bool allow_empty -) -{ - size_t used = 0U; - int input_column; - int cursor_row; - bool accepted = false; - - if (label == NULL || - output == NULL || - output_size < 2U) { - return false; - } - - output[0] = '\0'; - - trainlog_terminal_printf(tui_terminal, - row, - 2, - "%s", - label - ); - - trainlog_terminal_cursor_yx(tui_terminal, &cursor_row, &input_column); - - (void)cursor_row; - - - (void)trainlog_terminal_cursor_visible(tui_terminal, true); - - for (;;) { - int value; - char encoded[5]; - - trainlog_terminal_move(tui_terminal, - row, - input_column - ); - - trainlog_terminal_clear_to_end(tui_terminal); - - if (used > 0U) { - trainlog_terminal_putn(tui_terminal, - output, - used - ); - } - - trainlog_terminal_render(tui_terminal); - - if (!trainlog_terminal_read_unicode(tui_terminal, &value, encoded)) { - break; - } - - if (value < 0) { - int key = value; - - if (key == TRAINLOG_KEY_ENTER) { - if (allow_empty || - used > 0U) { - accepted = true; - break; - } - - continue; - } - - if (key == TRAINLOG_KEY_BACKSPACE || - key == TRAINLOG_KEY_DELETE) { - if (used > 0U) { - do { - --used; - } while ( - used > 0U && - (((unsigned char)output[used] & - 0xc0U) == 0x80U) - ); - - output[used] = '\0'; - } - - continue; - } - - continue; - } - - if (value == 27) { - accepted = false; - break; - } - - if (value == '\n' || value == '\r') { - if (allow_empty || - used > 0U) { - accepted = true; - break; - } - - continue; - } - - if (value == 8 || value == 127) { - if (used > 0U) { - do { - --used; - } while ( - used > 0U && - (((unsigned char)output[used] & - 0xc0U) == 0x80U) - ); - - output[used] = '\0'; - } - - continue; - } - - if (value >= 32) { - size_t encoded_size; - encoded_size = strlen(encoded); - - if (encoded_size == 0U || - encoded_size > - output_size - used - 1U) { - continue; - } - - (void)memcpy( - output + used, - encoded, - encoded_size - ); - - used += encoded_size; - output[used] = '\0'; - } - } - - - (void)trainlog_terminal_cursor_visible(tui_terminal, false); - - return accepted; -} - -static bool parse_int(const char *text, int minimum, int maximum, int *output) -{ - char *end = NULL; - long value; - - if (text == NULL || output == NULL || text[0] == '\0') { - return false; - } - - value = strtol(text, &end, 10); - if (end == text || *end != '\0' || - value < (long)minimum || value > (long)maximum) { - return false; - } - - *output = (int)value; - return true; + return type == TRAINLOG_SESSION_MAX_TEST ? "[MAX]" : "[ENTRAINEMENT]"; } static bool parse_double_positive(const char *text, double *output) @@ -556,128 +193,6 @@ static bool parse_double_positive(const char *text, double *output) return true; } -static bool parse_double_nonnegative(const char *text, double *output) -{ - char *end = NULL; - double value; - - if (text == NULL || output == NULL || text[0] == '\0') { - return false; - } - - value = strtod(text, &end); - if (end == text || *end != '\0' || !isfinite(value) || value < 0.0) { - return false; - } - - *output = value; - return true; -} - -static bool prompt_int_value( - int row, - const char *label, - int minimum, - int maximum, - int default_value, - int *output -) -{ - char buffer[64]; - - for (;;) { - char decorated[128]; - - (void)snprintf( - decorated, - sizeof(decorated), - "%s [%d]: ", - label, - default_value - ); - - if (!prompt_text(row, decorated, buffer, sizeof(buffer), true)) { - return false; - } - - if (buffer[0] == '\0') { - *output = default_value; - return true; - } - - if (parse_int(buffer, minimum, maximum, output)) { - return true; - } - - status_line("Valeur entière invalide.", TRAINLOG_COLOR_ERROR); - trainlog_terminal_render(tui_terminal); - } -} - -static bool prompt_optional_double( - int row, - const char *label, - bool *present, - double *output -) -{ - char buffer[64]; - - for (;;) { - if (!prompt_text(row, label, buffer, sizeof(buffer), true)) { - return false; - } - - if (buffer[0] == '\0') { - *present = false; - *output = 0.0; - return true; - } - - if (parse_double_positive(buffer, output)) { - *present = true; - return true; - } - - status_line("Nombre positif invalide.", TRAINLOG_COLOR_ERROR); - trainlog_terminal_render(tui_terminal); - } -} - -static bool prompt_optional_nonnegative_double( - int row, - const char *label, - bool *present, - double *output -) -{ - char buffer[64]; - - for (;;) { - if (!prompt_text(row, label, buffer, sizeof(buffer), true)) { - return false; - } - - if (buffer[0] == '\0') { - *present = false; - *output = 0.0; - return true; - } - - if (parse_double_nonnegative(buffer, output)) { - *present = true; - return true; - } - - status_line("Nombre non négatif invalide.", TRAINLOG_COLOR_ERROR); - trainlog_terminal_render(tui_terminal); - } -} - -/* TRAINLOG_EXERCISE_PERFORMANCE_TUI */ - -#define EXERCISE_GRAPH_POINTS 12U - static const char *exercise_load_mode_label( TrainlogLoadMode mode ) @@ -724,7 +239,17 @@ static void format_compact_max_weight( double value, char *output, size_t output_size -); +) +{ + size_t length; + if (output == NULL || output_size == 0U) return; + (void)snprintf(output, output_size, "%.2f", value); + length = strlen(output); + while (length > 0U && output[length - 1U] == '0') + output[--length] = '\0'; + if (length > 0U && output[length - 1U] == '.') + output[--length] = '\0'; +} static void exercise_format_performance( const TrainlogExercisePerformancePoint *point, @@ -899,1319 +424,8 @@ static double exercise_graph_value( return point->weight_kg; } -static void draw_exercise_performance_graph( - const TrainlogExercisePerformancePoint *points, - size_t count, - TrainlogLoadMode mode, - TrainlogTrackingMode tracking_mode, - int top, - int height, - bool measured_max -) -{ - size_t indices[EXERCISE_GRAPH_POINTS]; - size_t selected_count = 0U; - size_t index; - double minimum = 0.0; - double maximum = 0.0; - const int left = 11; - int width = - trainlog_terminal_columns(tui_terminal) - left - 4; - - if (points == NULL || - count == 0U || - height < 3 || - width < 12) { - return; - } - - for (index = 0U; - index < count && - selected_count < EXERCISE_GRAPH_POINTS; - ++index) { - if (points[index].has_performance != 0 && - points[index].load_mode == mode) { - indices[selected_count] = index; - ++selected_count; - } - } - - if (selected_count == 0U) { - trainlog_terminal_printf(tui_terminal, - top + 1, - 4, - "Aucune performance réussie pour ce mode." - ); - return; - } - - minimum = - exercise_graph_value( - &points[indices[0]] - ); - - maximum = minimum; - - for (index = 1U; - index < selected_count; - ++index) { - double value = - exercise_graph_value( - &points[indices[index]] - ); - - if (value < minimum) { - minimum = value; - } - - if (value > maximum) { - maximum = value; - } - } - - if (maximum == minimum) { - minimum -= 1.0; - maximum += 1.0; - } - - if (mode == TRAINLOG_LOAD_ASSISTANCE) { - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_WARNING - ) - ); - - trainlog_terminal_printf(tui_terminal, - top, - left, - measured_max - ? "Assistance mesurée (kg) — moins = mieux" - : "Assistance (kg) — moins = mieux" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_WARNING - ) - ); - } else if (mode == - TRAINLOG_LOAD_EXTERNAL) { - trainlog_terminal_printf(tui_terminal, - top, - left, - measured_max - ? "Max mesuré (kg)" - : "Charge du meilleur set (kg)" - ); - } else if ( - tracking_mode == - TRAINLOG_TRACKING_DURATION - ) { - trainlog_terminal_printf(tui_terminal, - top, - left, - measured_max - ? "Durée max mesurée" - : "Meilleure durée" - ); - } else { - trainlog_terminal_printf(tui_terminal, - top, - left, - measured_max - ? "Répétitions max mesurées" - : "Meilleures répétitions" - ); - } - - trainlog_terminal_printf(tui_terminal, - top + 1, - 2, - "%.1f", - maximum - ); - - trainlog_terminal_printf(tui_terminal, - top + height - 1, - 2, - "%.1f", - minimum - ); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_GRAPH - ) - ); - - { - int previous_x = -1; - int previous_y = -1; - size_t order; - - for (order = selected_count; - order > 0U; - --order) { - size_t chronological = - selected_count - order; - - const TrainlogExercisePerformancePoint *point = - &points[indices[order - 1U]]; - - double value = - exercise_graph_value(point); - - double ratio = - (value - minimum) / - (maximum - minimum); - - int x = - selected_count == 1U - ? left + (width / 2) - : left + - (int)( - (chronological * - (size_t)(width - 1)) / - (selected_count - 1U) - ); - - int y = - top + - height - - 1 - - (int)( - ratio * - (double)(height - 1) - ); - - if (previous_x >= 0 && - x > previous_x) { - int line_x; - - for (line_x = previous_x + 1; - line_x < x; - ++line_x) { - int line_y = - previous_y + - (((y - previous_y) * - (line_x - previous_x)) / - (x - previous_x)); - - trainlog_terminal_draw(tui_terminal, - line_y, - line_x, - (uint32_t)'.' - ); - } - } - - trainlog_terminal_draw(tui_terminal, - y, - x, - chronological + 1U == - selected_count - ? (uint32_t)'O' - : (uint32_t)'*' - ); - - previous_x = x; - previous_y = y; - } - } - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_GRAPH - ) - ); - - { - char oldest[11]; - char newest[11]; - - exercise_short_date( - points[indices[selected_count - 1U]] - .started_at, - oldest - ); - - exercise_short_date( - points[indices[0]].started_at, - newest - ); - - trainlog_terminal_printf(tui_terminal, - top + height, - left, - "%s", - oldest - ); - - trainlog_terminal_printf(tui_terminal, - top + height, - left + width - 10, - "%s", - newest - ); - } -} - /* TRAINLOG_EXERCISE_FRAMES */ -static void exercise_panel( - int top, - int left, - int bottom, - int right, - const char *label -) -{ - TrainlogPanel *panel; - int height; - int width; - - if (top < 0 || - left < 0 || - bottom <= top || - right <= left || - bottom >= trainlog_terminal_rows(tui_terminal) || - right >= trainlog_terminal_columns(tui_terminal)) { - return; - } - - height = bottom - top + 1; - width = right - left + 1; - - panel = tui_panel_create(tui_terminal, height, - width, - top, - left - ); - - if (panel == NULL) { - return; - } - - tui_panel_box(panel); - - if (label != NULL && - label[0] != '\0' && - width > 8) { - tui_panel_style_on( - panel, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - tui_panel_print( - panel, - 0, - 2, - " %.*s ", - width - 6, - label - ); - - tui_panel_style_off( - panel, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - tui_panel_commit(panel); - tui_panel_destroy(panel); -} - -static void screen_exercise_performance( - TrainlogDatabase *database, - const TrainlogExercise *exercise -) -{ - TrainlogExercisePerformancePoint - points[MAX_SESSIONS]; - - size_t count = 0U; - size_t index; - size_t history_limit; - - TrainlogLoadMode graph_mode = - TRAINLOG_LOAD_NONE; - - const TrainlogExercisePerformancePoint *latest = - NULL; - - const TrainlogExercisePerformancePoint *best = - NULL; - - char latest_text[128]; - char best_text[128]; - - bool decorated = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 36; - - int summary_top = - decorated ? 8 : 3; - - int summary_bottom = - decorated ? 14 : 9; - - int graph_top = - decorated ? 15 : 11; - - int graph_bottom = - decorated ? 25 : 20; - - int history_top = - decorated ? 26 : 22; - - int history_bottom = - trainlog_terminal_rows(tui_terminal) - 4; - - if (database == NULL || - exercise == NULL) { - return; - } - - if (trainlog_database_list_exercise_performance( - database, - exercise->exercise_id, - points, - MAX_SESSIONS, - &count - ) != TRAINLOG_STATUS_OK) { - draw_shell( - "Performance exercice", - "Une touche pour revenir" - ); - - status_line( - "Impossible de lire l'historique.", - TRAINLOG_COLOR_ERROR - ); - - wait_key(); - return; - } - - for (index = 0U; - index < count; - ++index) { - if (points[index].has_performance != 0) { - latest = &points[index]; - graph_mode = - points[index].load_mode; - break; - } - } - - if (latest != NULL) { - for (index = 0U; - index < count; - ++index) { - if (points[index].has_performance != 0 && - points[index].load_mode == - graph_mode && - exercise_point_better( - &points[index], - best - )) { - best = &points[index]; - } - } - } - - exercise_format_performance( - latest, - latest_text, - sizeof(latest_text) - ); - - exercise_format_performance( - best, - best_text, - sizeof(best_text) - ); - - if (decorated) { - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - section_ascii_header( - ":: P E R F O R M A N C E E X E R C I C E ::" - ); - - dashboard_panel( - summary_top, - 2, - summary_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "PERFORMANCE" - ); - - dashboard_panel( - graph_top, - 2, - graph_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "EVOLUTION" - ); - - if (history_bottom > - history_top + 2) { - focused_panel( - history_top, - 2, - history_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "HISTORIQUE", - true - ); - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "b/Échap retour" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } else { - draw_shell( - "TRAINLOG — Performance exercice", - "b/Échap retour" - ); - - exercise_panel( - summary_top, - 2, - summary_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "PERFORMANCE" - ); - - exercise_panel( - graph_top, - 2, - graph_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "EVOLUTION" - ); - - if (history_bottom > - history_top + 2) { - exercise_panel( - history_top, - 2, - history_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "HISTORIQUE" - ); - } - } - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 1, - 5, - "%s", - exercise->name - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 2, - 5, - "Séances enregistrées : %zu", - count - ); - - if (latest == NULL) { - trainlog_terminal_printf(tui_terminal, - summary_top + 3, - 5, - "Aucune série réussie enregistrée." - ); - } else { - trainlog_terminal_printf(tui_terminal, - summary_top + 3, - 5, - "Mode suivi : %s", - exercise_load_mode_label( - graph_mode - ) - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 4, - 5, - "Dernier meilleur set : %.*s", - trainlog_terminal_columns(tui_terminal) - 30, - latest_text - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 5, - 5, - "Meilleur set enregistré : %.*s", - trainlog_terminal_columns(tui_terminal) - 33, - best_text - ); - } - - if (latest != NULL) { - int graph_content_top = - graph_top + 1; - - int graph_height = - graph_bottom - - graph_top - - 2; - - draw_exercise_performance_graph( - points, - count, - graph_mode, - exercise->tracking_mode, - graph_content_top, - graph_height, - false - ); - } - - if (history_bottom > - history_top + 2) { - int history_first_row = - history_top + 1; - - history_limit = - history_bottom > - history_first_row - ? (size_t)( - history_bottom - - history_first_row - ) - : 0U; - - for (index = 0U; - index < count && - index < history_limit; - ++index) { - char date[11]; - char summary[128]; - - exercise_short_date( - points[index].started_at, - date - ); - - exercise_format_performance( - &points[index], - summary, - sizeof(summary) - ); - - trainlog_terminal_printf(tui_terminal, - history_first_row + - (int)index, - 5, - "%s %-13s %.*s", - date, - exercise_load_mode_label( - points[index].load_mode - ), - trainlog_terminal_columns(tui_terminal) - 40, - summary - ); - } - } - - trainlog_terminal_render(tui_terminal); - - for (;;) { - int key = trainlog_terminal_get_key(tui_terminal); - - if (key == 'b' || - key == 'B' || - key == 27 || - key == '\n' || - key == TRAINLOG_KEY_ENTER) { - return; - } - } -} - -/* TRAINLOG_MEASURED_MAX_TUI_V1 */ - -static void screen_exercise_measured_max( - TrainlogDatabase *database, - const TrainlogExercise *exercise -) -{ - static const double increments[] = { - 0.5, - 1.0, - 2.5, - 5.0 - }; - - static const double percentages[] = { - 60.0, - 70.0, - 80.0, - 90.0 - }; - - size_t increment_index = 2U; - - if ( - database == NULL || - exercise == NULL - ) { - return; - } - - for (;;) { - TrainlogExercisePerformancePoint - points[MAX_SESSIONS]; - - TrainlogExercisePerformancePoint - max_points[MAX_SESSIONS]; - - TrainlogMeasuredMaxSummary summary; - - size_t count = 0U; - size_t max_count = 0U; - size_t index; - size_t history_limit; - - bool decorated = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - int summary_top = - decorated ? 8 : 3; - - int summary_bottom = - decorated ? 15 : 9; - - int graph_top = - decorated ? 16 : 10; - - int graph_bottom = - decorated ? 23 : 17; - - int history_top = - decorated ? 24 : 18; - - int history_bottom = - trainlog_terminal_rows(tui_terminal) - 4; - - int key; - - if ( - trainlog_database_list_exercise_performance( - database, - exercise->exercise_id, - points, - MAX_SESSIONS, - &count - ) != TRAINLOG_STATUS_OK || - trainlog_measured_max_summarize( - points, - count, - &summary - ) != TRAINLOG_STATUS_OK - ) { - draw_shell( - "TRAINLOG — Max mesuré", - "Une touche pour revenir" - ); - - status_line( - "Impossible de lire les tests de max.", - TRAINLOG_COLOR_ERROR - ); - - wait_key(); - return; - } - - for ( - index = 0U; - index < count && - max_count < MAX_SESSIONS; - ++index - ) { - if ( - points[index].session_type == - TRAINLOG_SESSION_MAX_TEST - ) { - max_points[max_count] = - points[index]; - - ++max_count; - } - } - - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, - trainlog_terminal_rows(tui_terminal) - 1, - trainlog_terminal_columns(tui_terminal) - 1); - - if (decorated) { - section_ascii_header( - ":: M A X M E S U R E ::" - ); - - dashboard_panel( - summary_top, - 2, - summary_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "MAX MESURE" - ); - - dashboard_panel( - graph_top, - 2, - graph_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "EVOLUTION DES TESTS MAX" - ); - - if ( - history_bottom > - history_top + 1 - ) { - exercise_panel( - history_top, - 2, - history_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "HISTORIQUE TESTS MAX" - ); - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "r arrondi charge b/Échap retour" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } else { - draw_shell( - "TRAINLOG — Max mesuré", - "r arrondi charge b/Échap retour" - ); - - exercise_panel( - summary_top, - 2, - summary_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "MAX MESURE" - ); - - exercise_panel( - graph_top, - 2, - graph_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "EVOLUTION" - ); - - if ( - history_bottom > - history_top + 1 - ) { - exercise_panel( - history_top, - 2, - history_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "HISTORIQUE" - ); - } - } - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 1, - 5, - "%s", - exercise->name - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 2, - 5, - "Tests max : %zu · réussis : %zu", - summary.test_count, - summary.successful_test_count - ); - - if (!summary.found) { - trainlog_terminal_printf(tui_terminal, - summary_top + 3, - 5, - "Aucun max mesuré réussi." - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 4, - 5, - "Seules les séances explicitement « Test de max » comptent." - ); - } else { - char current_text[128]; - char record_text[128]; - char current_date[11]; - char record_date[11]; - TrainlogResolvedEquipment current_equipment; - const char *current_equipment_label = "aucun"; - - exercise_format_performance( - &summary.current, - current_text, - sizeof(current_text) - ); - - exercise_format_performance( - &summary.record, - record_text, - sizeof(record_text) - ); - - exercise_short_date( - summary.current.started_at, - current_date - ); - - exercise_short_date( - summary.record.started_at, - record_date - ); - - if (summary.current.equipment_id[0] != '\0') { - current_equipment_label = - trainlog_database_resolve_equipment(database, - summary.current.equipment_id, ¤t_equipment) == - TRAINLOG_STATUS_OK - ? current_equipment.display_name - : summary.current.equipment_id; - } - - trainlog_terminal_printf(tui_terminal, - summary_top + 3, - 5, - "Actuel : %s · %s · Machine : %.*s", - current_date, - current_text, - trainlog_terminal_columns(tui_terminal) - 48, - current_equipment_label - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 4, - 5, - "Record même mode : %s · %.*s", - record_date, - trainlog_terminal_columns(tui_terminal) - 42, - record_text - ); - - if ( - summary.current.load_mode == - TRAINLOG_LOAD_EXTERNAL - ) { - double working[4]; - bool valid = true; - - for ( - index = 0U; - index < 4U; - ++index - ) { - if ( - trainlog_measured_max_working_load( - &summary.current, - percentages[index], - increments[increment_index], - &working[index] - ) != TRAINLOG_STATUS_OK - ) { - valid = false; - break; - } - } - - if (valid) { - trainlog_terminal_printf(tui_terminal, - summary_top + 5, - 5, - "Travail : 60%% %.1f · 70%% %.1f · 80%% %.1f · 90%% %.1f kg", - working[0], - working[1], - working[2], - working[3] - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 6, - 5, - "Arrondi : %.1f kg (r pour changer) · aucun 1RM estimé", - increments[increment_index] - ); - } - } else if ( - summary.current.load_mode == - TRAINLOG_LOAD_ASSISTANCE - ) { - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_WARNING - ) - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 5, - 5, - "Assistance : moins de kg = mieux." - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 6, - 5, - "Pourcentages de charge non applicables à l'assistance." - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_WARNING - ) - ); - } else { - trainlog_terminal_printf(tui_terminal, - summary_top + 5, - 5, - "Sans charge externe : pourcentages non applicables." - ); - - trainlog_terminal_printf(tui_terminal, - summary_top + 6, - 5, - "Le max reste une valeur réellement réalisée, jamais estimée." - ); - } - } - - if ( - summary.found && - max_count > 0U - ) { - int graph_content_top = - graph_top + 1; - - int graph_height = - graph_bottom - - graph_top - - 2; - - draw_exercise_performance_graph( - max_points, - max_count, - summary.current.load_mode, - exercise->tracking_mode, - graph_content_top, - graph_height, - true - ); - } else { - trainlog_terminal_printf(tui_terminal, - graph_top + 2, - 5, - "Aucun point de max mesuré à tracer." - ); - } - - if ( - history_bottom > - history_top + 1 - ) { - int first_row = - history_top + 1; - - history_limit = - history_bottom > - first_row - ? (size_t)( - history_bottom - - first_row - ) - : 0U; - - for ( - index = 0U; - index < max_count && - index < history_limit; - ++index - ) { - char date[11]; - char text[128]; - - exercise_short_date( - max_points[index].started_at, - date - ); - - exercise_format_performance( - &max_points[index], - text, - sizeof(text) - ); - - trainlog_terminal_printf(tui_terminal, - first_row + - (int)index, - 5, - "%s %-13s %.*s", - date, - exercise_load_mode_label( - max_points[index] - .load_mode - ), - trainlog_terminal_columns(tui_terminal) - 40, - text - ); - } - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if ( - key == 'b' || - key == 'B' || - key == 27 || - key == '\n' || - key == TRAINLOG_KEY_ENTER - ) { - return; - } - - if ( - key == 'r' || - key == 'R' - ) { - increment_index = - ( - increment_index + 1U - ) % - ( - sizeof(increments) / - sizeof(increments[0]) - ); - } - } -} - -/* TRAINLOG_SECTION_ASCII_HEADER */ - -static void section_ascii_header( - const char *subtitle -) -{ - int header_left = 2; - int header_right = trainlog_terminal_columns(tui_terminal) - 3; - int subtitle_width; - - if (header_right - header_left < 20) { - return; - } - - /* WHY: one restrained three-row plaque provides a recognizable terminal - * identity without consuming the panel rows reserved by every screen. */ - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style(TRAINLOG_COLOR_MUTED)); - trainlog_terminal_box(tui_terminal, 1, header_left, 3, header_right); - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style(TRAINLOG_COLOR_MUTED)); - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, 1, header_left + 3, - "◆ TRAINLOG ◆"); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - if (subtitle != NULL && subtitle[0] != '\0') { - subtitle_width = header_right - header_left - 4; - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - 2, - header_left + 2, - "%.*s", - subtitle_width, - subtitle - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } -} - -static void section_scrollbar( - int top, - int bottom, - int column, - size_t selected, - size_t count, - size_t visible -) -{ - int track_height; - int row; - int thumb; - - if (bottom <= top || - count <= visible || - count <= 1U) { - return; - } - - track_height = - bottom - top + 1; - - if (track_height < 2) { - return; - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - for (row = top; - row <= bottom; - ++row) { - trainlog_terminal_draw(tui_terminal, - row, - column, - 0x2502U - ); - } - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - thumb = - top + - (int)( - (selected * - (size_t)(track_height - 1)) / - (count - 1U) - ); - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_draw(tui_terminal, - thumb, - column, - 0x2593U - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); -} - static bool secondary_zone_contains( char secondary[][TRAINLOG_ZONE_ID_MAX + 1U], size_t count, @@ -2231,95 +445,6 @@ static bool secondary_zone_contains( /* WHY: terminal users select translated catalogue rows; accepting raw IDs * would leak wire identity into product behavior and permit unknown values. */ -static bool choose_body_zones( - char primary[TRAINLOG_ZONE_ID_MAX + 1U], - char secondary[][TRAINLOG_ZONE_ID_MAX + 1U], - size_t *secondary_count, - bool require_primary -) -{ - size_t selected = 0U; - size_t count = trainlog_body_zone_catalog_count(); - if (primary == NULL || secondary == NULL || secondary_count == NULL || - count == 0U || count > MAX_BODY_ZONES || *secondary_count > MAX_BODY_ZONES) - return false; - for (;;) { - size_t index; - int key; - draw_shell("Zones corporelles", - "↑↓ naviguer p principale Espace secondaire n non renseignée Entrée valider Échap annuler"); - for (index = 0U; index < count; ++index) { - const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index); - bool is_primary = zone != NULL && strcmp(primary, zone->zone_id) == 0; - bool is_secondary = zone != NULL && secondary_zone_contains( - secondary, *secondary_count, zone->zone_id, NULL); - if (zone == NULL) continue; - if (index == selected) - trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); - trainlog_terminal_printf(tui_terminal, 4 + (int)index, 4, - " %s%-27s %s ", zone->parent_zone_id != NULL ? " ↳ " : "", - zone->display_name, - zone->is_group ? "[groupe]" : is_primary ? "[principale]" : - is_secondary ? "[secondaire]" : "[ ]"); - if (index == selected) - trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); - } - if (primary[0] == '\0') - trainlog_terminal_printf(tui_terminal, 4 + (int)count + 1, 4, - "Zone principale : Non renseignée"); - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - if (key == 27) return false; - if (key == TRAINLOG_KEY_UP) - selected = selected > 0U ? selected - 1U : count - 1U; - else if (key == TRAINLOG_KEY_DOWN) - selected = selected + 1U < count ? selected + 1U : 0U; - else if (key == 'n' || key == 'N') { - primary[0] = '\0'; - *secondary_count = 0U; - } - else if (key == 'p' || key == 'P') { - const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(selected); - size_t secondary_index; - if (zone != NULL && !zone->is_group) { - (void)snprintf(primary, TRAINLOG_ZONE_ID_MAX + 1U, "%s", zone->zone_id); - if (secondary_zone_contains(secondary, *secondary_count, - zone->zone_id, &secondary_index)) { - size_t move; - for (move = secondary_index; move + 1U < *secondary_count; ++move) - (void)memcpy(secondary[move], secondary[move + 1U], - sizeof(secondary[move])); - --*secondary_count; - } - } - } else if (key == ' ') { - const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(selected); - size_t secondary_index; - if (zone != NULL && primary[0] != '\0' && !zone->is_group && - strcmp(primary, zone->zone_id) != 0) { - if (secondary_zone_contains(secondary, *secondary_count, - zone->zone_id, &secondary_index)) { - size_t move; - for (move = secondary_index; move + 1U < *secondary_count; ++move) - (void)memcpy(secondary[move], secondary[move + 1U], - sizeof(secondary[move])); - --*secondary_count; - } else if (*secondary_count < MAX_BODY_ZONES) { - (void)snprintf(secondary[*secondary_count], - TRAINLOG_ZONE_ID_MAX + 1U, "%s", zone->zone_id); - ++*secondary_count; - } - } - } else if (key == '\n' || key == TRAINLOG_KEY_ENTER) { - if (require_primary && primary[0] == '\0') { - status_line("Une zone principale est requise.", TRAINLOG_COLOR_ERROR); - continue; - } - return true; - } - } -} - static bool load_exercise_body_zones( TrainlogDatabase *database, const char *exercise_id, @@ -2348,43 +473,6 @@ static bool load_exercise_body_zones( return true; } -static bool edit_exercise_body_zones( - TrainlogDatabase *database, - TrainlogExercise *exercise -) -{ - char primary[TRAINLOG_ZONE_ID_MAX + 1U]; - char secondary[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; - const char *secondary_ids[MAX_BODY_ZONES]; - size_t secondary_count = 0U; - size_t index; - char new_name[TRAINLOG_NAME_MAX + 1U]; - char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U]; - const char *saved_name; - TrainlogStatus status; - if (!load_exercise_body_zones(database, exercise->exercise_id, primary, - secondary, &secondary_count)) return false; - if (!choose_body_zones(primary, secondary, &secondary_count, false)) return false; - draw_shell("Modifier l'exercice", "Nom vide = conserver · Échap annule"); - if (!prompt_text(4, "Nouveau nom : ", new_name, sizeof(new_name), true)) return false; - saved_name = new_name[0] == '\0' ? exercise->name : new_name; - status = trainlog_catalog_normalize_name(saved_name, normalized, sizeof(normalized)); - if (status != TRAINLOG_STATUS_OK) return false; - for (index = 0U; index < secondary_count; ++index) secondary_ids[index] = secondary[index]; - status = trainlog_database_update_exercise_profiled(database, - exercise->exercise_id, saved_name, normalized, exercise->tracking_mode, - exercise->recording_mode, exercise->data_fields, - primary[0] == '\0' ? NULL : primary, secondary_ids, secondary_count); - if (status == TRAINLOG_STATUS_OK) - (void)snprintf(exercise->name, sizeof(exercise->name), "%s", saved_name); - status_line(status == TRAINLOG_STATUS_OK ? "✓ Exercice modifié." : - status == TRAINLOG_STATUS_CONFLICT ? "Conflit : nom ou profil déjà utilisé." : - "Impossible de modifier l'exercice.", - status == TRAINLOG_STATUS_OK ? TRAINLOG_COLOR_SUCCESS : TRAINLOG_COLOR_ERROR); - wait_key(); - return status == TRAINLOG_STATUS_OK; -} - /* CONTRACT: knowledge text is read-only catalogue data. The screen stores * display lines locally so navigation never changes the scientific record. */ #define KNOWLEDGE_LINES_MAX 128U @@ -2487,1027 +575,6 @@ static void knowledge_add_zones(char lines[][KNOWLEDGE_LINE_MAX], size_t *count, value->secondary_zone_ids, width); } -static void screen_exercise_knowledge(const TrainlogExercise *exercise) -{ - const TrainlogExerciseKnowledge *record; - const TrainlogKnowledgeInterpretation *value; - const char *label; - char lines[KNOWLEDGE_LINES_MAX][KNOWLEDGE_LINE_MAX]; - size_t line_count; - size_t scroll = 0U; - int key; - if (exercise == NULL) return; - record = trainlog_exercise_knowledge_lookup(exercise->exercise_id); - value = record == NULL ? NULL : record->interpretation; - label = "Connaissances validées"; - if (value == NULL && record != NULL && record->conditional_interpretation != NULL) { - value = record->conditional_interpretation; - label = "Interprétation conditionnelle — à confirmer"; - } - for (;;) { - int rows = trainlog_terminal_rows(tui_terminal); - int columns = trainlog_terminal_columns(tui_terminal); - int viewport_rows; - int width; - size_t index; - if (rows < 20 || columns < 72) { - draw_shell("TRAINLOG — Connaissances exercice", "b/Échap retour"); - trainlog_terminal_printf(tui_terminal, 3, 4, "Terminal trop petit — minimum 72x20."); - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - if (key == 27 || key == 'b' || key == 'B' || key == 'k' || key == 'K') return; - continue; - } - width = columns - 8; - viewport_rows = rows - 6; - line_count = 0U; - knowledge_add_wrapped(lines, &line_count, exercise->name, width); - if (record == NULL || value == NULL) { - knowledge_add_wrapped(lines, &line_count, - record == NULL ? "Aucune fiche scientifique pour cet identifiant." - : "Interprétation scientifique non résolue.", width); - } else { - knowledge_add_wrapped(lines, &line_count, label, width); - knowledge_add_ids(lines, &line_count, "Mouvement :", value->pattern_ids, false, width); - knowledge_add_wrapped(lines, &line_count, "Confiance :", width); - knowledge_add_wrapped(lines, &line_count, value->confidence, width); - knowledge_add_ids(lines, &line_count, "Muscles principaux :", - value->primary_muscle_ids, true, width); - knowledge_add_ids(lines, &line_count, "Secondaires :", - value->secondary_muscle_ids, true, width); - knowledge_add_ids(lines, &line_count, "Stabilisateurs :", - value->stabilizer_muscle_ids, true, width); - knowledge_add_zones(lines, &line_count, value, width); - knowledge_add_plain_ids(lines, &line_count, "Sources :", value->source_refs, width); - } - if (scroll >= line_count) scroll = line_count == 0U ? 0U : line_count - 1U; - draw_shell("TRAINLOG — Connaissances exercice", - "↑↓ défiler Pg↑/Pg↓ page b/Échap/k retour"); - for (index = 0U; index < (size_t)viewport_rows && scroll + index < line_count; ++index) - trainlog_terminal_printf(tui_terminal, 3 + (int)index, 4, "%s", lines[scroll + index]); - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - if (key == 27 || key == 'b' || key == 'B' || key == 'k' || key == 'K') return; - if (key == TRAINLOG_KEY_UP && scroll > 0U) --scroll; - else if (key == TRAINLOG_KEY_DOWN && scroll + (size_t)viewport_rows < line_count) ++scroll; - else if (key == TRAINLOG_KEY_PAGE_UP) { - scroll = scroll > (size_t)viewport_rows ? scroll - (size_t)viewport_rows : 0U; - } else if (key == TRAINLOG_KEY_PAGE_DOWN && scroll + (size_t)viewport_rows < line_count) { - size_t maximum = line_count - (size_t)viewport_rows; - scroll = scroll + (size_t)viewport_rows < maximum ? scroll + (size_t)viewport_rows : maximum; - } - } -} - -static void screen_exercise_detail( - TrainlogDatabase *database, - TrainlogExercise *exercise -) -{ - TrainlogResolvedEquipment explicit_items[64]; - TrainlogResolvedEquipment historic_items[128]; - size_t explicit_count = 0U; - size_t historic_count = 0U; - size_t selected = 0U; - size_t index; - - if (database == NULL || exercise == NULL) return; - for (index = 0U; index < trainlog_equipment_catalog_relation_count() && - explicit_count < 64U; ++index) { - const TrainlogExerciseEquipmentRelation *relation = - trainlog_equipment_catalog_relation_at(index); - if (relation != NULL && strcmp(relation->exercise_id, - exercise->exercise_id) == 0 && - trainlog_database_resolve_equipment(database, relation->equipment_id, - &explicit_items[explicit_count]) == TRAINLOG_STATUS_OK) { - ++explicit_count; - } - } - if (trainlog_database_list_exercise_equipment(database, - exercise->exercise_id, historic_items, 128U, - &historic_count) != TRAINLOG_STATUS_OK) historic_count = 0U; - if (historic_count > 128U) historic_count = 128U; - - for (;;) { - size_t total = explicit_count + historic_count; - char primary[TRAINLOG_ZONE_ID_MAX + 1U]; - char secondary[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; - size_t secondary_count = 0U; - int row = 10; - int key; - if (total > 0U && selected >= total) selected = total - 1U; - draw_shell("TRAINLOG — Fiche exercice", - "↑↓ équipement Entrée fiche k connaissances p performance m max b/Échap retour"); - trainlog_terminal_printf(tui_terminal, 3, 4, "%s", exercise->name); - trainlog_terminal_printf(tui_terminal, 4, 4, "Identifiant : %s · suivi : %s", - exercise->exercise_id, - exercise->tracking_mode == TRAINLOG_TRACKING_REPS ? "répétitions" : "durée"); - if (load_exercise_body_zones(database, exercise->exercise_id, primary, - secondary, &secondary_count) && primary[0] != '\0') { - const TrainlogBodyZone *primary_zone = trainlog_body_zone_catalog_lookup(primary); - const TrainlogBodyZone *group = primary_zone != NULL && - primary_zone->parent_zone_id != NULL - ? trainlog_body_zone_catalog_lookup(primary_zone->parent_zone_id) : NULL; - char secondary_names[256] = ""; - for (index = 0U; index < secondary_count; ++index) { - const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup(secondary[index]); - size_t used = strlen(secondary_names); - if (zone != NULL && used < sizeof(secondary_names) - 1U) - (void)snprintf(secondary_names + used, sizeof(secondary_names) - used, - "%s%s", used > 0U ? ", " : "", zone->display_name); - } - trainlog_terminal_printf(tui_terminal, 5, 4, "Zone principale : %s", - primary_zone != NULL ? primary_zone->display_name : primary); - trainlog_terminal_printf(tui_terminal, 6, 4, "Zones secondaires : %s", - secondary_names[0] != '\0' ? secondary_names : "Aucune"); - trainlog_terminal_printf(tui_terminal, 7, 4, "Groupe : %s", - group != NULL ? group->display_name : "Aucun"); - } else { - trainlog_terminal_printf(tui_terminal, 5, 4, "Zone : Non renseignée"); - } - trainlog_terminal_printf(tui_terminal, 9, 4, - "Relations explicites du manifeste (%zu)", explicit_count); - if (explicit_count == 0U) trainlog_terminal_printf(tui_terminal, row++, 6, "— aucune"); - for (index = 0U; index < explicit_count; ++index, ++row) { - if (selected == index) trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); - trainlog_terminal_printf(tui_terminal, row, 6, " %-58.58s ", explicit_items[index].display_name); - if (selected == index) trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); - } - ++row; - trainlog_terminal_printf(tui_terminal, row++, 4, - "Équipements utilisés historiquement (%zu)", historic_count); - if (historic_count == 0U) trainlog_terminal_printf(tui_terminal, row++, 6, "— aucun"); - for (index = 0U; index < historic_count && row < trainlog_terminal_rows(tui_terminal) - 3; - ++index, ++row) { - size_t absolute = explicit_count + index; - if (selected == absolute) trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE); - trainlog_terminal_printf(tui_terminal, row, 6, " %-58.58s [%s] ", - historic_items[index].display_name, - equipment_origin_label(historic_items[index].origin)); - if (selected == absolute) trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_REVERSE); - } - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - if (key == 27 || key == 'b' || key == 'B') return; - if (total > 0U && key == TRAINLOG_KEY_UP) - selected = selected > 0U ? selected - 1U : total - 1U; - else if (total > 0U && key == TRAINLOG_KEY_DOWN) - selected = selected + 1U < total ? selected + 1U : 0U; - else if (total > 0U && (key == '\n' || key == TRAINLOG_KEY_ENTER)) - screen_equipment_detail(selected < explicit_count - ? &explicit_items[selected] : &historic_items[selected - explicit_count]); - else if (key == 'p' || key == 'P') screen_exercise_performance(database, exercise); - else if (key == 'm' || key == 'M') screen_exercise_measured_max(database, exercise); - else if (key == 'k' || key == 'K') screen_exercise_knowledge(exercise); - else if (key == 'e' || key == 'E') (void)edit_exercise_body_zones(database, exercise); - } -} - -static void screen_exercises( - TrainlogDatabase *database -) -{ - TrainlogExercise exercises[MAX_EXERCISES]; - size_t selected = 0U; - int nav_selected = 3; - int focus = 1; - int zone_filter = -1; /* -1 all, catalogue index, count = unclassified. */ - char search[TRAINLOG_NAME_MAX + 1U] = ""; - - for (;;) { - size_t count = 0U; - size_t top = 0U; - size_t index; - size_t zone_count = trainlog_body_zone_catalog_count(); - char normalized_search[(TRAINLOG_NAME_MAX * 4U) + 1U] = ""; - const char *filter_zone_id = NULL; - const char *filter_label = "Toutes les zones"; - bool unclassified_only = zone_filter == (int)zone_count; - - bool large_layout = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - bool framed = - trainlog_terminal_columns(tui_terminal) >= 90 && - trainlog_terminal_rows(tui_terminal) >= 24; - - int list_top = - large_layout - ? 11 - : 3; - - int list_bottom = - trainlog_terminal_rows(tui_terminal) - 4; - - int first_row = - list_top + 2; - - int visible_rows = - framed - ? list_bottom - - first_row - : trainlog_terminal_rows(tui_terminal) - 7; - - int key; - - if (visible_rows < 1) { - return; - } - - if (search[0] != '\0' && trainlog_catalog_normalize_name(search, - normalized_search, sizeof(normalized_search)) != TRAINLOG_STATUS_OK) { - normalized_search[0] = '\0'; - } - if (zone_filter >= 0 && zone_filter < (int)zone_count) { - const TrainlogBodyZone *zone = - trainlog_body_zone_catalog_at((size_t)zone_filter); - if (zone != NULL) { - filter_zone_id = zone->zone_id; - filter_label = zone->display_name; - } - } else if (unclassified_only) { - filter_label = "Non renseignés"; - } - if (trainlog_database_list_exercises_filtered( - database, - normalized_search, - filter_zone_id, - true, - false, - unclassified_only, - exercises, - MAX_EXERCISES, - &count - ) != TRAINLOG_STATUS_OK) { - return; - } - - if (count > 0U && - selected >= count) { - selected = - count - 1U; - } - - if (count > 0U && - selected >= - (size_t)visible_rows) { - top = - selected - - (size_t)visible_rows + - 1U; - } - - if (large_layout) { - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - section_ascii_header( - ":: E X E R C I C E S ::" - ); - - primary_top_navbar( - 3, - nav_selected, - focus == 0 - ); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "Tab zone ↑↓ catalogue / recherche z filtre x effacer Entrée fiche (e modifier) a ajouter b/Échap retour" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } else { - draw_shell( - "TRAINLOG — Exercices", - "↑↓ naviguer / recherche z filtre x effacer Entrée fiche a ajouter b/Échap retour" - ); - } - - trainlog_terminal_printf(tui_terminal, first_row - 1, framed ? 5 : 4, - "Filtre : %s · Recherche : %s", filter_label, - search[0] != '\0' ? search : "—"); - - if (framed) { - if (large_layout) { - focused_panel( - list_top, - 2, - list_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "CATALOGUE", - focus == 1 - ); - } else { - exercise_panel( - list_top, - 2, - list_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "CATALOGUE" - ); - } - } - - if (count == 0U) { - trainlog_terminal_printf(tui_terminal, - first_row + 1, - framed ? 5 : 4, - "Aucun exercice." - ); - } - - for (index = 0U; - index < (size_t)visible_rows && - top + index < count; - ++index) { - size_t absolute = - top + index; - - int item_row = - first_row + - (int)index; - - int item_col = - framed ? 5 : 4; - - if (focus == 1 && - absolute == selected) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_printf(tui_terminal, - item_row, - item_col, - " %-42s [%s] ", - exercises[absolute].name, - exercises[absolute].tracking_mode == - TRAINLOG_TRACKING_REPS - ? "reps" - : "durée" - ); - - if (focus == 1 && - absolute == selected) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - } - - if (large_layout) { - section_scrollbar( - first_row, - list_bottom - 1, - trainlog_terminal_columns(tui_terminal) - 5, - selected, - count, - (size_t)visible_rows - ); - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if (key == '/') { - draw_shell("Recherche exercices", "Préfixe vide = tous · Échap annule"); - if (prompt_text(4, "Préfixe : ", search, sizeof(search), true)) selected = 0U; - continue; - } - if (key == 'z' || key == 'Z') { - ++zone_filter; - if (zone_filter > (int)zone_count) zone_filter = -1; - selected = 0U; - continue; - } - if (key == 'x' || key == 'X') { - search[0] = '\0'; - zone_filter = -1; - selected = 0U; - continue; - } - - if (large_layout && - (key == TRAINLOG_KEY_TAB || - key == TRAINLOG_KEY_SHIFT_TAB)) { - focus = - focus == 0 - ? 1 - : 0; - continue; - } - - if (large_layout && -primary_top_nav_forward(key)) { - return; - } - - if (key == 'b' || - key == 'B' || - key == 27) { - return; - } - - if (large_layout && - focus == 0) { - if (key == TRAINLOG_KEY_LEFT) { - nav_selected = - nav_selected > 0 - ? nav_selected - 1 - : PRIMARY_NAV_COUNT - 1; - } else if (key == TRAINLOG_KEY_RIGHT) { - nav_selected = - nav_selected < PRIMARY_NAV_COUNT - 1 - ? nav_selected + 1 - : 0; - } else if ( - key == '\n' || - key == TRAINLOG_KEY_ENTER - ) { -if (primary_top_nav_activate( - nav_selected - )) { - return; - } - } - - continue; - } - - if (count > 0U && - key == TRAINLOG_KEY_UP) { - selected = - selected > 0U - ? selected - 1U - : 0U; - - continue; - } - - if (count > 0U && - key == TRAINLOG_KEY_DOWN) { - selected = - selected + 1U < count - ? selected + 1U - : count - 1U; - - continue; - } - - if (count > 0U && - key == TRAINLOG_KEY_PAGE_UP) { - size_t jump = - (size_t)visible_rows; - - selected = - selected > jump - ? selected - jump - : 0U; - - continue; - } - - if (count > 0U && - key == TRAINLOG_KEY_PAGE_DOWN) { - size_t jump = - (size_t)visible_rows; - - selected = - selected + jump < count - ? selected + jump - : count - 1U; - - continue; - } - - if (count > 0U && - (key == 'm' || - key == 'M')) { - screen_exercise_measured_max( - database, - &exercises[selected] - ); - - continue; - } - - if (count > 0U && - (key == '\n' || - key == TRAINLOG_KEY_ENTER)) { - screen_exercise_detail( - database, - &exercises[selected] - ); - - continue; - } - - if (count > 0U && (key == 'p' || key == 'P')) { - screen_exercise_performance(database, &exercises[selected]); - continue; - } - - if (key == 'a' || - key == 'A') { - char name[TRAINLOG_NAME_MAX + 1U]; - int mode = 1; - int organization = 1; - int wants_speed = 0; - int wants_distance = 0; - TrainlogExerciseDataFields data_fields = 0U; - TrainlogExercise created; - TrainlogStatus status; - char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U] = ""; - char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; - const char *secondary_ids[MAX_BODY_ZONES]; - size_t secondary_count = 0U; - - draw_shell( - "Nouvel exercice", - "Entrée valide chaque champ · Échap annule" - ); - - if (!prompt_text( - 4, - "Nom : ", - name, - sizeof(name), - false - )) { - continue; - } - - if (!prompt_int_value( - 5, - "Mode 1=reps 2=durée", - 1, - 2, - 1, - &mode - )) { - continue; - } - - if (!prompt_int_value( - 8, - "Organisation (1 séries, 2 continu)", - 1, - 2, - 1, - &organization - )) { - continue; - } - - if (organization == 2) { - mode = 2; - - if (!prompt_int_value( - 10, - "Mesurer la vitesse km/h ? (0 non, 1 oui)", - 0, - 1, - 1, - &wants_speed - )) { - continue; - } - - if (!prompt_int_value( - 12, - "Mesurer la distance km ? (0 non, 1 oui)", - 0, - 1, - 0, - &wants_distance - )) { - continue; - } - - if (wants_speed != 0) { - data_fields |= - TRAINLOG_EXERCISE_DATA_SPEED_KMH; - } - - if (wants_distance != 0) { - data_fields |= - TRAINLOG_EXERCISE_DATA_DISTANCE_KM; - } - } - if (!choose_body_zones(primary_zone, secondary_zones, - &secondary_count, organization == 1)) continue; - for (index = 0U; index < secondary_count; ++index) - secondary_ids[index] = secondary_zones[index]; - status = - trainlog_catalog_create_exercise_profiled_with_zones( - database, - name, - mode == 1 - ? TRAINLOG_TRACKING_REPS - : TRAINLOG_TRACKING_DURATION, - organization == 2 - ? TRAINLOG_RECORDING_CONTINUOUS - : TRAINLOG_RECORDING_SETS, - data_fields, - primary_zone[0] == '\0' ? NULL : primary_zone, - secondary_ids, - secondary_count, - &created - ); - - if (status == - TRAINLOG_STATUS_OK) { - status_line( - "✓ Exercice ajouté.", - TRAINLOG_COLOR_SUCCESS - ); - } else if ( - status == - TRAINLOG_STATUS_CONFLICT - ) { - status_line( - "Doublon détecté.", - TRAINLOG_COLOR_WARNING - ); - } else { - status_line( - "Impossible d'ajouter l'exercice.", - TRAINLOG_COLOR_ERROR - ); - } - - wait_key(); - } - } -} - -static bool create_exercise_inline( - TrainlogDatabase *database -) -{ - char name[TRAINLOG_NAME_MAX + 1U]; - int mode = 1; - int organization = 1; - int wants_speed = 0; - int wants_distance = 0; - TrainlogExerciseDataFields data_fields = 0U; - TrainlogExercise created; - TrainlogStatus status; - char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U] = ""; - char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; - const char *secondary_ids[MAX_BODY_ZONES]; - size_t secondary_count = 0U; - size_t index; - - draw_shell( - "Nouvel exercice", - "Entrée valide chaque champ · Échap annule" - ); - - if (!prompt_text( - 4, - "Nom : ", - name, - sizeof(name), - false - )) { - return false; - } - - if (!prompt_int_value( - 5, - "Mode 1=reps 2=durée", - 1, - 2, - 1, - &mode - )) { - return false; - } - - if (!prompt_int_value( - 8, - "Organisation (1 séries, 2 continu)", - 1, - 2, - 1, - &organization - )) { - return false; - } - - if (organization == 2) { - mode = 2; - - if (!prompt_int_value( - 10, - "Mesurer la vitesse km/h ? (0 non, 1 oui)", - 0, - 1, - 1, - &wants_speed - )) { - return false; - } - - if (!prompt_int_value( - 12, - "Mesurer la distance km ? (0 non, 1 oui)", - 0, - 1, - 0, - &wants_distance - )) { - return false; - } - - if (wants_speed != 0) { - data_fields |= - TRAINLOG_EXERCISE_DATA_SPEED_KMH; - } - - if (wants_distance != 0) { - data_fields |= - TRAINLOG_EXERCISE_DATA_DISTANCE_KM; - } - } - if (!choose_body_zones(primary_zone, secondary_zones, - &secondary_count, organization == 1)) return false; - for (index = 0U; index < secondary_count; ++index) - secondary_ids[index] = secondary_zones[index]; - status = - trainlog_catalog_create_exercise_profiled_with_zones( - database, - name, - mode == 1 - ? TRAINLOG_TRACKING_REPS - : TRAINLOG_TRACKING_DURATION, - organization == 2 - ? TRAINLOG_RECORDING_CONTINUOUS - : TRAINLOG_RECORDING_SETS, - data_fields, - primary_zone[0] == '\0' ? NULL : primary_zone, - secondary_ids, - secondary_count, - &created - ); - - if (status == TRAINLOG_STATUS_OK) { - status_line( - "✓ Exercice ajouté au catalogue.", - TRAINLOG_COLOR_SUCCESS - ); - } else if ( - status == TRAINLOG_STATUS_CONFLICT - ) { - status_line( - "Doublon détecté.", - TRAINLOG_COLOR_WARNING - ); - } else { - status_line( - "Impossible d'ajouter l'exercice.", - TRAINLOG_COLOR_ERROR - ); - } - - wait_key(); - - return status == TRAINLOG_STATUS_OK; -} - -static bool choose_exercise( - TrainlogDatabase *database, - TrainlogExercise *output -) -{ - TrainlogExercise exercises[MAX_EXERCISES]; - size_t selected = 0U; - - for (;;) { - size_t count = 0U; - size_t top = 0U; - size_t index; - bool large_layout = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - int list_top = - large_layout ? 8 : 3; - int list_bottom = - trainlog_terminal_rows(tui_terminal) - 4; - int first_row = - list_top + 1; - int visible_rows = - list_bottom - first_row; - int key; - - if (visible_rows < 1) { - return false; - } - - if (trainlog_database_list_exercises( - database, - exercises, - MAX_EXERCISES, - &count - ) != TRAINLOG_STATUS_OK) { - return false; - } - - if (count == 0U) { - draw_shell( - "Choisir un exercice", - "a ajouter un exercice · Échap annuler" - ); - - trainlog_terminal_printf(tui_terminal, - 4, - 4, - "Aucun exercice disponible." - ); - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if (key == 'a' || - key == 'A') { - (void)create_exercise_inline( - database - ); - continue; - } - - if (key == 27) { - return false; - } - - continue; - } - - if (selected >= count) { - selected = - count - 1U; - } - - if (selected >= - (size_t)visible_rows) { - top = - selected - - (size_t)visible_rows + - 1U; - } - - if (large_layout) { - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - section_ascii_header( - ":: C H O I S I R E X E R C I C E ::" - ); - - focused_panel( - list_top, - 2, - list_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "CATALOGUE", - true - ); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "↑↓ choisir Entrée sélectionner a créer un exercice Échap annuler" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } else { - draw_shell( - "Choisir un exercice", - "↑↓ naviguer Entrée choisir a ajouter Échap annuler" - ); - } - - for (index = 0U; - index < (size_t)visible_rows && - top + index < count; - ++index) { - size_t absolute = - top + index; - - int item_row = - first_row + - (int)index; - - if (absolute == selected) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_printf(tui_terminal, - item_row, - large_layout ? 5 : 4, - " %-42s [%s] ", - exercises[absolute].name, - exercises[absolute].tracking_mode == - TRAINLOG_TRACKING_REPS - ? "reps" - : "durée" - ); - - if (absolute == selected) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - } - - if (large_layout) { - section_scrollbar( - first_row, - list_bottom - 1, - trainlog_terminal_columns(tui_terminal) - 5, - selected, - count, - (size_t)visible_rows - ); - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if (key == TRAINLOG_KEY_UP) { - selected = - selected > 0U - ? selected - 1U - : count - 1U; - } else if (key == TRAINLOG_KEY_DOWN) { - selected = - selected + 1U < count - ? selected + 1U - : 0U; - } else if ( - key == '\n' || - key == TRAINLOG_KEY_ENTER - ) { - *output = - exercises[selected]; - - return true; - } else if ( - key == 'a' || - key == 'A' - ) { - (void)create_exercise_inline( - database - ); - } else if (key == 27) { - return false; - } - } -} - /* TRAINLOG_DURATION_BODY_GRAPH_HELPERS */ typedef struct TrainlogBodyMetricView { @@ -3533,312 +600,6 @@ static const TrainlogBodyMetricView BODY_METRICS[] = { {TRAINLOG_BODY_METRIC_RIGHT_CALF, "Mollet droit", "cm"} }; -static bool prompt_duration_value( - int row, - const char *label, - int minimum_seconds, - int maximum_seconds, - int default_seconds, - int *output_seconds -) -{ - char buffer[64]; - char default_text[64]; - char decorated[192]; - - if (trainlog_duration_format( - default_seconds, - default_text, - sizeof(default_text) - ) != TRAINLOG_STATUS_OK) { - return false; - } - - for (;;) { - int parsed; - - (void)snprintf( - decorated, - sizeof(decorated), - "%s [%s]: ", - label, - default_text - ); - - if (!prompt_text( - row, - decorated, - buffer, - sizeof(buffer), - true - )) { - return false; - } - - if (buffer[0] == '\0') { - *output_seconds = default_seconds; - return true; - } - - if (trainlog_duration_parse(buffer, &parsed) == - TRAINLOG_STATUS_OK && - parsed >= minimum_seconds && - parsed <= maximum_seconds) { - *output_seconds = parsed; - return true; - } - - status_line( - "Durée invalide : 90, 90s, 1:30, 1m30, 2m.", - TRAINLOG_COLOR_ERROR - ); - trainlog_terminal_render(tui_terminal); - } -} - -static void draw_body_metric_graph( - int row, - int height, - const TrainlogBodyMetricPoint *points, - size_t count, - const char *unit -) -{ - double minimum; - double maximum; - size_t start; - size_t index; - int width; - - if (count == 0U) { - trainlog_terminal_printf(tui_terminal, row, 4, "Aucune donnée pour cette mesure."); - return; - } - - width = trainlog_terminal_columns(tui_terminal) - 14; - if (width < 10 || height < 3) { - return; - } - - start = count > (size_t)width - ? count - (size_t)width - : 0U; - - minimum = points[start].value; - maximum = points[start].value; - - for (index = start + 1U; index < count; ++index) { - if (points[index].value < minimum) { - minimum = points[index].value; - } - if (points[index].value > maximum) { - maximum = points[index].value; - } - } - - if (maximum == minimum) { - int graph_row = row + (height / 2); - - trainlog_terminal_printf(tui_terminal, graph_row, 2, "%.1f %s", minimum, unit); - - trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_GRAPH)); - - for (index = start; index < count; ++index) { - int x = 12 + (int)(index - start); - - if (x < trainlog_terminal_columns(tui_terminal) - 2) { - trainlog_terminal_draw(tui_terminal, graph_row, x, (uint32_t)'*'); - } - } - - trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_GRAPH)); - return; - } - - trainlog_terminal_printf(tui_terminal, row, 2, "%.1f", maximum); - trainlog_terminal_printf(tui_terminal, row + height - 1, 2, "%.1f", minimum); - - trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_GRAPH)); - - for (index = start; index < count; ++index) { - double ratio = - (points[index].value - minimum) / - (maximum - minimum); - - int y = row + height - 1 - - (int)(ratio * (double)(height - 1)); - - int x = 10 + (int)(index - start); - - if (y < row) { - y = row; - } - if (y > row + height - 1) { - y = row + height - 1; - } - - if (x < trainlog_terminal_columns(tui_terminal) - 2) { - trainlog_terminal_draw(tui_terminal, y, x, (uint32_t)'*'); - } - } - - trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(TRAINLOG_COLOR_GRAPH)); -} - -static void add_body_observation(TrainlogDatabase *database) -{ - TrainlogBodyObservationInput observation; - char id[TRAINLOG_GENERATED_ID_CAPACITY]; - char timestamp[TRAINLOG_TIMESTAMP_MAX + 1U]; - int row; - - (void)memset(&observation, 0, sizeof(observation)); - - if (trainlog_id_generate("bo", id, sizeof(id)) != TRAINLOG_STATUS_OK || - trainlog_time_now_rfc3339( - timestamp, - sizeof(timestamp) - ) != TRAINLOG_STATUS_OK) { - return; - } - - (void)snprintf( - observation.observation_id, - sizeof(observation.observation_id), - "%s", - id - ); - - (void)snprintf( - observation.observed_at, - sizeof(observation.observed_at), - "%s", - timestamp - ); - - draw_shell( - "Nouvelle mesure — 1/2", - "Entrée valider · Échap annuler · vide = mesure non faite" - ); - - row = 4; - -#define BODY_PROMPT(label_, flag_, value_) \ - do { \ - if (!prompt_optional_double( \ - row++, \ - (label_), \ - &(flag_), \ - &(value_) \ - )) { \ - status_line( \ - "Saisie annulée : aucune mesure enregistrée.", \ - TRAINLOG_COLOR_MUTED \ - ); \ - wait_key(); \ - return; \ - } \ - } while (0) - - BODY_PROMPT( - "Poids kg : ", - observation.has_body_weight, - observation.body_weight_kg - ); - BODY_PROMPT( - "Cou cm : ", - observation.has_neck, - observation.neck_cm - ); - BODY_PROMPT( - "Épaules cm : ", - observation.has_shoulders, - observation.shoulders_cm - ); - BODY_PROMPT( - "Poitrine cm : ", - observation.has_chest, - observation.chest_cm - ); - BODY_PROMPT( - "Tour de taille cm : ", - observation.has_waist, - observation.waist_cm - ); - BODY_PROMPT( - "Hanches cm : ", - observation.has_hips, - observation.hips_cm - ); - - draw_shell( - "Nouvelle mesure — 2/2", - "Entrée valider · Échap annuler · vide = mesure non faite" - ); - - row = 4; - - BODY_PROMPT( - "Bras gauche cm : ", - observation.has_left_arm, - observation.left_arm_cm - ); - BODY_PROMPT( - "Bras droit cm : ", - observation.has_right_arm, - observation.right_arm_cm - ); - BODY_PROMPT( - "Avant-bras gauche cm : ", - observation.has_left_forearm, - observation.left_forearm_cm - ); - BODY_PROMPT( - "Avant-bras droit cm : ", - observation.has_right_forearm, - observation.right_forearm_cm - ); - BODY_PROMPT( - "Cuisse gauche cm : ", - observation.has_left_thigh, - observation.left_thigh_cm - ); - BODY_PROMPT( - "Cuisse droite cm : ", - observation.has_right_thigh, - observation.right_thigh_cm - ); - BODY_PROMPT( - "Mollet gauche cm : ", - observation.has_left_calf, - observation.left_calf_cm - ); - BODY_PROMPT( - "Mollet droit cm : ", - observation.has_right_calf, - observation.right_calf_cm - ); - -#undef BODY_PROMPT - - if (trainlog_database_insert_body_observation( - database, - &observation - ) == TRAINLOG_STATUS_OK) { - status_line( - "✓ Mesures enregistrées.", - TRAINLOG_COLOR_SUCCESS - ); - } else { - status_line( - "Aucune mesure valide enregistrée.", - TRAINLOG_COLOR_WARNING - ); - } - - wait_key(); -} - /* TRAINLOG_GLOBAL_BODY_OVERLAY_HELPERS */ #define MAX_GLOBAL_BODY_DATES \ @@ -3949,80 +710,6 @@ static size_t global_date_index( : date_count - 1U; } -static void global_plot_point( - int row, - int column, - char symbol, - TrainlogColorRole role -) -{ - uint32_t current; - uint32_t character; - - if (row < 0 || - row >= trainlog_terminal_rows(tui_terminal) || - column < 0 || - column >= trainlog_terminal_columns(tui_terminal)) { - return; - } - - /* Standard-plane writes are deterministic; a later series marks overlap. */ - current = (uint32_t)' '; - - character = - (uint32_t)(unsigned char)symbol; - - if (current != (uint32_t)' ' && - current != (uint32_t)'.' && - current != character) { - character = (uint32_t)'#'; - } - - trainlog_terminal_style_on(tui_terminal, trainlog_theme_style(role)); - trainlog_terminal_draw(tui_terminal, row, column, character); - trainlog_terminal_style_off(tui_terminal, trainlog_theme_style(role)); -} - -static void global_plot_segment( - int x1, - int y1, - int x2, - int y2, - char symbol, - TrainlogColorRole role -) -{ - int x; - - if (x2 < x1) { - return; - } - - if (x1 == x2) { - global_plot_point( - y2, - x2, - symbol, - role - ); - return; - } - - for (x = x1; x <= x2; ++x) { - int y = - y1 + - (((y2 - y1) * (x - x1)) / - (x2 - x1)); - - global_plot_point( - y, - x, - symbol, - role - ); - } -} - static int normalized_graph_row( double value, double minimum, @@ -4067,318 +754,6 @@ static int normalized_graph_row( return row; } -static void draw_global_body_overlay( - TrainlogDatabase *database -) -{ - static TrainlogGlobalBodySeries series[14]; - - static char dates[MAX_GLOBAL_BODY_DATES] - [TRAINLOG_TIMESTAMP_MAX + 1U]; - - const size_t metric_count = - sizeof(BODY_METRICS) / - sizeof(BODY_METRICS[0]); - - size_t date_count = 0U; - size_t metric_index; - double minimum = 100.0; - double maximum = 100.0; - const int graph_top = 5; - const int graph_height = 5; - const int graph_left = 8; - int graph_width = - trainlog_terminal_columns(tui_terminal) - graph_left - 3; - - (void)memset( - series, - 0, - sizeof(series) - ); - - (void)memset( - dates, - 0, - sizeof(dates) - ); - - for (metric_index = 0U; - metric_index < metric_count; - ++metric_index) { - TrainlogGlobalBodySeries *item = - &series[metric_index]; - - size_t point_index; - - item->symbol = - GLOBAL_BODY_SYMBOLS[metric_index]; - - item->role = - GLOBAL_BODY_ROLES[metric_index]; - - if (trainlog_database_list_body_metric_points( - database, - BODY_METRICS[metric_index].metric, - item->points, - MAX_BODY_METRIC_POINTS, - &item->count - ) != TRAINLOG_STATUS_OK || - item->count == 0U) { - continue; - } - - item->baseline = - item->points[0].value; - - (void)trainlog_body_percent_change( - item->baseline, - item->points[item->count - 1U].value, - &item->latest_percent - ); - - for (point_index = 0U; - point_index < item->count; - ++point_index) { - double normalized = 100.0; - - (void)global_date_add( - dates, - &date_count, - item->points[point_index].observed_at - ); - - if (trainlog_body_index100( - item->baseline, - item->points[point_index].value, - &normalized - ) == TRAINLOG_STATUS_OK) { - if (normalized < minimum) { - minimum = normalized; - } - - if (normalized > maximum) { - maximum = normalized; - } - } - } - } - - if (date_count == 0U) { - trainlog_terminal_printf(tui_terminal, - 4, - 4, - "Aucune mensuration disponible pour la vue globale." - ); - return; - } - - if (graph_width < 10) { - return; - } - - qsort( - dates, - date_count, - sizeof(dates[0]), - global_date_compare - ); - - trainlog_terminal_printf(tui_terminal, - 3, - 4, - "Vue globale — première mesure de chaque série = 100" - ); - - if (minimum == maximum) { - minimum = 99.0; - maximum = 101.0; - } - - trainlog_terminal_printf(tui_terminal, - graph_top, - 2, - "%.1f", - maximum - ); - - trainlog_terminal_printf(tui_terminal, - graph_top + graph_height - 1, - 2, - "%.1f", - minimum - ); - - if (100.0 >= minimum && - 100.0 <= maximum) { - int baseline_row = - normalized_graph_row( - 100.0, - minimum, - maximum, - graph_top, - graph_height - ); - - int column; - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - for (column = graph_left; - column < - graph_left + graph_width; - ++column) { - trainlog_terminal_draw(tui_terminal, - baseline_row, - column, - (uint32_t)'.' - ); - } - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } - - for (metric_index = 0U; - metric_index < metric_count; - ++metric_index) { - TrainlogGlobalBodySeries *item = - &series[metric_index]; - - size_t point_index; - int previous_x = -1; - int previous_y = -1; - - if (item->count == 0U) { - continue; - } - - for (point_index = 0U; - point_index < item->count; - ++point_index) { - size_t date_index = - global_date_index( - dates, - date_count, - item->points[point_index].observed_at - ); - - double normalized = 100.0; - int x; - int y; - - if (trainlog_body_index100( - item->baseline, - item->points[point_index].value, - &normalized - ) != TRAINLOG_STATUS_OK) { - continue; - } - - if (date_count == 1U) { - x = - graph_left + - (graph_width / 2); - } else { - x = - graph_left + - (int)( - (date_index * - (size_t)(graph_width - 1)) / - (date_count - 1U) - ); - } - - y = - normalized_graph_row( - normalized, - minimum, - maximum, - graph_top, - graph_height - ); - - if (previous_x >= 0 && - previous_y >= 0) { - global_plot_segment( - previous_x, - previous_y, - x, - y, - item->symbol, - item->role - ); - } else { - global_plot_point( - y, - x, - item->symbol, - item->role - ); - } - - previous_x = x; - previous_y = y; - } - } - - { - size_t row_index = 0U; - - for (metric_index = 0U; - metric_index < metric_count; - ++metric_index) { - TrainlogGlobalBodySeries *item = - &series[metric_index]; - - int column; - int row; - - if (item->count == 0U) { - continue; - } - - row = - 11 + - (int)(row_index % 7U); - - column = - row_index < 7U - ? 4 - : (trainlog_terminal_columns(tui_terminal) / 2); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - item->role - ) - ); - - trainlog_terminal_printf(tui_terminal, - row, - column, - "%c %-16s %+.1f%%", - item->symbol, - BODY_METRICS[metric_index].label, - item->latest_percent - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - item->role - ) - ); - - ++row_index; - } - } -} - /* TRAINLOG_DASHBOARD_GRAPH_ONLY */ /* TRAINLOG_DASHBOARD_12_MONTHS */ @@ -4497,2009 +872,16 @@ static bool dashboard_current_month_key( return true; } -static int dashboard_month_x( - size_t month_index, - int graph_left, - int graph_width -) -{ - if (DASHBOARD_MONTH_COUNT <= 1U) { - return graph_left; - } - - return graph_left + - (int)( - (month_index * - (size_t)(graph_width - 1)) / - (DASHBOARD_MONTH_COUNT - 1U) - ); -} - -static void dashboard_draw_month_axis( - const TrainlogDashboardMonth months[DASHBOARD_MONTH_COUNT], - int row, - int graph_left, - int graph_width -) -{ - size_t index; - int spacing = - graph_width / - (int)(DASHBOARD_MONTH_COUNT - 1U); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - for (index = 0U; - index < DASHBOARD_MONTH_COUNT; - ++index) { - int x = - dashboard_month_x( - index, - graph_left, - graph_width - ); - - if (spacing >= 6) { - char label[8]; - - (void)snprintf( - label, - sizeof(label), - "%02d/%02d", - months[index].month, - months[index].year % 100 - ); - - /* - * Center the month label on its slot, then clamp it inside - * the terminal so the first and last visible months are not - * silently lost at the borders. - */ - x -= 2; - - if (x < 2) { - x = 2; - } - - if (x > trainlog_terminal_columns(tui_terminal) - 8) { - x = trainlog_terminal_columns(tui_terminal) - 8; - } - - trainlog_terminal_printf(tui_terminal, - row, - x, - "%s", - label - ); - } else { - if (x + 2 < trainlog_terminal_columns(tui_terminal) - 1) { - trainlog_terminal_printf(tui_terminal, - row, - x, - "%02d", - months[index].month - ); - } - } - } - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); -} - -/* TRAINLOG_DASHBOARD_ASCII_PANELS */ - -static void dashboard_panel( - int top, - int left, - int bottom, - int right, - const char *label -) -{ - TrainlogPanel *panel; - int height; - int width; - - if (top < 0 || - left < 0 || - bottom <= top || - right <= left || - bottom >= trainlog_terminal_rows(tui_terminal) || - right >= trainlog_terminal_columns(tui_terminal)) { - return; - } - - height = bottom - top + 1; - width = right - left + 1; - - panel = tui_panel_create(tui_terminal, height, - width, - top, - left - ); - - if (panel == NULL) { - return; - } - - tui_panel_box(panel); - - if (label != NULL && - label[0] != '\0' && - width > 8) { - tui_panel_style_on( - panel, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - tui_panel_print( - panel, - 0, - 2, - " %.*s ", - width - 6, - label - ); - - tui_panel_style_off( - panel, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - /* - * derwin() shares the parent screen storage. syncok()+wsyncup() makes - * the panel border part of tui_terminal, so later dashboard content and one - * final trainlog_terminal_render(tui_terminal) compose cleanly. - */ - tui_panel_commit(panel); - tui_panel_destroy(panel); -} - -/* TRAINLOG_PRIMARY_TOP_NAVIGATION */ - -/* TRAINLOG_FOCUSED_PANELS */ - -static void focused_panel( - int top, - int left, - int bottom, - int right, - const char *label, - bool active -) -{ - TrainlogPanel *panel; - int height; - int width; - uint32_t border_attribute; - - if (top < 0 || - left < 0 || - bottom <= top || - right <= left || - bottom >= trainlog_terminal_rows(tui_terminal) || - right >= trainlog_terminal_columns(tui_terminal)) { - return; - } - - height = bottom - top + 1; - width = right - left + 1; - - panel = tui_panel_create(tui_terminal, height, - width, - top, - left - ); - - if (panel == NULL) { - return; - } - - border_attribute = - active - ? TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_WARNING - ) - : trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ); - - tui_panel_style_on( - panel, - border_attribute - ); - - tui_panel_box(panel); - - if (label != NULL && - label[0] != '\0' && - width > 8) { - tui_panel_print( - panel, - 0, - 2, - " %.*s ", - width - 6, - label - ); - } - - tui_panel_style_off( - panel, - border_attribute - ); - - tui_panel_commit(panel); - tui_panel_destroy(panel); -} - -static void primary_top_navbar( - int active_page, - int selected_page, - bool focused -) -{ - int column = 5; - int index; - - focused_panel( - 8, - 2, - 10, - trainlog_terminal_columns(tui_terminal) - 3, - "NAVIGATION", - focused - ); - - for (index = 0; - index < PRIMARY_NAV_COUNT; - ++index) { - int width = - (int)strlen(primary_nav_labels[index]) + 4; - - if (index == selected_page) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } else if (index == active_page) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_printf(tui_terminal, - 9, - column, - " %s ", - primary_nav_labels[index] - ); - - if (index == selected_page) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } else if (index == active_page) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - column += width + 2; - } -} - -static bool primary_top_nav_activate( - int selected_page -) -{ - if (selected_page < 0 || - selected_page >= PRIMARY_NAV_COUNT) { - return false; - } - - if (selected_page == 0) { - return true; - } - - return trainlog_terminal_push_key(tui_terminal, '0' + selected_page); -} - -/* - * Primary screens return to the main dispatcher. Requeueing the numeric - * shortcut lets the dashboard consume it immediately, so cross-page - * navigation does not recursively nest TUI screens. - */ -static bool primary_top_nav_forward( - int key -) -{ - int forwarded = 0; - - switch (key) { - case '0': - case TRAINLOG_KEY_HOME: - return true; - - case TRAINLOG_KEY_F1: - case '1': - forwarded = '1'; - break; - - case TRAINLOG_KEY_F2: - case '2': - forwarded = '2'; - break; - - case TRAINLOG_KEY_F3: - case '3': - forwarded = '3'; - break; - - case TRAINLOG_KEY_F4: - case '4': - forwarded = '4'; - break; - - case TRAINLOG_KEY_F5: - case '5': - forwarded = '5'; - break; - - case '6': - forwarded = '6'; - break; - - default: - return false; - } - - return trainlog_terminal_push_key(tui_terminal, forwarded); -} - -static void dashboard_ascii_header(void) -{ - section_ascii_header("Accueil · Séance · Progression"); -} - -static void draw_dashboard_body_graph( - TrainlogDatabase *database -) -{ - static TrainlogGlobalBodySeries series[14]; - - static TrainlogDashboardMonthValue - monthly[14][DASHBOARD_MONTH_COUNT]; - - TrainlogDashboardMonth - months[DASHBOARD_MONTH_COUNT]; - - const size_t metric_count = - sizeof(BODY_METRICS) / - sizeof(BODY_METRICS[0]); - - bool large_layout = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - int graph_panel_top = - large_layout ? 11 : 2; - - int graph_panel_bottom = - large_layout ? 20 : 9; - - int graph_top = - graph_panel_top + 1; - - int graph_height = - graph_panel_bottom - - graph_panel_top - - 3; - - int graph_left = - large_layout ? 10 : 8; - - int graph_right = - trainlog_terminal_columns(tui_terminal) - 5; - - int graph_width = - graph_right - - graph_left + - 1; - - int axis_row = - graph_panel_bottom - 1; - - int legend_panel_top = - large_layout - ? graph_panel_bottom + 1 - : 10; - - int legend_rows = - large_layout ? 5 : 6; - - int legend_panel_bottom = - legend_panel_top + - legend_rows + - 1; - - int legend_top = - legend_panel_top + 1; - - size_t metric_index; - size_t plotted_series = 0U; - double minimum = 0.0; - double maximum = 0.0; - long current_key; - long first_key; - - if (large_layout) { - dashboard_panel( - graph_panel_top, - 2, - graph_panel_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "EVOLUTION CORPORELLE - 12 MOIS" - ); - - dashboard_panel( - legend_panel_top, - 2, - legend_panel_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "MESURES" - ); - } else { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - 1, - 3, - "TRAINLOG :: DASHBOARD" - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - if (!dashboard_current_month_key( - ¤t_key - )) { - trainlog_terminal_printf(tui_terminal, - graph_top + 1, - 5, - "Impossible de déterminer le mois courant." - ); - return; - } - - first_key = - current_key - - (long)(DASHBOARD_MONTH_COUNT - 1U); - - for (metric_index = 0U; - metric_index < DASHBOARD_MONTH_COUNT; - ++metric_index) { - dashboard_month_from_key( - first_key + (long)metric_index, - &months[metric_index] - ); - } - - (void)memset(series, 0, sizeof(series)); - (void)memset(monthly, 0, sizeof(monthly)); - - for (metric_index = 0U; - metric_index < metric_count; - ++metric_index) { - TrainlogGlobalBodySeries *item = - &series[metric_index]; - - size_t point_index; - size_t visible_count = 0U; - size_t first_visible = 0U; - size_t latest_visible = 0U; - bool have_first = false; - - item->symbol = - GLOBAL_BODY_SYMBOLS[metric_index]; - - item->role = - GLOBAL_BODY_ROLES[metric_index]; - - if (trainlog_database_list_body_metric_points( - database, - BODY_METRICS[metric_index].metric, - item->points, - MAX_BODY_METRIC_POINTS, - &item->count - ) != TRAINLOG_STATUS_OK || - item->count == 0U) { - continue; - } - - /* - * The dashboard is monthly: multiple observations in one month keep - * the last actual value, while absent months remain absent. - */ - for (point_index = 0U; - point_index < item->count; - ++point_index) { - int year; - int month; - long key; - long offset; - - if (!dashboard_parse_year_month( - item->points[point_index].observed_at, - &year, - &month - )) { - continue; - } - - key = - dashboard_month_key( - year, - month - ); - - offset = key - first_key; - - if (offset < 0L || - offset >= - (long)DASHBOARD_MONTH_COUNT) { - continue; - } - - monthly[metric_index][(size_t)offset] - .present = true; - - monthly[metric_index][(size_t)offset] - .value = - item->points[point_index].value; - } - - for (point_index = 0U; - point_index < DASHBOARD_MONTH_COUNT; - ++point_index) { - if (!monthly[metric_index][point_index] - .present) { - continue; - } - - if (!have_first) { - first_visible = point_index; - have_first = true; - } - - latest_visible = point_index; - ++visible_count; - } - - if (!have_first) { - item->count = 0U; - continue; - } - - item->baseline = - monthly[metric_index][first_visible] - .value; - - if (trainlog_body_percent_change( - item->baseline, - monthly[metric_index][latest_visible] - .value, - &item->latest_percent - ) != TRAINLOG_STATUS_OK) { - item->count = 0U; - continue; - } - - item->count = visible_count; - - if (visible_count < 2U) { - continue; - } - - ++plotted_series; - - for (point_index = 0U; - point_index < DASHBOARD_MONTH_COUNT; - ++point_index) { - double percent = 0.0; - - if (!monthly[metric_index][point_index] - .present) { - continue; - } - - if (trainlog_body_percent_change( - item->baseline, - monthly[metric_index][point_index] - .value, - &percent - ) == TRAINLOG_STATUS_OK) { - if (percent < minimum) { - minimum = percent; - } - - if (percent > maximum) { - maximum = percent; - } - } - } - } - - if (graph_width < 12 || - graph_height < 3) { - return; - } - - if (plotted_series == 0U) { - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - graph_top + 1, - graph_left, - "Premières courbes après 2 mois relevés pour une même mesure." - ); - - trainlog_terminal_printf(tui_terminal, - graph_top + 2, - graph_left, - "Un mois sans relevé reste vide." - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - dashboard_draw_month_axis( - months, - axis_row, - graph_left, - graph_width - ); - } else { - if (minimum == maximum) { - minimum = -1.0; - maximum = 1.0; - } - - trainlog_terminal_printf(tui_terminal, - graph_top, - 3, - "%+.1f%%", - maximum - ); - - trainlog_terminal_printf(tui_terminal, - graph_top + graph_height - 1, - 3, - "%+.1f%%", - minimum - ); - - { - int zero_row = - normalized_graph_row( - 0.0, - minimum, - maximum, - graph_top, - graph_height - ); - - int column; - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - for (column = graph_left; - column <= graph_right; - ++column) { - trainlog_terminal_draw(tui_terminal, - zero_row, - column, - (uint32_t)'.' - ); - } - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } - - for (metric_index = 0U; - metric_index < metric_count; - ++metric_index) { - TrainlogGlobalBodySeries *item = - &series[metric_index]; - - size_t month_index; - int previous_x = -1; - int previous_y = -1; - long previous_month = -2L; - - if (item->count < 2U) { - continue; - } - - for (month_index = 0U; - month_index < DASHBOARD_MONTH_COUNT; - ++month_index) { - double percent = 0.0; - int x; - int y; - - if (!monthly[metric_index][month_index] - .present) { - previous_x = -1; - previous_y = -1; - previous_month = -2L; - continue; - } - - if (trainlog_body_percent_change( - item->baseline, - monthly[metric_index][month_index] - .value, - &percent - ) != TRAINLOG_STATUS_OK) { - continue; - } - - x = - dashboard_month_x( - month_index, - graph_left, - graph_width - ); - - y = - normalized_graph_row( - percent, - minimum, - maximum, - graph_top, - graph_height - ); - - if (previous_x >= 0 && - previous_y >= 0 && - previous_month + 1L == - (long)month_index) { - global_plot_segment( - previous_x, - previous_y, - x, - y, - item->symbol, - item->role - ); - } else { - global_plot_point( - y, - x, - item->symbol, - item->role - ); - } - - previous_x = x; - previous_y = y; - previous_month = - (long)month_index; - } - } - - dashboard_draw_month_axis( - months, - axis_row, - graph_left, - graph_width - ); - } - - { - size_t legend_index = 0U; - - int columns = - large_layout ? 3 : 2; - - int cell_width = - (trainlog_terminal_columns(tui_terminal) - 8) / - columns; - - for (metric_index = 0U; - metric_index < metric_count; - ++metric_index) { - TrainlogGlobalBodySeries *item = - &series[metric_index]; - - char label[96]; - char evolution[16]; - int logical_column; - int logical_row; - int row; - int column; - int label_width; - size_t month_index; - size_t latest_month = 0U; - bool found = false; - double latest = 0.0; - - if (item->count == 0U) { - continue; - } - - for (month_index = 0U; - month_index < DASHBOARD_MONTH_COUNT; - ++month_index) { - if (monthly[metric_index][month_index] - .present) { - latest_month = month_index; - found = true; - } - } - - if (!found) { - continue; - } - - logical_column = - (int)( - legend_index / - (size_t)legend_rows - ); - - logical_row = - (int)( - legend_index % - (size_t)legend_rows - ); - - if (logical_column >= columns) { - break; - } - - latest = - monthly[metric_index][latest_month] - .value; - - row = - legend_top + - logical_row; - - column = - (large_layout ? 4 : 2) + - (logical_column * cell_width); - - label_width = - cell_width - 18; - - if (label_width < 7) { - label_width = 7; - } - - (void)snprintf( - label, - sizeof(label), - "%s (%s)", - BODY_METRICS[metric_index].label, - BODY_METRICS[metric_index].unit - ); - - if (item->count < 2U) { - (void)snprintf( - evolution, - sizeof(evolution), - "%s", - "réf." - ); - } else { - (void)snprintf( - evolution, - sizeof(evolution), - "%+.1f%%", - item->latest_percent - ); - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - item->role - ) - ); - - trainlog_terminal_printf(tui_terminal, - row, - column, - "%c %-*.*s %6.1f", - item->symbol, - label_width, - label_width, - label, - latest - ); - - trainlog_terminal_printf(tui_terminal, - row, - column + cell_width - 7, - "%6s", - evolution - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - item->role - ) - ); - - ++legend_index; - } - } -} - -static DashboardAction screen_dashboard( - TrainlogDatabase *database -) -{ - static const char *const footer = - "←→ naviguer Entrée ouvrir g générer une séance q quitter"; - - int selected = 0; - - for (;;) { - bool large_layout = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - int nav_top = - large_layout ? 8 : trainlog_terminal_rows(tui_terminal) - 4; - - int nav_bottom = - large_layout ? 10 : trainlog_terminal_rows(tui_terminal) - 3; - - int key; - int index; - int column; - - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - if (large_layout) { - dashboard_ascii_header(); - - focused_panel( - nav_top, - 2, - nav_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "NAVIGATION", - true - ); - } - - draw_dashboard_body_graph(database); - - column = - large_layout ? 5 : 3; - - for (index = 0; - index < PRIMARY_NAV_COUNT; - ++index) { - int width = - (int)strlen(primary_nav_labels[index]) + 4; - - if (!large_layout && index != selected) { - continue; - } - - if (index == selected) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_printf(tui_terminal, - large_layout - ? nav_top + 1 - : trainlog_terminal_rows(tui_terminal) - 3, - column, - large_layout ? " %s " : " ← %s → ", - primary_nav_labels[index] - ); - - if (index == selected) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - column += width + 2; - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - footer - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - switch (key) { - case TRAINLOG_KEY_UP: - case TRAINLOG_KEY_LEFT: - selected = - selected > 0 - ? selected - 1 - : PRIMARY_NAV_COUNT - 1; - break; - - case TRAINLOG_KEY_DOWN: - case TRAINLOG_KEY_RIGHT: - selected = - selected < PRIMARY_NAV_COUNT - 1 - ? selected + 1 - : 0; - break; - - case '\n': - case TRAINLOG_KEY_ENTER: - switch (selected) { - case 0: - break; - case 1: - return DASHBOARD_NEW_SESSION; - case 2: - return DASHBOARD_HISTORY; - case 3: - return DASHBOARD_EXERCISES; - case 4: - return DASHBOARD_EQUIPMENT; - case 5: - return DASHBOARD_BODY; - case 6: - return DASHBOARD_SYNC; - default: - break; - } - break; - - case '0': - case TRAINLOG_KEY_HOME: - selected = 0; - break; - - case TRAINLOG_KEY_F1: - case '1': - return DASHBOARD_NEW_SESSION; - - case 'g': - case 'G': - return DASHBOARD_GENERATE_SESSION; - - case TRAINLOG_KEY_F2: - case '2': - return DASHBOARD_HISTORY; - - case TRAINLOG_KEY_F3: - case '3': - return DASHBOARD_EXERCISES; - - case TRAINLOG_KEY_F4: - case '4': - return DASHBOARD_EQUIPMENT; - - case TRAINLOG_KEY_F5: - case '5': - return DASHBOARD_BODY; - - case '6': - return DASHBOARD_SYNC; - - case 'q': - case 'Q': - return DASHBOARD_QUIT; - - default: - break; - } - } -} - -static bool build_session_exercise( - TrainlogDatabase *database, - TrainlogSessionType session_type, - TrainlogSessionExerciseInput *output, - TrainlogSetInput *set_storage, - size_t set_capacity -) -{ - TrainlogExercise exercise; - int load_mode = 1; - int target_sets = 3; - int target_metric; - int rest_seconds = 60; - bool target_has_weight = false; - double target_weight = 0.0; - - if (!choose_exercise( - database, - &exercise - )) { - return false; - } - - (void)memset( - output, - 0, - sizeof(*output) - ); - - (void)snprintf( - output->exercise_id, - sizeof(output->exercise_id), - "%s", - exercise.exercise_id - ); - - output->recording_mode = - exercise.recording_mode; - - output->data_fields = - exercise.data_fields; - - if (!choose_equipment(database, output->equipment_id, - sizeof(output->equipment_id))) { - return false; - } - - if (session_type == TRAINLOG_SESSION_MAX_TEST) { - bool has_max = false; - double max_weight = 0.0; - - draw_shell( - exercise.name, - "Échap annuler · Test de max" - ); - if (!prompt_optional_double( - 4, - "Poids max (kg) : ", - &has_max, - &max_weight - ) || !has_max || max_weight <= 0.0) { - status_line( - "Poids max requis et strictement positif.", - TRAINLOG_COLOR_ERROR - ); - return false; - } - - /* CONTRACT: the max result has no hidden series/repetition payload. */ - output->has_max_weight = true; - output->max_weight_kg = max_weight; - output->load_mode = TRAINLOG_LOAD_NONE; - output->sets = NULL; - output->set_count = 0U; - return true; - } - - if (exercise.recording_mode == - TRAINLOG_RECORDING_CONTINUOUS) { - int duration_minutes = 30; - int duration_seconds; - bool has_value = false; - double value = 0.0; - - draw_shell( - exercise.name, - "Échap annuler · activité continue" - ); - - if (!prompt_int_value( - 4, - "Durée (minutes)", - 1, - 1440, - duration_minutes, - &duration_minutes - )) { - return false; - } - - duration_seconds = - duration_minutes * 60; - - output->continuous_duration_seconds = - duration_seconds; - - output->load_mode = - TRAINLOG_LOAD_NONE; - - output->rest_seconds = 0; - output->target_sets = 0; - output->target_reps = 0; - output->target_duration_seconds = 0; - output->target_has_weight = false; - output->sets = NULL; - output->set_count = 0U; - - if ((exercise.data_fields & - TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U) { - if (!prompt_optional_double( - 5, - "Vitesse km/h : ", - &has_value, - &value - ) || - !has_value) { - return false; - } - - output->continuous_has_speed = - true; - - output->continuous_speed_kmh = - value; - } - - if ((exercise.data_fields & - TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U) { - has_value = false; - value = 0.0; - - if (!prompt_optional_double( - 6, - "Distance km : ", - &has_value, - &value - ) || - !has_value) { - return false; - } - - output->continuous_has_distance = - true; - - output->continuous_distance_km = - value; - } - - return true; - } - - target_metric = - exercise.tracking_mode == - TRAINLOG_TRACKING_REPS - ? 10 - : 45; - - draw_shell( - exercise.name, - "Échap annuler · Durées : 90, 90s, 1:30, 1m30, 2m" - ); - - if (!prompt_int_value( - 4, - "Charge 1=aucune 2=externe 3=assistance", - 1, - 3, - 1, - &load_mode - )) { - return false; - } - - if (load_mode != 1) { - const char *target_load_prompt = - load_mode == 3 - ? "Assistance cible kg : " - : "Charge cible kg : "; - - if (!prompt_optional_double( - 5, - target_load_prompt, - &target_has_weight, - &target_weight - ) || - !target_has_weight) { - return false; - } - } - - if (!prompt_int_value( - 6, - "Séries prévues", - 1, - (int)set_capacity, - 3, - &target_sets - )) { - return false; - } - - if (exercise.tracking_mode == - TRAINLOG_TRACKING_REPS) { - if (!prompt_int_value( - 7, - "Répétitions cibles", - 1, - 10000, - target_metric, - &target_metric - )) { - return false; - } - } else { - if (!prompt_duration_value( - 7, - "Durée cible", - 1, - 86400, - target_metric, - &target_metric - )) { - return false; - } - } - - if (!prompt_duration_value( - 8, - "Repos prévu", - 0, - 86400, - 60, - &rest_seconds - )) { - return false; - } - - output->load_mode = - load_mode == 1 - ? TRAINLOG_LOAD_NONE - : ( - load_mode == 2 - ? TRAINLOG_LOAD_EXTERNAL - : TRAINLOG_LOAD_ASSISTANCE - ); - - output->rest_seconds = rest_seconds; - output->target_sets = target_sets; - - output->target_reps = - exercise.tracking_mode == - TRAINLOG_TRACKING_REPS - ? target_metric - : 0; - - output->target_duration_seconds = - exercise.tracking_mode == - TRAINLOG_TRACKING_DURATION - ? target_metric - : 0; - - output->target_has_weight = - target_has_weight; - - output->target_weight_kg = - target_weight; - - output->sets = set_storage; - output->set_count = 0U; - - return true; -} - -/* TRAINLOG_SESSION_TYPE_TUI */ - -static const char *session_type_label( - TrainlogSessionType type -) -{ - switch (type) { - case TRAINLOG_SESSION_MAX_TEST: - return "Test de max"; - - case TRAINLOG_SESSION_TRAINING: - default: - return "Entraînement"; - } -} - -static const char *session_type_history_label( - TrainlogSessionType type -) -{ - return type == TRAINLOG_SESSION_MAX_TEST - ? "[MAX]" - : "[ENTRAINEMENT]"; -} - -static bool choose_session_type( - TrainlogSessionType *output -) -{ - int selected = 0; - int nav_selected = 1; - int focus = 1; - - if (output == NULL) { - return false; - } - - for (;;) { - bool large_layout = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - int key; - - if (large_layout) { - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - section_ascii_header( - ":: N O U V E L L E S E A N C E ::" - ); - - primary_top_navbar( - 1, - nav_selected, - focus == 0 - ); - - focused_panel( - 11, - 4, - 20, - trainlog_terminal_columns(tui_terminal) - 5, - "TYPE DE SEANCE", - focus == 1 - ); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "Tab zone ←→ menu ↑↓ type Entrée valider 0/Home accueil F2-F5 direct Échap annuler" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - if (focus == 1 && - selected == 0) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_printf(tui_terminal, - 13, - 7, - " Entraînement " - ); - - if (focus == 1 && - selected == 0) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - 14, - 9, - "Séance normale : progression, volume, travail courant." - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - if (focus == 1 && - selected == 1) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_printf(tui_terminal, - 17, - 7, - " Test de max " - ); - - if (focus == 1 && - selected == 1) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - 18, - 9, - "Séance explicitement dédiée aux mesures de max." - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } else { - TrainlogPanel *panel; - int panel_width = - trainlog_terminal_columns(tui_terminal) - 8; - - draw_shell( - "TRAINLOG — Nouvelle séance", - "↑↓ choisir Entrée valider Échap annuler" - ); - - if (panel_width < 40) { - panel_width = 40; - } - - panel = tui_panel_create(tui_terminal, 9, - panel_width, - 3, - 4 - ); - - if (panel == NULL) { - return false; - } - - tui_panel_box(panel); - - tui_panel_style_on( - panel, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - tui_panel_print( - panel, - 0, - 2, - " TYPE DE SEANCE " - ); - - tui_panel_style_off( - panel, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - if (selected == 0) { - tui_panel_style_on( - panel, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - tui_panel_print( - panel, - 2, - 3, - " Entraînement " - ); - - if (selected == 0) { - tui_panel_style_off( - panel, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - tui_panel_print( - panel, - 3, - 5, - "Séance normale : progression, volume, travail courant." - ); - - if (selected == 1) { - tui_panel_style_on( - panel, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - tui_panel_print( - panel, - 5, - 3, - " Test de max " - ); - - if (selected == 1) { - tui_panel_style_off( - panel, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - tui_panel_print( - panel, - 6, - 5, - "Séance explicitement dédiée aux mesures de max." - ); - - tui_panel_commit(panel); - tui_panel_destroy(panel); - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if (large_layout && - (key == TRAINLOG_KEY_TAB || - key == TRAINLOG_KEY_SHIFT_TAB)) { - focus = - focus == 0 - ? 1 - : 0; - continue; - } - - if (key == 27) { - return false; - } - - if (large_layout && - (key == '0' || - key == TRAINLOG_KEY_HOME)) { - return false; - } - - if (large_layout && - (key == '2' || - key == TRAINLOG_KEY_F2 || - key == '3' || - key == TRAINLOG_KEY_F3 || - key == '4' || - key == TRAINLOG_KEY_F4 || - key == '5' || - key == TRAINLOG_KEY_F5 || - key == '6')) { - if (primary_top_nav_forward(key)) { - return false; - } - - continue; - } - - if (large_layout && - focus == 0) { - if (key == TRAINLOG_KEY_LEFT) { - nav_selected = - nav_selected > 0 - ? nav_selected - 1 - : PRIMARY_NAV_COUNT - 1; - } else if (key == TRAINLOG_KEY_RIGHT) { - nav_selected = - nav_selected < PRIMARY_NAV_COUNT - 1 - ? nav_selected + 1 - : 0; - } else if ( - key == '\n' || - key == TRAINLOG_KEY_ENTER - ) { - if (nav_selected == 1) { - focus = 1; - } else if ( - primary_top_nav_activate( - nav_selected - ) - ) { - return false; - } - } - - continue; - } - - if (key == TRAINLOG_KEY_UP || - key == TRAINLOG_KEY_LEFT || - key == TRAINLOG_KEY_DOWN || - key == TRAINLOG_KEY_RIGHT) { - selected = - selected == 0 - ? 1 - : 0; - continue; - } - - if (key == '\n' || - key == TRAINLOG_KEY_ENTER) { - *output = - selected == 0 - ? TRAINLOG_SESSION_TRAINING - : TRAINLOG_SESSION_MAX_TEST; - return true; - } - } -} - -/* TRAINLOG_SESSION_EDIT_TUI */ - typedef struct TrainlogSessionDraftExercise { TrainlogSessionExerciseInput input; TrainlogSetInput sets[MAX_SETS_PER_EXERCISE]; char name[TRAINLOG_NAME_MAX + 1U]; TrainlogTrackingMode tracking_mode; char notes[TRAINLOG_NOTE_MAX + 1U]; + /* Transient provenance only; the persisted plan stores the resulting kg. */ + bool target_from_percent_max; } TrainlogSessionDraftExercise; -static bool draft_lookup_exercise( - TrainlogDatabase *database, - const char *exercise_id, - char *output_name, - size_t output_name_size, - TrainlogTrackingMode *output_tracking -) -{ - TrainlogExercise exercises[MAX_EXERCISES]; - size_t count = 0U; - size_t index; - - if (database == NULL || - exercise_id == NULL || - output_name == NULL || - output_name_size == 0U || - output_tracking == NULL) { - return false; - } - - if (trainlog_database_list_exercises( - database, - exercises, - MAX_EXERCISES, - &count - ) != TRAINLOG_STATUS_OK) { - return false; - } - - for (index = 0U; - index < count; - ++index) { - if (strcmp( - exercises[index].exercise_id, - exercise_id - ) == 0) { - (void)snprintf( - output_name, - output_name_size, - "%s", - exercises[index].name - ); - - *output_tracking = - exercises[index].tracking_mode; - - return true; - } - } - - return false; -} - -static bool draft_build_exercise( - TrainlogDatabase *database, - TrainlogSessionType session_type, - TrainlogSessionDraftExercise *draft, - const char *preserved_notes -) -{ - TrainlogSessionExerciseInput input; - TrainlogSetInput sets[MAX_SETS_PER_EXERCISE]; - - if (database == NULL || - draft == NULL) { - return false; - } - - (void)memset( - &input, - 0, - sizeof(input) - ); - - (void)memset( - sets, - 0, - sizeof(sets) - ); - - if (!build_session_exercise( - database, - session_type, - &input, - sets, - MAX_SETS_PER_EXERCISE - )) { - return false; - } - - (void)memset( - draft, - 0, - sizeof(*draft) - ); - - draft->input = input; - - if (input.set_count > - MAX_SETS_PER_EXERCISE) { - return false; - } - - (void)memcpy( - draft->sets, - sets, - input.set_count * - sizeof(draft->sets[0]) - ); - - draft->input.sets = - draft->sets; - - if (preserved_notes != NULL && - preserved_notes[0] != '\0') { - (void)snprintf( - draft->notes, - sizeof(draft->notes), - "%s", - preserved_notes - ); - - draft->input.notes = - draft->notes; - } else { - draft->notes[0] = '\0'; - draft->input.notes = NULL; - } - - if (!draft_lookup_exercise( - database, - draft->input.exercise_id, - draft->name, - sizeof(draft->name), - &draft->tracking_mode - )) { - return false; - } - - return true; -} - static void draft_bind_input( TrainlogSessionDraftExercise *draft ) @@ -6538,271 +920,6 @@ static void draft_format_set_metric( } } -static bool draft_edit_set_cell( - TrainlogSessionDraftExercise *draft, - size_t row, - size_t field, - bool require_metric_input -) -{ - TrainlogSetInput candidate; - - if (draft == NULL || row >= draft->input.set_count) { - return false; - } - - candidate = draft->sets[row]; - - if (field == 0U) { - int value = draft->tracking_mode == TRAINLOG_TRACKING_DURATION - ? candidate.duration_seconds - : candidate.reps; - bool accepted; - - if (require_metric_input) { - char buffer[64]; - const char *label = - draft->tracking_mode == TRAINLOG_TRACKING_DURATION - ? "Durée réalisée (requise) : " - : "Répétitions réalisées (requises) : "; - - for (;;) { - if (!prompt_text(6, label, buffer, sizeof(buffer), false)) { - accepted = false; - break; - } - if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) { - accepted = - trainlog_duration_parse(buffer, &value) == - TRAINLOG_STATUS_OK && - value >= 1 && value <= 86400; - } else { - accepted = parse_int(buffer, 0, 10000, &value); - } - if (accepted) { - break; - } - status_line("Valeur réalisée invalide.", - TRAINLOG_COLOR_ERROR); - trainlog_terminal_render(tui_terminal); - } - } else { - accepted = draft->tracking_mode == TRAINLOG_TRACKING_DURATION - ? prompt_duration_value(6, "Durée réalisée", 1, 86400, - value > 0 ? value : 1, &value) - : prompt_int_value(6, "Répétitions réalisées", 0, 10000, - value >= 0 ? value : 0, &value); - } - - if (!accepted) { - /* CONTRACT: Escape cancels the active cell; the candidate was - * edited off to the side, so the draft remains byte-for-byte. */ - return false; - } - - if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) { - candidate.duration_seconds = value; - } else { - candidate.reps = value; - } - } else { - bool has_weight = false; - double weight = 0.0; - - if (!prompt_optional_nonnegative_double( - 6, - draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE - ? "Assistance kg (vide = aucune) : " - : "Charge kg (vide = aucune) : ", - &has_weight, - &weight - )) { - return false; - } - - candidate.has_weight = has_weight; - candidate.weight_kg = has_weight ? weight : 0.0; - } - - draft->sets[row] = candidate; - return true; -} - -static bool draft_append_incomplete_set( - TrainlogSessionDraftExercise *draft -) -{ - TrainlogSetInput *added; - size_t appended_index; - - if (draft == NULL || - draft->input.set_count >= MAX_SETS_PER_EXERCISE) { - return false; - } - - appended_index = draft->input.set_count; - added = &draft->sets[appended_index]; - (void)memset(added, 0, sizeof(*added)); - ++draft->input.set_count; - - /* INVARIANT: targets are planning metadata. A new actual row exists only - * if its metric prompt is accepted; cancellation rolls the append back. */ - if (!draft_edit_set_cell(draft, appended_index, 0U, true)) { - --draft->input.set_count; - (void)memset(added, 0, sizeof(*added)); - return false; - } - - return true; -} - -static void draft_edit_sets( - TrainlogSessionDraftExercise *draft -) -{ - size_t selected = 0U; - size_t field = 0U; - - if (draft == NULL || - draft->input.recording_mode != TRAINLOG_RECORDING_SETS || - draft->input.has_max_weight) { - return; - } - - for (;;) { - size_t index; - size_t top = 0U; - size_t visible; - int first_row = 7; - int rows = trainlog_terminal_rows(tui_terminal); - int key; - bool has_load = draft->input.load_mode != TRAINLOG_LOAD_NONE; - const char *metric_label = - draft->tracking_mode == TRAINLOG_TRACKING_DURATION - ? "Durée" - : "Reps"; - const char *load_label = - draft->input.load_mode == TRAINLOG_LOAD_ASSISTANCE - ? "Assistance" - : "Charge"; - - visible = rows > first_row + 3 - ? (size_t)(rows - first_row - 3) - : 1U; - - if (draft->input.set_count > 0U && - selected >= draft->input.set_count) { - selected = draft->input.set_count - 1U; - } - if (selected >= visible) { - top = selected - visible + 1U; - } - - draw_shell( - draft->name, - "↑↓ série ←→/Tab champ Entrée modifier a ajouter d supprimer f/b terminer" - ); - trainlog_terminal_printf(tui_terminal, 3, 4, - "Valeurs réellement effectuées — cible indicative, jamais recopiée"); - trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_BOLD); - trainlog_terminal_printf(tui_terminal, 5, 4, - has_load ? "%-8s %-20s %-20s" : "%-8s %-20s", - "Série", metric_label, has_load ? load_label : ""); - trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_BOLD); - - if (draft->input.set_count == 0U) { - trainlog_terminal_printf(tui_terminal, first_row, 4, - "Aucune série. a = ajouter une série."); - } - - for (index = top; - index < draft->input.set_count && index - top < visible; - ++index) { - char metric[64]; - char weight[64]; - int row = first_row + (int)(index - top); - - draft_format_set_metric(draft, &draft->sets[index], metric, - sizeof(metric)); - if (draft->sets[index].has_weight) { - (void)snprintf(weight, sizeof(weight), "%.2f kg", - draft->sets[index].weight_kg); - } else { - (void)snprintf(weight, sizeof(weight), "—"); - } - - if (index == selected) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - } - trainlog_terminal_printf(tui_terminal, row, 4, - has_load ? "%c %-6zu %-20.20s %-20.20s" - : "%c %-6zu %-20.20s", - index == selected ? (field == 0U ? '>' : '*') : ' ', - index + 1U, metric, has_load ? weight : ""); - if (index == selected) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - } - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if (key == 'f' || key == 'F' || key == 'b' || key == 'B' || - key == 27 || key == TRAINLOG_KEY_ESCAPE) { - return; - } - if (key == TRAINLOG_KEY_UP && draft->input.set_count > 0U) { - selected = selected > 0U ? selected - 1U - : draft->input.set_count - 1U; - continue; - } - if (key == TRAINLOG_KEY_DOWN && draft->input.set_count > 0U) { - selected = selected + 1U < draft->input.set_count - ? selected + 1U : 0U; - continue; - } - if (has_load && (key == TRAINLOG_KEY_LEFT || - key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_TAB || - key == TRAINLOG_KEY_SHIFT_TAB)) { - field = field == 0U ? 1U : 0U; - continue; - } - if ((key == '\n' || key == TRAINLOG_KEY_ENTER) && - draft->input.set_count > 0U) { - (void)draft_edit_set_cell(draft, selected, field, false); - continue; - } - if ((key == 'a' || key == 'A') && - draft->input.set_count < MAX_SETS_PER_EXERCISE) { - selected = draft->input.set_count; - field = 0U; - (void)draft_append_incomplete_set(draft); - if (draft->input.set_count == 0U || - selected >= draft->input.set_count) { - selected = draft->input.set_count > 0U - ? draft->input.set_count - 1U : 0U; - } - continue; - } - if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) && - draft->input.set_count > 0U) { - for (index = selected; index + 1U < draft->input.set_count; - ++index) { - draft->sets[index] = draft->sets[index + 1U]; - } - --draft->input.set_count; - if (draft->input.set_count > 0U && - selected >= draft->input.set_count) { - selected = draft->input.set_count - 1U; - } - } - } -} - static void draft_delete_exercise( TrainlogSessionDraftExercise *drafts, size_t *count, @@ -6934,372 +1051,6 @@ static void draft_set_summary( } } -static bool confirm_draft_delete( - const char *name -) -{ - int answer = 0; - - draw_shell( - "Supprimer de la séance ?", - "1 confirmer 0 annuler" - ); - - trainlog_terminal_printf(tui_terminal, - 4, - 4, - "%s", - name != NULL - ? name - : "Exercice" - ); - - if (!prompt_int_value( - 6, - "Supprimer ? 1=oui 0=non", - 0, - 1, - 0, - &answer - )) { - return false; - } - - return answer == 1; -} - -static bool edit_session_draft( - TrainlogDatabase *database, - TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES], - size_t *count, - TrainlogSessionType session_type -) -{ - size_t selected = 0U; - - if (database == NULL || - drafts == NULL || - count == NULL) { - return false; - } - - for (;;) { - size_t index; - size_t top = 0U; - - bool large_layout = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - int frame_top = - large_layout ? 8 : 3; - - int frame_bottom = - trainlog_terminal_rows(tui_terminal) - 4; - - int first_row = - frame_top + 3; - - int visible_rows = - frame_bottom - - first_row; - - int key; - - if (visible_rows < 1) { - return false; - } - - if (*count > 0U && - selected >= *count) { - selected = - *count - 1U; - } - - if (*count > 0U && - selected >= - (size_t)visible_rows) { - top = - selected - - (size_t)visible_rows + - 1U; - } - - if (large_layout) { - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - section_ascii_header( - ":: S E A N C E E N C O U R S ::" - ); - - focused_panel( - frame_top, - 2, - frame_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "RESUME AVANT ENREGISTREMENT", - true - ); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "↑↓ choisir e/Entrée séries r remplacer a ajouter d supprimer f enregistrer q/Échap abandonner" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } else { - draw_shell( - "TRAINLOG — Séance en cours", - "↑↓ choisir e/Entrée séries r remplacer a ajouter d supprimer f enregistrer q/Échap abandonner" - ); - } - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - frame_top + 1, - large_layout ? 5 : 4, - "Type : %s", - session_type_label( - session_type - ) - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - if (*count == 0U) { - trainlog_terminal_printf(tui_terminal, - first_row + 1, - large_layout ? 5 : 4, - "Aucun exercice saisi. a = ajouter." - ); - } - - for (index = 0U; - index < (size_t)visible_rows && - top + index < *count; - ++index) { - size_t absolute = - top + index; - - char summary[96]; - - int row = - first_row + - (int)index; - - draft_set_summary( - &drafts[absolute], - summary, - sizeof(summary) - ); - - if (absolute == selected) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_printf(tui_terminal, - row, - large_layout ? 5 : 4, - " %2zu %-36.36s %-28.28s ", - absolute + 1U, - drafts[absolute].name, - summary - ); - - if (absolute == selected) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if (key == 'q' || - key == 'Q' || - key == 27) { - return false; - } - - if (key == 'f' || - key == 'F') { - size_t draft_index; - - if (*count == 0U) { - status_line( - "Ajoutez au moins un exercice avant d'enregistrer.", - TRAINLOG_COLOR_WARNING - ); - - trainlog_terminal_render(tui_terminal); - (void)trainlog_terminal_get_key(tui_terminal); - continue; - } - - for (draft_index = 0U; draft_index < *count; ++draft_index) { - if (drafts[draft_index].input.recording_mode == - TRAINLOG_RECORDING_SETS && - !drafts[draft_index].input.has_max_weight && - drafts[draft_index].input.set_count == 0U) { - char message[192]; - - selected = draft_index; - (void)snprintf(message, sizeof(message), - "Ajoutez au moins une série réalisée pour %s.", - drafts[draft_index].name); - status_line(message, TRAINLOG_COLOR_WARNING); - trainlog_terminal_render(tui_terminal); - (void)trainlog_terminal_get_key(tui_terminal); - break; - } - } - - if (draft_index < *count) { - continue; - } - - return true; - } - - if (key == TRAINLOG_KEY_UP && - *count > 0U) { - selected = - selected > 0U - ? selected - 1U - : *count - 1U; - continue; - } - - if (key == TRAINLOG_KEY_DOWN && - *count > 0U) { - selected = - selected + 1U < *count - ? selected + 1U - : 0U; - continue; - } - - if (key == 'a' || - key == 'A') { - if (*count >= - MAX_SESSION_EXERCISES) { - status_line( - "Nombre maximal d'exercices atteint.", - TRAINLOG_COLOR_WARNING - ); - - trainlog_terminal_render(tui_terminal); - (void)trainlog_terminal_get_key(tui_terminal); - continue; - } - - if (draft_build_exercise( - database, - session_type, - &drafts[*count], - NULL - )) { - selected = *count; - ++(*count); - draft_edit_sets(&drafts[selected]); - } - - continue; - } - - if ((key == 'e' || - key == 'E' || - key == '\n' || - key == TRAINLOG_KEY_ENTER) && - *count > 0U) { - draft_edit_sets(&drafts[selected]); - continue; - } - - if ((key == 'r' || key == 'R') && *count > 0U) { - TrainlogSessionDraftExercise replacement; - - if (draft_build_exercise( - database, - session_type, - &replacement, - drafts[selected].notes - )) { - /* INVARIANT: editing replaces values, never occurrence - * identity. Sync idempotency depends on stable entry_id. */ - (void)snprintf( - replacement.input.entry_id, - sizeof(replacement.input.entry_id), - "%s", - drafts[selected].input.entry_id - ); - drafts[selected] = - replacement; - - draft_bind_input( - &drafts[selected] - ); - } - - continue; - } - - if ((key == 'd' || - key == 'D') && - *count > 0U) { - if (confirm_draft_delete( - drafts[selected].name - )) { - draft_delete_exercise( - drafts, - count, - selected - ); - - if (*count > 0U && - selected >= *count) { - selected = - *count - 1U; - } - } - } - } -} - static bool load_persisted_draft( TrainlogDatabase *database, const char *session_id, @@ -7512,140 +1263,6 @@ static const char *generator_goal_label(const char *goal_id) return goal_id; } -static bool generator_choose_zone(const TrainlogBodyZone **output) -{ - size_t selected = 0U; - size_t count = trainlog_body_zone_catalog_count(); - if (output == NULL || count == 0U || count > MAX_BODY_ZONES) return false; - for (;;) { - size_t index; - int key; - draw_shell("Générer une séance — zone", "↑↓ choisir Entrée continuer q annuler"); - for (index = 0U; index < count; ++index) { - const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index); - if (index == selected) trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - trainlog_terminal_printf(tui_terminal, 3 + (int)index, 4, - " %c %-40.40s ", index == selected ? '>' : ' ', - zone != NULL ? zone->display_name : "?"); - if (index == selected) trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - } - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - if (key == 'q' || key == 'Q' || key == 27 || key == TRAINLOG_KEY_ESCAPE) return false; - if (key == TRAINLOG_KEY_UP) selected = selected > 0U ? selected - 1U : count - 1U; - else if (key == TRAINLOG_KEY_DOWN) selected = selected + 1U < count ? selected + 1U : 0U; - else if (key == '\n' || key == TRAINLOG_KEY_ENTER) { - *output = trainlog_body_zone_catalog_at(selected); - return *output != NULL; - } - } -} - -static bool generator_choose_goal(const TrainlogSessionGenerationGoalPolicy **output) -{ - size_t selected = 0U; - size_t count = trainlog_session_generation_policy_v1.goal_count; - if (output == NULL || count == 0U) return false; - for (;;) { - size_t index; - int key; - draw_shell("Générer une séance — objectif", "↑↓ choisir Entrée continuer q annuler"); - for (index = 0U; index < count; ++index) { - const TrainlogSessionGenerationGoalPolicy *goal = - &trainlog_session_generation_policy_v1.goals[index]; - if (index == selected) trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - trainlog_terminal_printf(tui_terminal, 4 + (int)index, 4, - " %c %-18.18s %d × %d repos %d s ", - index == selected ? '>' : ' ', generator_goal_label(goal->id), - goal->sets, goal->repetitions, goal->rest_seconds); - if (index == selected) trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - } - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - if (key == 'q' || key == 'Q' || key == 27 || key == TRAINLOG_KEY_ESCAPE) return false; - if (key == TRAINLOG_KEY_UP) selected = selected > 0U ? selected - 1U : count - 1U; - else if (key == TRAINLOG_KEY_DOWN) selected = selected + 1U < count ? selected + 1U : 0U; - else if (key == '\n' || key == TRAINLOG_KEY_ENTER) { - *output = &trainlog_session_generation_policy_v1.goals[selected]; - return true; - } - } -} - -static bool generator_choose_duration(int *output_minutes) -{ - size_t selected = 0U; - size_t count = trainlog_session_generation_policy_v1.duration_preset_count; - if (output_minutes == NULL || count == 0U) return false; - for (;;) { - size_t index; - int key; - draw_shell("Générer une séance — durée", "↑↓ choisir Entrée continuer q annuler"); - for (index = 0U; index < count; ++index) { - if (index == selected) trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - trainlog_terminal_printf(tui_terminal, 4 + (int)index, 4, - " %c %d minutes ", index == selected ? '>' : ' ', - trainlog_session_generation_policy_v1.duration_presets_minutes[index]); - if (index == selected) trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - } - if (selected == count) trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - trainlog_terminal_printf(tui_terminal, 4 + (int)count, 4, - " %c Durée personnalisée ", selected == count ? '>' : ' '); - if (selected == count) trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - if (key == 'q' || key == 'Q' || key == 27 || key == TRAINLOG_KEY_ESCAPE) return false; - if (key == TRAINLOG_KEY_UP) selected = selected > 0U ? selected - 1U : count; - else if (key == TRAINLOG_KEY_DOWN) selected = selected < count ? selected + 1U : 0U; - else if (key == '\n' || key == TRAINLOG_KEY_ENTER) { - if (selected < count) { - *output_minutes = trainlog_session_generation_policy_v1.duration_presets_minutes[selected]; - return true; - } - return prompt_int_value(10, "Durée personnalisée (minutes)", - trainlog_session_generation_policy_v1.min_minutes, - trainlog_session_generation_policy_v1.max_minutes, - trainlog_session_generation_policy_v1.min_minutes, output_minutes); - } - } -} - -static int generator_confirm_exposure(const TrainlogBodyZone *zone, - const TrainlogBodyZoneRecentExposure *exposure) -{ - int key; - if (exposure->warning_level == TRAINLOG_GENERATION_WARNING_NONE) return 1; - draw_shell("Zone travaillée récemment", "c continuer z choisir une autre zone q annuler"); - trainlog_terminal_printf(tui_terminal, 4, 4, "%s travaillé récemment.", zone->display_name); - trainlog_terminal_printf(tui_terminal, 6, 4, - "24 h : %zu séries principales, %zu secondaires (%zu séances)", - exposure->within_24h.primary_set_count, exposure->within_24h.secondary_set_count, - exposure->within_24h.session_count); - trainlog_terminal_printf(tui_terminal, 7, 4, - "72 h : %zu séries principales, %zu secondaires (%zu séances)", - exposure->within_72h.primary_set_count, exposure->within_72h.secondary_set_count, - exposure->within_72h.session_count); - if (exposure->has_latest) trainlog_terminal_printf(tui_terminal, 9, 4, - "Dernière exposition réelle : %.48s", exposure->latest_started_at); - trainlog_terminal_printf(tui_terminal, 11, 4, - "Indicateur de récence uniquement; il ne mesure pas la récupération."); - trainlog_terminal_render(tui_terminal); - for (;;) { - key = trainlog_terminal_get_key(tui_terminal); - if (key == 'c' || key == 'C' || key == '\n' || key == TRAINLOG_KEY_ENTER) return 1; - if (key == 'z' || key == 'Z') return 0; - if (key == 'q' || key == 'Q' || key == 27 || key == TRAINLOG_KEY_ESCAPE) return -1; - } -} - static bool generator_prepare_preview(TrainlogDatabase *database, const TrainlogGeneratedSession *session, TrainlogGeneratorPreviewItem *items) { @@ -7672,51 +1289,6 @@ static bool generator_prepare_preview(TrainlogDatabase *database, return true; } -static bool generator_preview(const TrainlogGeneratedSession *session, - const TrainlogGeneratorPreviewItem *items) -{ - size_t selected = 0U; - for (;;) { - size_t index, top; - int key; - int visible = (trainlog_terminal_rows(tui_terminal) - 7) / 3; - if (visible < 1) visible = 1; - top = selected >= (size_t)visible ? selected - (size_t)visible + 1U : 0U; - draw_shell("Aperçu de la séance générée", - "↑↓ parcourir a accepter et saisir le réalisé q annuler"); - trainlog_terminal_printf(tui_terminal, 3, 4, - "%zu exercice(s) · estimation %d min · cibles indicatives", - session->exercise_count, session->estimated_duration_seconds / 60); - for (index = top; index < session->exercise_count && index - top < (size_t)visible; ++index) { - const TrainlogGeneratedExercise *exercise = &session->exercises[index]; - int row = 5 + (int)(index - top) * 3; - char weight[64]; - if (exercise->has_target_weight) (void)snprintf(weight, sizeof(weight), - " · %.2f kg observés · %.19s", exercise->target_weight_kg, - exercise->load_source_started_at); - else (void)snprintf(weight, sizeof(weight), " · aucune prescription numérique"); - if (index == selected) trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - trainlog_terminal_printf(tui_terminal, row, 3, "%c %zu. %-31.31s · %-24.24s", - index == selected ? '>' : ' ', index + 1U, items[index].exercise_name, items[index].equipment_name); - trainlog_terminal_printf(tui_terminal, row + 1, 5, - "%d×%d%s · repos %d s", exercise->target_sets, - exercise->target_repetitions, weight, exercise->rest_seconds); - trainlog_terminal_printf(tui_terminal, row + 2, 5, "%.24s · %.32s%s", - items[index].zone_name, items[index].movement_name, - exercise->exposure_warning_level != TRAINLOG_GENERATION_WARNING_NONE ? " · récence" : ""); - if (index == selected) trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - } - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - if (key == 'a' || key == 'A' || key == '\n' || key == TRAINLOG_KEY_ENTER) return true; - if (key == 'q' || key == 'Q' || key == 27 || key == TRAINLOG_KEY_ESCAPE) return false; - if (key == TRAINLOG_KEY_UP) selected = selected > 0U ? selected - 1U : session->exercise_count - 1U; - else if (key == TRAINLOG_KEY_DOWN) selected = selected + 1U < session->exercise_count ? selected + 1U : 0U; - } -} - static bool generator_build_drafts(TrainlogDatabase *database, const TrainlogGeneratedSession *session, TrainlogSessionDraftExercise *drafts) { @@ -7748,71 +1320,9 @@ static bool generator_build_drafts(TrainlogDatabase *database, return true; } -static void edit_persisted_session( - TrainlogDatabase *database, - const char *session_id -) -{ - TrainlogSessionSummary session; - TrainlogSessionDraftExercise - drafts[MAX_SESSION_EXERCISES]; - - size_t count = 0U; - - if (!load_persisted_draft( - database, - session_id, - &session, - drafts, - &count - )) { - status_line( - "Impossible de charger la séance à modifier.", - TRAINLOG_COLOR_ERROR - ); - - wait_key(); - return; - } - - if (!edit_session_draft( - database, - drafts, - &count, - session.session_type - )) { - status_line( - "Modification abandonnée : aucune donnée changée.", - TRAINLOG_COLOR_MUTED - ); - - wait_key(); - return; - } - - if (persist_draft_replacement( - database, - session_id, - drafts, - count - )) { - status_line( - "✓ Séance corrigée.", - TRAINLOG_COLOR_SUCCESS - ); - } else { - status_line( - "Échec de la correction : ancienne séance conservée.", - TRAINLOG_COLOR_ERROR - ); - } - - wait_key(); -} - static TrainlogStatus persist_new_session_drafts(TrainlogDatabase *database, TrainlogSessionDraftExercise *drafts, size_t exercise_count, - const char *started_at) + const char *started_at, TrainlogSessionType session_type) { TrainlogSessionExerciseInput *inputs; TrainlogSessionInput session; @@ -7835,7 +1345,7 @@ static TrainlogStatus persist_new_session_drafts(TrainlogDatabase *database, inputs[index] = drafts[index].input; } (void)memset(&session, 0, sizeof(session)); - session.session_type = TRAINLOG_SESSION_TRAINING; + session.session_type = session_type; (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", ended_at); @@ -7846,1541 +1356,6 @@ static TrainlogStatus persist_new_session_drafts(TrainlogDatabase *database, return status; } -static void generator_show_empty(const TrainlogGeneratedSession *session) -{ - size_t index; - draw_shell("Séance non générée", "Une touche pour revenir"); - trainlog_terminal_printf(tui_terminal, 4, 4, - "Pas assez d’exercices résolus et compatibles pour cette demande."); - for (index = 0U; index < session->shortage_count && index < 6U; ++index) - trainlog_terminal_printf(tui_terminal, 6 + (int)index, 4, "· %.58s", - session->shortage_codes[index]); - trainlog_terminal_printf(tui_terminal, 14, 4, - "Aucune correspondance n’a été inventée; rien ne peut être enregistré."); - wait_key(); -} - -static void screen_session_generator(TrainlogDatabase *database) -{ - const TrainlogBodyZone *zone; - const TrainlogSessionGenerationGoalPolicy *goal; - int duration_minutes; - if (!generator_choose_zone(&zone) || !generator_choose_goal(&goal) || - !generator_choose_duration(&duration_minutes)) return; - for (;;) { - TrainlogGeneratedSession *generated = calloc(1U, sizeof(*generated)); - TrainlogGeneratorPreviewItem *items = calloc(TRAINLOG_GENERATOR_MAX_SELECTED, - sizeof(*items)); - TrainlogGenerationDatabaseRequest request; - char reference_time[TRAINLOG_TIMESTAMP_MAX + 1U]; - TrainlogStatus status; - int exposure_choice; - if (generated == NULL || items == NULL) { - free(generated); free(items); - status_line("Mémoire insuffisante pour générer la séance.", TRAINLOG_COLOR_ERROR); - wait_key(); return; - } - if (trainlog_time_now_rfc3339(reference_time, sizeof(reference_time)) != TRAINLOG_STATUS_OK) { - free(generated); free(items); return; - } - (void)memset(&request, 0, sizeof(request)); - request.zone_id = zone->zone_id; - request.goal_id = goal->id; - request.duration_minutes = duration_minutes; - request.reference_time = reference_time; - /* NULL is deliberate: omitted equipment means all exact compatible - * supplied contexts, distinct from an explicit empty inventory. */ - request.available_equipment_ids = NULL; - status = trainlog_session_generate_from_database(database, &request, generated); - if (status != TRAINLOG_STATUS_OK) { - draw_shell("Génération impossible", "Une touche pour revenir"); - status_line(status == TRAINLOG_STATUS_DATABASE_ERROR - ? "Analyse refusée : horodatage ou historique stocké invalide." - : "La demande de génération est invalide ou incomplète.", - TRAINLOG_COLOR_ERROR); - free(generated); free(items); wait_key(); return; - } - exposure_choice = generator_confirm_exposure(zone, &generated->exposure); - if (exposure_choice < 0) { free(generated); free(items); return; } - if (exposure_choice == 0) { - free(generated); free(items); - if (!generator_choose_zone(&zone)) return; - continue; - } - if (generated->exercise_count == 0U) { - generator_show_empty(generated); - free(generated); free(items); return; - } - if (!generator_prepare_preview(database, generated, items)) { - free(generated); free(items); - status_line("Impossible de résoudre les libellés de l’aperçu.", TRAINLOG_COLOR_ERROR); - wait_key(); return; - } - if (!generator_preview(generated, items)) { - free(generated); free(items); return; - } - { - TrainlogSessionDraftExercise *drafts = calloc(MAX_SESSION_EXERCISES, - sizeof(*drafts)); - char accepted_at[TRAINLOG_TIMESTAMP_MAX + 1U]; - size_t count = generated->exercise_count; - bool built = drafts != NULL && generator_build_drafts(database, generated, drafts); - free(generated); free(items); - if (!built) { - free(drafts); - status_line("Impossible de préparer l’éditeur de séance.", TRAINLOG_COLOR_ERROR); - wait_key(); return; - } - if (trainlog_time_now_rfc3339(accepted_at, sizeof(accepted_at)) != TRAINLOG_STATUS_OK) { - free(drafts); return; - } - if (!edit_session_draft(database, drafts, &count, TRAINLOG_SESSION_TRAINING)) { - free(drafts); - draw_shell("Séance abandonnée", "Une touche pour revenir"); - status_line("Aucune donnée de séance n’a été enregistrée.", TRAINLOG_COLOR_MUTED); - wait_key(); return; - } - status = persist_new_session_drafts(database, drafts, count, accepted_at); - free(drafts); - draw_shell("Fin de séance", "Une touche pour revenir"); - status_line(status == TRAINLOG_STATUS_OK - ? "✓ Séance générée et réalisée enregistrée." - : "Échec lors de l’enregistrement de la séance.", - status == TRAINLOG_STATUS_OK ? TRAINLOG_COLOR_SUCCESS : TRAINLOG_COLOR_ERROR); - wait_key(); - return; - } - } -} - -static void screen_new_session( - TrainlogDatabase *database -) -{ - TrainlogSessionDraftExercise - drafts[MAX_SESSION_EXERCISES]; - - TrainlogSessionExerciseInput - exercise_inputs[MAX_SESSION_EXERCISES]; - - TrainlogSessionInput session; - char session_id[TRAINLOG_GENERATED_ID_CAPACITY]; - char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; - char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U]; - - TrainlogSessionType session_type = - TRAINLOG_SESSION_TRAINING; - - size_t catalog_count = 0U; - size_t exercise_count = 0U; - size_t index; - TrainlogStatus status; - - if (trainlog_database_exercise_count( - database, - &catalog_count - ) != TRAINLOG_STATUS_OK) { - return; - } - - if (catalog_count == 0U) { - draw_shell( - "Nouvelle séance", - "Une touche pour revenir" - ); - - status_line( - "Ajoutez d'abord au moins un exercice.", - TRAINLOG_COLOR_WARNING - ); - - wait_key(); - return; - } - - if (!choose_session_type( - &session_type - )) { - return; - } - - if (trainlog_id_generate( - "se", - session_id, - sizeof(session_id) - ) != TRAINLOG_STATUS_OK || - trainlog_time_now_rfc3339( - started_at, - sizeof(started_at) - ) != TRAINLOG_STATUS_OK) { - return; - } - - (void)memset( - drafts, - 0, - sizeof(drafts) - ); - - /* - * Start with the first exercise entry immediately. If the user cancels - * that form, nothing has been committed and the review screen still lets - * them add another exercise or abandon the whole session. - */ - if (draft_build_exercise( - database, - session_type, - &drafts[0], - NULL - )) { - exercise_count = 1U; - draft_edit_sets(&drafts[0]); - } - - if (!edit_session_draft( - database, - drafts, - &exercise_count, - session_type - )) { - draw_shell( - "Séance abandonnée", - "Une touche pour revenir" - ); - - status_line( - "Aucune donnée de séance n'a été enregistrée.", - TRAINLOG_COLOR_MUTED - ); - - wait_key(); - return; - } - - if (trainlog_time_now_rfc3339( - ended_at, - sizeof(ended_at) - ) != TRAINLOG_STATUS_OK) { - return; - } - - for (index = 0U; - index < exercise_count; - ++index) { - draft_bind_input( - &drafts[index] - ); - - exercise_inputs[index] = - drafts[index].input; - } - - (void)memset( - &session, - 0, - sizeof(session) - ); - - session.session_type = - session_type; - - (void)snprintf( - session.session_id, - sizeof(session.session_id), - "%s", - session_id - ); - - (void)snprintf( - session.started_at, - sizeof(session.started_at), - "%s", - started_at - ); - - (void)snprintf( - session.ended_at, - sizeof(session.ended_at), - "%s", - ended_at - ); - - session.exercises = - exercise_inputs; - - session.exercise_count = - exercise_count; - - status = - trainlog_database_insert_session( - database, - &session - ); - - draw_shell( - "Fin de séance", - "Une touche pour revenir" - ); - - if (status == TRAINLOG_STATUS_OK) { - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_SUCCESS - ) - ); - - trainlog_terminal_printf(tui_terminal, - 4, - 2, - "✓ Séance enregistrée." - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_SUCCESS - ) - ); - - trainlog_terminal_printf(tui_terminal, - 6, - 2, - "Début : %s", - started_at - ); - - trainlog_terminal_printf(tui_terminal, - 7, - 2, - "Fin : %s", - ended_at - ); - - trainlog_terminal_printf(tui_terminal, - 8, - 2, - "Type : %s", - session_type_label( - session_type - ) - ); - - trainlog_terminal_printf(tui_terminal, - 9, - 2, - "Exercices : %zu", - exercise_count - ); - } else { - status_line( - "Échec lors de l'enregistrement de la séance.", - TRAINLOG_COLOR_ERROR - ); - } - - wait_key(); -} - -/* TRAINLOG_SESSION_DETAILS_SCREEN */ - -static const char *session_detail_load_label(TrainlogLoadMode mode) -{ - switch (mode) { - case TRAINLOG_LOAD_EXTERNAL: - return "externe"; - case TRAINLOG_LOAD_ASSISTANCE: - return "assistance"; - case TRAINLOG_LOAD_NONE: - default: - return "aucune"; - } -} - -static void format_compact_max_weight( - double value, - char *output, - size_t output_size -) -{ - size_t length; - - if (output == NULL || output_size == 0U) { - return; - } - - (void)snprintf(output, output_size, "%.2f", value); - length = strlen(output); - while (length > 0U && output[length - 1U] == '0') { - output[--length] = '\0'; - } - if (length > 0U && output[length - 1U] == '.') { - output[--length] = '\0'; - } -} - -static void draw_max_test_table( - TrainlogDatabase *database, - const TrainlogPersistedExerciseDetail *exercises, - size_t count, - size_t selected, - bool decorated -) -{ - int header_row = decorated ? 18 : 7; - int first_row = header_row + 1; - int last_row = trainlog_terminal_rows(tui_terminal) - 3; - size_t visible = last_row >= first_row - ? (size_t)(last_row - first_row + 1) - : 1U; - size_t start = selected >= visible - ? selected - visible + 1U - : 0U; - size_t end = start + visible < count ? start + visible : count; - size_t index; - size_t max_count = 0U; - int column = decorated ? 5 : 4; - - for (index = 0U; index < count; ++index) { - if (exercises[index].has_max_weight != 0) { - ++max_count; - } - } - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - trainlog_terminal_printf(tui_terminal, decorated ? 16 : 6, column, - "Test de max — %zu résultat(s) MAX", max_count); - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - - trainlog_terminal_printf(tui_terminal, header_row, column, - decorated - ? "%-28s %-30s %10s" - : "%-20s %-27s %12s", - "Exercice", "Machine", "Max"); - - for (index = start; index < end; ++index) { - TrainlogResolvedEquipment resolved; - const char *equipment_label = "—"; - char max_text[32] = ""; - char max_label[40] = "—"; - - if (exercises[index].equipment_id[0] != '\0') { - equipment_label = - trainlog_database_resolve_equipment(database, - exercises[index].equipment_id, &resolved) == - TRAINLOG_STATUS_OK - ? resolved.display_name - : exercises[index].equipment_id; - } - if (exercises[index].has_max_weight != 0) { - format_compact_max_weight(exercises[index].max_weight_kg, - max_text, sizeof(max_text)); - (void)snprintf(max_label, sizeof(max_label), "%s kg", max_text); - } - - if (index == selected) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style(TRAINLOG_COLOR_SUCCESS)); - } - trainlog_terminal_printf(tui_terminal, - first_row + (int)(index - start), column, - decorated - ? "%-28.28s %-30.30s %10.10s" - : "%-20.20s %-27.27s %12.12s", - exercises[index].name, equipment_label, max_label); - if (index == selected) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style(TRAINLOG_COLOR_SUCCESS)); - } - } -} - -static void screen_session_detail( - TrainlogDatabase *database, - const char *session_id -) -{ - TrainlogSessionSummary session; - TrainlogPersistedExerciseDetail - exercises[MAX_SESSION_EXERCISES]; - - size_t count = 0U; - size_t selected = 0U; - size_t set_top = 0U; - TrainlogStatus status; - - status = - trainlog_database_get_session_details( - database, - session_id, - &session, - exercises, - MAX_SESSION_EXERCISES, - &count - ); - - if (status != TRAINLOG_STATUS_OK) { - draw_shell( - "Détail séance", - "Une touche pour revenir" - ); - - status_line( - "Impossible de charger la séance.", - TRAINLOG_COLOR_ERROR - ); - - wait_key(); - return; - } - - for (;;) { - bool decorated = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 32; - - int key; - - if (decorated) { - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - section_ascii_header( - ":: D E T A I L S E A N C E ::" - ); - - dashboard_panel( - 8, - 2, - 13, - trainlog_terminal_columns(tui_terminal) - 3, - "SEANCE" - ); - - focused_panel( - 14, - 2, - trainlog_terminal_rows(tui_terminal) - 4, - trainlog_terminal_columns(tui_terminal) - 3, - "EXERCICE", - true - ); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "←→/↑↓ exercice PgUp/PgDn séries i équipement e modifier b/Échap retour" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - 10, - 5, - "Début : %s", - session.started_at - ); - - trainlog_terminal_printf(tui_terminal, - 11, - 5, - "Fin : %s", - session.ended_at[0] != '\0' - ? session.ended_at - : "séance ouverte" - ); - - trainlog_terminal_printf(tui_terminal, - 12, - 5, - "Type : %s", - session_type_label( - session.session_type - ) - ); - } else if (session.session_type == TRAINLOG_SESSION_MAX_TEST) { - /* CONTRACT: max history is exercise-indexed; equipment is only - * rendered as occurrence context and never owns the value. */ - draw_max_test_table(database, exercises, count, selected, decorated); - } else { - draw_shell( - "TRAINLOG — Détail séance", - "←→/↑↓ exercice PgUp/PgDn séries i équipement e modifier b/Échap retour" - ); - - trainlog_terminal_printf(tui_terminal, - 3, - 4, - "Début : %s", - session.started_at - ); - - trainlog_terminal_printf(tui_terminal, - 4, - 4, - "Fin : %s", - session.ended_at[0] != '\0' - ? session.ended_at - : "séance ouverte" - ); - - trainlog_terminal_printf(tui_terminal, - 5, - 4, - "Type : %s", - session_type_label( - session.session_type - ) - ); - } - - if (count == 0U) { - trainlog_terminal_printf(tui_terminal, - decorated ? 17 : 7, - decorated ? 5 : 4, - "Aucun exercice dans cette séance." - ); - } else { - TrainlogPersistedExerciseDetail *exercise = - &exercises[selected]; - TrainlogResolvedEquipment resolved_equipment; - bool has_equipment = exercise->equipment_id[0] != '\0' && - trainlog_database_resolve_equipment(database, - exercise->equipment_id, &resolved_equipment) == TRAINLOG_STATUS_OK; - const char *equipment_label = has_equipment - ? resolved_equipment.display_name - : (exercise->equipment_id[0] != '\0' - ? exercise->equipment_id - : "—"); - - /* CONTRACT: history identifies the selected occurrence by its - * stable entry_id, so two appearances of Marche stay distinct. */ - trainlog_terminal_printf(tui_terminal, - decorated ? 19 : 9, - decorated ? 5 : 4, - "Occurrence : %s Équipement : %s", - exercise->entry_id, - equipment_label); - - if (exercise->has_max_weight != 0) { - int title_row = decorated ? 16 : 6; - int header_row = decorated ? 20 : 10; - int value_row = decorated ? 21 : 11; - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - trainlog_terminal_printf(tui_terminal, - title_row, - decorated ? 5 : 4, - "Exercice %zu/%zu — Test de max", - selected + 1U, - count); - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); - - trainlog_terminal_printf(tui_terminal, - header_row, - decorated ? 5 : 4, - "%-28s %-30s %10s", - "Exercice", "Machine", "Max"); - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style(TRAINLOG_COLOR_SUCCESS)); - trainlog_terminal_printf(tui_terminal, - value_row, - decorated ? 5 : 4, - "%-28.28s %-30.30s %7.2f kg", - exercise->name, - equipment_label, - exercise->max_weight_kg); - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style(TRAINLOG_COLOR_SUCCESS)); - } else if (exercise->recording_mode == - TRAINLOG_RECORDING_CONTINUOUS) { - char duration_text[64]; - - int title_row = - decorated ? 16 : 6; - - int mode_row = - decorated ? 18 : 8; - - int first_data_row = - decorated ? 20 : 10; - - int row = - first_data_row; - - if (trainlog_duration_format( - exercise->continuous_duration_seconds, - duration_text, - sizeof(duration_text) - ) != TRAINLOG_STATUS_OK) { - (void)snprintf( - duration_text, - sizeof(duration_text), - "%d s", - exercise->continuous_duration_seconds - ); - } - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - title_row, - decorated ? 5 : 4, - "Exercice %zu/%zu — %s", - selected + 1U, - count, - exercise->name - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - mode_row, - decorated ? 5 : 4, - "Mode : continu" - ); - - trainlog_terminal_printf(tui_terminal, - row++, - decorated ? 5 : 4, - "Durée : %s", - duration_text - ); - - if (exercise->has_continuous_speed != 0) { - trainlog_terminal_printf(tui_terminal, - row++, - decorated ? 5 : 4, - "Vitesse : %.1f km/h", - exercise->continuous_speed_kmh - ); - } - - if (exercise->has_continuous_distance != 0) { - trainlog_terminal_printf(tui_terminal, - row++, - decorated ? 5 : 4, - "Distance : %.2f km", - exercise->continuous_distance_km - ); - } - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_SUCCESS - ) - ); - - trainlog_terminal_printf(tui_terminal, - row + 1, - decorated ? 5 : 4, - "Réalisé : activité continue" - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_SUCCESS - ) - ); - } else { - char rest_text[64]; - char target_duration_text[64]; - - int title_row = - decorated ? 16 : 6; - - int mode_row = - decorated ? 18 : 8; - - int target_row = - decorated ? 20 : 10; - - int weight_row = - decorated ? 21 : 11; - - int actual_row = - decorated ? 23 : 13; - - int table_header_row = - decorated ? 25 : 15; - int table_last_row = - trainlog_terminal_rows(tui_terminal) - 4; - - if (trainlog_duration_format( - exercise->rest_seconds, - rest_text, - sizeof(rest_text) - ) != TRAINLOG_STATUS_OK) { - (void)snprintf( - rest_text, - sizeof(rest_text), - "%ds", - exercise->rest_seconds - ); - } - - target_duration_text[0] = '\0'; - - if (exercise->tracking_mode == - TRAINLOG_TRACKING_DURATION) { - (void)trainlog_duration_format( - exercise->target_duration_seconds, - target_duration_text, - sizeof(target_duration_text) - ); - } - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - title_row, - decorated ? 5 : 4, - "Exercice %zu/%zu — %s", - selected + 1U, - count, - exercise->name - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - mode_row, - decorated ? 5 : 4, - "Mode : %-12s Charge : %-10s Repos : %s", - exercise->tracking_mode == - TRAINLOG_TRACKING_REPS - ? "répétitions" - : "durée", - session_detail_load_label( - exercise->load_mode - ), - rest_text - ); - - if ( - exercise->target_sets <= 0 - ) { - trainlog_terminal_printf(tui_terminal, - target_row, - decorated ? 5 : 4, - "Cible : non renseignée" - ); - } else if ( - exercise->tracking_mode == - TRAINLOG_TRACKING_REPS - ) { - trainlog_terminal_printf(tui_terminal, - target_row, - decorated ? 5 : 4, - "Cible : %d série(s) × %d reps", - exercise->target_sets, - exercise->target_reps - ); - } else { - trainlog_terminal_printf(tui_terminal, - target_row, - decorated ? 5 : 4, - "Cible : %d série(s) × %s", - exercise->target_sets, - target_duration_text - ); - } - - if (exercise->has_target_weight != 0) { - trainlog_terminal_printf(tui_terminal, - weight_row, - decorated ? 5 : 4, - "%s cible : %.1f kg", - exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE - ? "Assistance" : "Charge", - exercise->target_weight_kg - ); - } else { - trainlog_terminal_printf(tui_terminal, - weight_row, - decorated ? 5 : 4, - "%s cible : —", - exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE - ? "Assistance" : "Charge" - ); - } - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_SUCCESS - ) - ); - - trainlog_terminal_printf(tui_terminal, - actual_row, - decorated ? 5 : 4, - "Réalisé : %zu série(s)", - exercise->actual_set_count - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_SUCCESS - ) - ); - - trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_BOLD); - trainlog_terminal_printf(tui_terminal, - table_header_row, - decorated ? 5 : 4, - "%-8s %-18s %-18s", - "Série", - exercise->tracking_mode == TRAINLOG_TRACKING_REPS - ? "Reps" : "Durée", - exercise->load_mode == TRAINLOG_LOAD_ASSISTANCE - ? "Assistance" : "Charge" - ); - trainlog_terminal_style_off(tui_terminal, TRAINLOG_TEXT_BOLD); - - { - size_t set_index; - size_t visible_sets = table_last_row > table_header_row - ? (size_t)(table_last_row - table_header_row) - : 0U; - - if (exercise->actual_set_count == 0U) { - set_top = 0U; - } else if (set_top >= exercise->actual_set_count) { - set_top = exercise->actual_set_count - 1U; - } - - for (set_index = set_top; - set_index < exercise->actual_set_count && - set_index - set_top < visible_sets; - ++set_index) { - char metric[64]; - char weight[64]; - const TrainlogSetInput *set = - &exercise->actual_sets[set_index]; - - if (exercise->tracking_mode == - TRAINLOG_TRACKING_DURATION) { - if (trainlog_duration_format( - set->duration_seconds, - metric, - sizeof(metric) - ) != TRAINLOG_STATUS_OK) { - (void)snprintf(metric, sizeof(metric), "%d s", - set->duration_seconds); - } - } else { - (void)snprintf(metric, sizeof(metric), "%d", - set->reps); - } - - if (set->has_weight) { - (void)snprintf(weight, sizeof(weight), "%.2f kg", - set->weight_kg); - } else { - (void)snprintf(weight, sizeof(weight), "—"); - } - - trainlog_terminal_printf(tui_terminal, - table_header_row + 1 + - (int)(set_index - set_top), - decorated ? 5 : 4, - "%-8zu %-18.18s %-18.18s", - set_index + 1U, metric, weight); - } - - } - - } - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if (key == 'b' || - key == 'B' || - key == 27) { - trainlog_database_free_session_details(exercises, count); - return; - } - - if (key == 'e' || - key == 'E') { - edit_persisted_session( - database, - session_id - ); - - trainlog_database_free_session_details(exercises, count); - return; - } - - if (count > 0U && (key == 'i' || key == 'I') && - exercises[selected].equipment_id[0] != '\0') { - TrainlogResolvedEquipment equipment; - if (trainlog_database_resolve_equipment(database, - exercises[selected].equipment_id, &equipment) == TRAINLOG_STATUS_OK) { - screen_equipment_detail(&equipment); - } - continue; - } - - if (count > 0U && key == TRAINLOG_KEY_PAGE_UP) { - set_top = set_top > 0U ? set_top - 1U : 0U; - continue; - } - - if (count > 0U && key == TRAINLOG_KEY_PAGE_DOWN && - set_top + 1U < exercises[selected].actual_set_count) { - ++set_top; - continue; - } - - if (count > 0U && - (key == TRAINLOG_KEY_RIGHT || - key == TRAINLOG_KEY_DOWN)) { - selected = - selected + 1U < count - ? selected + 1U - : 0U; - set_top = 0U; - } else if ( - count > 0U && - (key == TRAINLOG_KEY_LEFT || - key == TRAINLOG_KEY_UP) - ) { - selected = - selected > 0U - ? selected - 1U - : count - 1U; - set_top = 0U; - } - } -} - -/* TRAINLOG_HISTORY_ASCII_HEADER */ - -static void history_ascii_header(void) -{ - /* CONTRACT: history shares the current page shell; the old local logo - * occupied six unrelated rows and made this screen an exception. */ - section_ascii_header("Historique"); -} - -static void history_scrollbar( - int top, - int bottom, - int column, - size_t selected, - size_t count, - size_t visible -) -{ - int track_height; - int row; - int thumb; - - if (bottom <= top || - count <= visible || - count <= 1U) { - return; - } - - track_height = - bottom - top + 1; - - if (track_height < 2) { - return; - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - for (row = top; - row <= bottom; - ++row) { - trainlog_terminal_draw(tui_terminal, - row, - column, - 0x2502U - ); - } - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - thumb = - top + - (int)( - (selected * - (size_t)(track_height - 1)) / - (count - 1U) - ); - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_draw(tui_terminal, - thumb, - column, - 0x2593U - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); -} - -static void screen_history( - TrainlogDatabase *database -) -{ - TrainlogSessionSummary - sessions[MAX_SESSIONS]; - - size_t selected = 0U; - int nav_selected = 2; - int focus = 1; - - for (;;) { - size_t count = 0U; - size_t top = 0U; - size_t index; - - bool large_layout = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - int list_top = - large_layout - ? 11 - : 3; - - int list_bottom = - trainlog_terminal_rows(tui_terminal) - 4; - - int first_row = - list_top + 1; - - int visible_rows = - list_bottom - - first_row; - - int key; - - if (visible_rows < 1) { - return; - } - - if (trainlog_database_list_sessions( - database, - sessions, - MAX_SESSIONS, - &count - ) != TRAINLOG_STATUS_OK) { - return; - } - - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - if (large_layout) { - history_ascii_header(); - - primary_top_navbar( - 2, - nav_selected, - focus == 0 - ); - - focused_panel( - list_top, - 2, - list_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "SEANCES ENREGISTREES", - focus == 1 - ); - } else { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - 1, - 2, - " TRAINLOG — Historique " - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - large_layout - ? "Tab zone ↑↓/PgUp/PgDn liste ←→ menu Entrée ouvrir e modifier 0/Home accueil F1-F5 direct b/Échap retour" - : "↑↓ naviguer Entrée détail e modifier b/Échap retour" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - if (count == 0U) { - trainlog_terminal_printf(tui_terminal, - first_row + 1, - large_layout ? 6 : 4, - "Aucune séance." - ); - } - - if (count > 0U) { - if (selected >= count) { - selected = - count - 1U; - } - - if (selected >= - (size_t)visible_rows) { - top = - selected - - (size_t)visible_rows + - 1U; - } - - for (index = 0U; - index < (size_t)visible_rows && - top + index < count; - ++index) { - size_t absolute = - top + index; - - int item_row = - first_row + - (int)index; - - int item_col = - large_layout ? 5 : 4; - - if (focus == 1 && - absolute == selected) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - { - char display_date[17]; - - session_history_datetime( - sessions[absolute].started_at, - display_date - ); - - trainlog_terminal_printf(tui_terminal, - item_row, - item_col, - " %-16s %-14s %2zu exercice(s) ", - display_date, - session_type_history_label( - sessions[absolute].session_type - ), - sessions[absolute].exercise_count - ); - } - - if (focus == 1 && - absolute == selected) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - } - - if (large_layout) { - history_scrollbar( - first_row, - list_bottom - 1, - trainlog_terminal_columns(tui_terminal) - 5, - selected, - count, - (size_t)visible_rows - ); - } - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if (large_layout && - (key == TRAINLOG_KEY_TAB || - key == TRAINLOG_KEY_SHIFT_TAB)) { - focus = - focus == 0 - ? 1 - : 0; - continue; - } - - if (large_layout && - primary_top_nav_forward(key)) { - return; - } - - if (key == 'b' || - key == 'B' || - key == 27) { - return; - } - - if (large_layout && - focus == 0) { - if (key == TRAINLOG_KEY_LEFT) { - nav_selected = - nav_selected > 0 - ? nav_selected - 1 - : PRIMARY_NAV_COUNT - 1; - } else if (key == TRAINLOG_KEY_RIGHT) { - nav_selected = - nav_selected < PRIMARY_NAV_COUNT - 1 - ? nav_selected + 1 - : 0; - } else if ( - key == '\n' || - key == TRAINLOG_KEY_ENTER - ) { - if (primary_top_nav_activate( - nav_selected - )) { - return; - } - } - - continue; - } - - if (count == 0U) { - continue; - } - - if (key == TRAINLOG_KEY_UP) { - selected = - selected > 0U - ? selected - 1U - : 0U; - - continue; - } - - if (key == TRAINLOG_KEY_DOWN) { - selected = - selected + 1U < count - ? selected + 1U - : count - 1U; - - continue; - } - - if (key == TRAINLOG_KEY_PAGE_UP) { - size_t jump = - (size_t)visible_rows; - - selected = - selected > jump - ? selected - jump - : 0U; - - continue; - } - - if (key == TRAINLOG_KEY_PAGE_DOWN) { - size_t jump = - (size_t)visible_rows; - - selected = - selected + jump < count - ? selected + jump - : count - 1U; - - continue; - } - - if (key == '\n' || - key == TRAINLOG_KEY_ENTER) { - screen_session_detail( - database, - sessions[selected].session_id - ); - - continue; - } - - if (key == 'e' || - key == 'E') { - edit_persisted_session( - database, - sessions[selected].session_id - ); - } - } -} - -/* TRAINLOG_BODY_RECORDS_TUI */ - -#define MAX_BODY_OBSERVATIONS 512U - -static void body_panel( - int top, - int left, - int bottom, - int right, - const char *label -) -{ - TrainlogPanel *panel; - int height; - int width; - - if (top < 0 || - left < 0 || - bottom <= top || - right <= left || - bottom >= trainlog_terminal_rows(tui_terminal) || - right >= trainlog_terminal_columns(tui_terminal)) { - return; - } - - height = bottom - top + 1; - width = right - left + 1; - - panel = tui_panel_create(tui_terminal, height, - width, - top, - left - ); - - if (panel == NULL) { - return; - } - - tui_panel_box(panel); - - if (label != NULL && - label[0] != '\0' && - width > 8) { - tui_panel_style_on( - panel, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - tui_panel_print( - panel, - 0, - 2, - " %.*s ", - width - 6, - label - ); - - tui_panel_style_off( - panel, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - tui_panel_commit(panel); - tui_panel_destroy(panel); -} - static void body_short_date( const char *timestamp, char output[9] @@ -9448,83 +1423,6 @@ static size_t body_record_metric_count( return count; } -static void body_draw_scrollbar( - int top, - int bottom, - int column, - size_t selected, - size_t count, - size_t visible -) -{ - int track_height; - int row; - int thumb; - - if (bottom <= top || - count <= visible || - count <= 1U) { - return; - } - - track_height = - bottom - top + 1; - - if (track_height < 2) { - return; - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - for (row = top; - row <= bottom; - ++row) { - trainlog_terminal_draw(tui_terminal, - row, - column, - 0x2502U - ); - } - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - thumb = - top + - (int)( - (selected * - (size_t)(track_height - 1)) / - (count - 1U) - ); - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_draw(tui_terminal, - thumb, - column, - 0x2593U - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); -} - static void body_summary_text( const TrainlogBodyObservationRecord *record, char *output, @@ -9604,76 +1502,6 @@ static void body_summary_text( ); } -static bool prompt_optional_double_existing( - int row, - const char *label, - bool *present, - double *value -) -{ - char prompt[160]; - char buffer[64]; - - if (label == NULL || - present == NULL || - value == NULL) { - return false; - } - - if (*present) { - (void)snprintf( - prompt, - sizeof(prompt), - "%s [%.1f] (Entrée=garder, -=effacer) : ", - label, - *value - ); - } else { - (void)snprintf( - prompt, - sizeof(prompt), - "%s [vide] : ", - label - ); - } - - if (!prompt_text( - row, - prompt, - buffer, - sizeof(buffer), - true - )) { - return false; - } - - if (buffer[0] == '\0') { - return true; - } - - if (strcmp(buffer, "-") == 0) { - *present = false; - *value = 0.0; - return true; - } - - if (!parse_double_positive( - buffer, - value - )) { - status_line( - "Nombre positif invalide.", - TRAINLOG_COLOR_ERROR - ); - - wait_key(); - return false; - } - - *present = true; - return true; -} - static void body_input_from_record( const TrainlogBodyObservationRecord *record, TrainlogBodyObservationInput *input @@ -9794,460 +1622,6 @@ static void body_input_from_record( : NULL; } -static bool body_edit_record( - TrainlogDatabase *database, - const char *observation_id -) -{ - TrainlogBodyObservationRecord record; - TrainlogBodyObservationInput input; - int row; - - if (trainlog_database_get_body_observation( - database, - observation_id, - &record - ) != TRAINLOG_STATUS_OK) { - return false; - } - - body_input_from_record( - &record, - &input - ); - - draw_shell( - "Modifier relevé — 1/2", - "Entrée garde · - efface une mesure · Échap annule tout" - ); - - row = 4; - -#define EDIT_BODY_VALUE(label_, flag_, value_) \ - do { \ - if (!prompt_optional_double_existing( \ - row++, \ - (label_), \ - &(flag_), \ - &(value_) \ - )) { \ - return false; \ - } \ - } while (0) - - EDIT_BODY_VALUE( - "Poids kg", - input.has_body_weight, - input.body_weight_kg - ); - - EDIT_BODY_VALUE( - "Cou cm", - input.has_neck, - input.neck_cm - ); - - EDIT_BODY_VALUE( - "Épaules cm", - input.has_shoulders, - input.shoulders_cm - ); - - EDIT_BODY_VALUE( - "Poitrine cm", - input.has_chest, - input.chest_cm - ); - - EDIT_BODY_VALUE( - "Tour de taille cm", - input.has_waist, - input.waist_cm - ); - - EDIT_BODY_VALUE( - "Hanches cm", - input.has_hips, - input.hips_cm - ); - - draw_shell( - "Modifier relevé — 2/2", - "Entrée garde · - efface une mesure · Échap annule tout" - ); - - row = 4; - - EDIT_BODY_VALUE( - "Bras gauche cm", - input.has_left_arm, - input.left_arm_cm - ); - - EDIT_BODY_VALUE( - "Bras droit cm", - input.has_right_arm, - input.right_arm_cm - ); - - EDIT_BODY_VALUE( - "Avant-bras gauche cm", - input.has_left_forearm, - input.left_forearm_cm - ); - - EDIT_BODY_VALUE( - "Avant-bras droit cm", - input.has_right_forearm, - input.right_forearm_cm - ); - - EDIT_BODY_VALUE( - "Cuisse gauche cm", - input.has_left_thigh, - input.left_thigh_cm - ); - - EDIT_BODY_VALUE( - "Cuisse droite cm", - input.has_right_thigh, - input.right_thigh_cm - ); - - EDIT_BODY_VALUE( - "Mollet gauche cm", - input.has_left_calf, - input.left_calf_cm - ); - - EDIT_BODY_VALUE( - "Mollet droit cm", - input.has_right_calf, - input.right_calf_cm - ); - -#undef EDIT_BODY_VALUE - - if (trainlog_database_update_body_observation( - database, - &input - ) == TRAINLOG_STATUS_OK) { - status_line( - "✓ Relevé corrigé.", - TRAINLOG_COLOR_SUCCESS - ); - - wait_key(); - return true; - } - - status_line( - "Correction impossible. Au moins une mesure doit rester présente.", - TRAINLOG_COLOR_ERROR - ); - - wait_key(); - return false; -} - -static void body_detail_value( - int row, - int column, - const char *label, - bool present, - double value, - const char *unit -) -{ - if (present) { - trainlog_terminal_printf(tui_terminal, - row, - column, - "%-24s %7.1f %s", - label, - value, - unit - ); - } else { - trainlog_terminal_printf(tui_terminal, - row, - column, - "%-24s %7s", - label, - "—" - ); - } -} - -static void screen_body_observation_detail( - TrainlogDatabase *database, - const char *observation_id -) -{ - int page = 0; - - for (;;) { - TrainlogBodyObservationRecord record; - char date[9]; - bool decorated = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 34; - int frame_top = - decorated ? 8 : 3; - int key; - - if (trainlog_database_get_body_observation( - database, - observation_id, - &record - ) != TRAINLOG_STATUS_OK) { - status_line( - "Impossible de charger le relevé.", - TRAINLOG_COLOR_ERROR - ); - - wait_key(); - return; - } - - body_short_date( - record.observed_at, - date - ); - - if (decorated) { - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - section_ascii_header( - ":: D E T A I L C O R P S ::" - ); - - focused_panel( - frame_top, - 2, - trainlog_terminal_rows(tui_terminal) - 4, - trainlog_terminal_columns(tui_terminal) - 3, - page == 0 - ? "RELEVE — GENERAL" - : "RELEVE — MEMBRES", - true - ); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "←→ page e Modifier b/Échap retour" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } else { - draw_shell( - "TRAINLOG — Détail relevé", - "←→ page e Modifier b/Échap retour" - ); - - body_panel( - 3, - 2, - trainlog_terminal_rows(tui_terminal) - 4, - trainlog_terminal_columns(tui_terminal) - 3, - page == 0 - ? "RELEVE — GENERAL" - : "RELEVE — MEMBRES" - ); - } - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - frame_top + 2, - 5, - "%s page %d/2", - date, - page + 1 - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - if (page == 0) { - body_detail_value( - frame_top + 5, - 6, - "Poids", - record.has_body_weight, - record.body_weight_kg, - "kg" - ); - - body_detail_value( - frame_top + 7, - 6, - "Cou", - record.has_neck, - record.neck_cm, - "cm" - ); - - body_detail_value( - frame_top + 9, - 6, - "Épaules", - record.has_shoulders, - record.shoulders_cm, - "cm" - ); - - body_detail_value( - frame_top + 11, - 6, - "Poitrine", - record.has_chest, - record.chest_cm, - "cm" - ); - - body_detail_value( - frame_top + 13, - 6, - "Tour de taille", - record.has_waist, - record.waist_cm, - "cm" - ); - - body_detail_value( - frame_top + 15, - 6, - "Hanches", - record.has_hips, - record.hips_cm, - "cm" - ); - } else { - body_detail_value( - frame_top + 5, - 6, - "Bras gauche", - record.has_left_arm, - record.left_arm_cm, - "cm" - ); - - body_detail_value( - frame_top + 7, - 6, - "Bras droit", - record.has_right_arm, - record.right_arm_cm, - "cm" - ); - - body_detail_value( - frame_top + 9, - 6, - "Avant-bras gauche", - record.has_left_forearm, - record.left_forearm_cm, - "cm" - ); - - body_detail_value( - frame_top + 11, - 6, - "Avant-bras droit", - record.has_right_forearm, - record.right_forearm_cm, - "cm" - ); - - body_detail_value( - frame_top + 13, - 6, - "Cuisse gauche", - record.has_left_thigh, - record.left_thigh_cm, - "cm" - ); - - body_detail_value( - frame_top + 15, - 6, - "Cuisse droite", - record.has_right_thigh, - record.right_thigh_cm, - "cm" - ); - - body_detail_value( - frame_top + 17, - 6, - "Mollet gauche", - record.has_left_calf, - record.left_calf_cm, - "cm" - ); - - body_detail_value( - frame_top + 19, - 6, - "Mollet droit", - record.has_right_calf, - record.right_calf_cm, - "cm" - ); - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if (key == 'b' || - key == 'B' || - key == 27) { - return; - } - - if (key == TRAINLOG_KEY_LEFT || - key == TRAINLOG_KEY_RIGHT) { - page = - page == 0 - ? 1 - : 0; - continue; - } - - if (key == 'e' || - key == 'E') { - (void)body_edit_record( - database, - observation_id - ); - } - } -} - /* TRAINLOG_BODY_ANALYTICS_TUI_V1 */ static bool body_analytics_profile_path( @@ -10542,116 +1916,6 @@ static bool body_analytics_profile_save( ) == 0; } -static bool body_analytics_profile_prompt( - TrainlogBodyAnalyticsProfile *profile -) -{ - char height_text[64]; - int formula; - double height_cm; - - if (profile == NULL) { - return false; - } - - formula = - trainlog_body_analytics_profile_valid( - profile - ) - ? ( - profile->formula == - TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE - ? 2 - : 1 - ) - : 1; - - height_cm = - trainlog_body_analytics_profile_valid( - profile - ) - ? profile->height_cm - : 170.0; - - draw_shell( - "TRAINLOG — Profil d'estimation", - "Entrée valide · Échap annule" - ); - - if ( - !prompt_int_value( - 4, - "Formule 1=homme 2=femme", - 1, - 2, - formula, - &formula - ) - ) { - return false; - } - - for (;;) { - char label[128]; - - (void)snprintf( - label, - sizeof(label), - "Taille cm [%.1f] : ", - height_cm - ); - - if ( - !prompt_text( - 6, - label, - height_text, - sizeof(height_text), - true - ) - ) { - return false; - } - - if ( - height_text[0] == '\0' - ) { - break; - } - - if ( - parse_double_positive( - height_text, - &height_cm - ) && - height_cm >= 100.0 && - height_cm <= 250.0 - ) { - break; - } - - status_line( - "Taille attendue entre 100 et 250 cm.", - TRAINLOG_COLOR_ERROR - ); - - trainlog_terminal_render(tui_terminal); - } - - profile->formula = - formula == 2 - ? TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE - : TRAINLOG_BODY_ANALYTICS_FORMULA_MALE; - - profile->height_cm = - height_cm; - - return - body_analytics_profile_save( - profile - ); -} - static const TrainlogBodyObservationRecord * body_analytics_oldest_weight( const TrainlogBodyObservationRecord *records, @@ -10761,946 +2025,6 @@ static bool body_analytics_oldest_estimate( return false; } -static void body_analytics_value( - int row, - const char *label, - bool present, - double value, - const char *suffix -) -{ - if (present) { - trainlog_terminal_printf(tui_terminal, - row, - 6, - "%-25s %8.2f %s", - label, - value, - suffix != NULL - ? suffix - : "" - ); - } else { - trainlog_terminal_printf(tui_terminal, - row, - 6, - "%-25s %8s", - label, - "—" - ); - } -} - -static void screen_body_analytics( - TrainlogDatabase *database -) -{ - TrainlogBodyObservationRecord - records[MAX_BODY_OBSERVATIONS]; - - size_t count = 0U; - int page = 0; - - TrainlogBodyAnalyticsProfile profile; - - bool has_profile = - body_analytics_profile_load( - &profile - ); - - if ( - database == NULL || - trainlog_database_list_body_observations( - database, - records, - MAX_BODY_OBSERVATIONS, - &count - ) != TRAINLOG_STATUS_OK - ) { - return; - } - - for (;;) { - TrainlogBodyAnalyticsResult - current; - - TrainlogBodyAnalyticsResult - oldest_estimate; - - const TrainlogBodyObservationRecord *latest = - count > 0U - ? &records[0] - : NULL; - - const TrainlogBodyObservationRecord *oldest_weight = - body_analytics_oldest_weight( - records, - count - ); - - const TrainlogBodyObservationRecord *oldest_waist = - body_analytics_oldest_waist( - records, - count - ); - - bool has_current = - latest != NULL && - trainlog_body_analytics_calculate( - has_profile - ? &profile - : NULL, - latest, - ¤t - ) == - TRAINLOG_STATUS_OK; - - bool has_oldest_estimate = - has_profile && - body_analytics_oldest_estimate( - &profile, - records, - count, - &oldest_estimate - ); - - bool decorated = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - int panel_top = - decorated ? 8 : 3; - - int panel_bottom = - trainlog_terminal_rows(tui_terminal) - 4; - - int key; - - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, - trainlog_terminal_rows(tui_terminal) - 1, - trainlog_terminal_columns(tui_terminal) - 1); - - if (decorated) { - section_ascii_header( - ":: A N A L Y S E C O R P O R E L L E ::" - ); - - focused_panel( - panel_top, - 2, - panel_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - page == 0 - ? "COMPOSITION ET TENDANCE" - : "PROPORTIONS ET SYMETRIE", - true - ); - } else { - draw_shell( - "TRAINLOG — Analyse corporelle", - "←→ page p profil b/Échap retour" - ); - - body_panel( - panel_top, - 2, - panel_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - page == 0 - ? "COMPOSITION" - : "PROPORTIONS" - ); - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "←→ page p profil estimation b/Échap retour" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - if (count == 0U) { - trainlog_terminal_printf(tui_terminal, - panel_top + 2, - 6, - "Aucun relevé corporel." - ); - - trainlog_terminal_printf(tui_terminal, - panel_top + 4, - 6, - "Ajoutez d'abord un relevé réel." - ); - } else if (page == 0) { - char date[9]; - - body_short_date( - latest->observed_at, - date - ); - - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - trainlog_terminal_printf(tui_terminal, - panel_top + 2, - 6, - "Dernier relevé : %s", - date - ); - - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_BOLD | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - - if (has_profile) { - trainlog_terminal_printf(tui_terminal, - panel_top + 4, - 6, - "Profil estimation : %s · %.1f cm", - profile.formula == - TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE - ? "formule femme" - : "formule homme", - profile.height_cm - ); - } else { - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_WARNING - ) - ); - - trainlog_terminal_printf(tui_terminal, - panel_top + 4, - 6, - "Profil estimation non configuré · p pour configurer." - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_WARNING - ) - ); - } - - body_analytics_value( - panel_top + 6, - "Graisse estimée", - has_current && - current - .has_body_fat_estimate, - has_current - ? current - .body_fat_percent - : 0.0, - "%" - ); - - body_analytics_value( - panel_top + 7, - "Masse grasse estimée", - has_current && - current - .has_fat_mass_estimate, - has_current - ? current - .fat_mass_kg - : 0.0, - "kg" - ); - - body_analytics_value( - panel_top + 8, - "Masse maigre estimée", - has_current && - current - .has_lean_mass_estimate, - has_current - ? current - .lean_mass_kg - : 0.0, - "kg" - ); - - if ( - latest->has_body_weight - ) { - double delta = - oldest_weight != NULL - ? latest->body_weight_kg - - oldest_weight - ->body_weight_kg - : 0.0; - - trainlog_terminal_printf(tui_terminal, - panel_top + 10, - 6, - "Poids : %.1f kg · variation depuis 1er poids : %+.1f kg", - latest->body_weight_kg, - delta - ); - } else { - trainlog_terminal_printf(tui_terminal, - panel_top + 10, - 6, - "Poids : —" - ); - } - - if ( - latest->has_waist - ) { - double delta = - oldest_waist != NULL - ? latest->waist_cm - - oldest_waist - ->waist_cm - : 0.0; - - trainlog_terminal_printf(tui_terminal, - panel_top + 11, - 6, - "Tour de taille : %.1f cm · variation : %+.1f cm", - latest->waist_cm, - delta - ); - } else { - trainlog_terminal_printf(tui_terminal, - panel_top + 11, - 6, - "Tour de taille : —" - ); - } - - if ( - has_current && - current - .has_body_fat_estimate && - has_oldest_estimate - ) { - trainlog_terminal_printf(tui_terminal, - panel_top + 12, - 6, - "Variation graisse estimée : %+.2f point(s)", - current - .body_fat_percent - - oldest_estimate - .body_fat_percent - ); - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_WARNING - ) - ); - - trainlog_terminal_printf(tui_terminal, - panel_top + 14, - 6, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 14, - "Estimation anthropométrique : tendance utile, pas mesure directe de composition." - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_WARNING - ) - ); - } else { - body_analytics_value( - panel_top + 3, - "Taille / hanches", - has_current && - current - .has_waist_hip_ratio, - has_current - ? current - .waist_hip_ratio - : 0.0, - "" - ); - - body_analytics_value( - panel_top + 4, - "Épaules / taille", - has_current && - current - .has_shoulder_waist_ratio, - has_current - ? current - .shoulder_waist_ratio - : 0.0, - "" - ); - - body_analytics_value( - panel_top + 5, - "Poitrine / taille", - has_current && - current - .has_chest_waist_ratio, - has_current - ? current - .chest_waist_ratio - : 0.0, - "" - ); - - trainlog_terminal_printf(tui_terminal, - panel_top + 8, - 6, - "ASYMETRIE GAUCHE / DROITE" - ); - - body_analytics_value( - panel_top + 10, - "Bras", - has_current && - current - .has_arm_asymmetry, - has_current - ? current - .arm_asymmetry_percent - : 0.0, - "%" - ); - - body_analytics_value( - panel_top + 11, - "Avant-bras", - has_current && - current - .has_forearm_asymmetry, - has_current - ? current - .forearm_asymmetry_percent - : 0.0, - "%" - ); - - body_analytics_value( - panel_top + 12, - "Cuisses", - has_current && - current - .has_thigh_asymmetry, - has_current - ? current - .thigh_asymmetry_percent - : 0.0, - "%" - ); - - body_analytics_value( - panel_top + 13, - "Mollets", - has_current && - current - .has_calf_asymmetry, - has_current - ? current - .calf_asymmetry_percent - : 0.0, - "%" - ); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - panel_top + 16, - 6, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 14, - "Ratios et asymétries sont descriptifs : Trainlog ne les transforme pas en diagnostic." - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if ( - key == 'b' || - key == 'B' || - key == 27 - ) { - return; - } - - if ( - key == TRAINLOG_KEY_LEFT || - key == TRAINLOG_KEY_RIGHT - ) { - page = - page == 0 - ? 1 - : 0; - - continue; - } - - if ( - key == 'p' || - key == 'P' - ) { - TrainlogBodyAnalyticsProfile - edited; - - if (has_profile) { - edited = - profile; - } else { - edited.formula = - TRAINLOG_BODY_ANALYTICS_FORMULA_MALE; - - edited.height_cm = - 170.0; - } - - if ( - body_analytics_profile_prompt( - &edited - ) - ) { - profile = - edited; - - has_profile = true; - - status_line( - "✓ Profil d'estimation enregistré localement.", - TRAINLOG_COLOR_SUCCESS - ); - - trainlog_terminal_render(tui_terminal); - (void)trainlog_terminal_get_key(tui_terminal); - } - } - } -} - -static void screen_body( - TrainlogDatabase *database -) -{ - TrainlogBodyObservationRecord - records[MAX_BODY_OBSERVATIONS]; - - size_t selected = 0U; - int nav_selected = 5; - int focus = 1; - - for (;;) { - TrainlogBodyMetricPoint - weight_points[MAX_BODY_METRIC_POINTS]; - - size_t count = 0U; - size_t weight_count = 0U; - size_t top = 0U; - size_t index; - - bool large_layout = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - int graph_top = - large_layout ? 11 : 3; - - int graph_bottom = - large_layout ? 19 : 11; - - int list_top = - graph_bottom + 1; - - int list_bottom = - trainlog_terminal_rows(tui_terminal) - 4; - - int first_row = - list_top + 1; - - int visible_rows = - list_bottom - - first_row; - - int key; - - if (visible_rows < 1) { - return; - } - - if (trainlog_database_list_body_observations( - database, - records, - MAX_BODY_OBSERVATIONS, - &count - ) != TRAINLOG_STATUS_OK) { - return; - } - - (void)trainlog_database_list_body_metric_points( - database, - TRAINLOG_BODY_METRIC_WEIGHT, - weight_points, - MAX_BODY_METRIC_POINTS, - &weight_count - ); - - if (count > 0U && - selected >= count) { - selected = - count - 1U; - } - - if (count > 0U && - selected >= - (size_t)visible_rows) { - top = - selected - - (size_t)visible_rows + - 1U; - } - - if (large_layout) { - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, trainlog_terminal_rows(tui_terminal) - 1, trainlog_terminal_columns(tui_terminal) - 1); - - section_ascii_header( - ":: C O R P S ::" - ); - - primary_top_navbar( - 5, - nav_selected, - focus == 0 - ); - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "Tab zone ↑↓/PgUp/PgDn relevés ←→ menu Entrée détail e Modifier a ajouter v analyse g vue globale 0/Home accueil F1-F5 direct" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } else { - draw_shell( - "TRAINLOG — Corps", - "↑↓ choisir Entrée détail e Modifier a ajouter v analyse g vue globale b/Échap retour" - ); - } - - body_panel( - graph_top, - 2, - graph_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "EVOLUTION DU POIDS" - ); - - if (weight_count == 0U) { - trainlog_terminal_printf(tui_terminal, - graph_top + 3, - 6, - "Aucune donnée de poids." - ); - } else { - draw_body_metric_graph( - graph_top + 1, - graph_bottom - - graph_top - - 2, - weight_points, - weight_count, - "kg" - ); - } - - if (large_layout) { - focused_panel( - list_top, - 2, - list_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "RELEVES ENREGISTRES", - focus == 1 - ); - } else { - body_panel( - list_top, - 2, - list_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "RELEVES ENREGISTRES" - ); - } - - if (count == 0U) { - trainlog_terminal_printf(tui_terminal, - first_row + 1, - 6, - "Aucun relevé. Appuyez sur a pour en ajouter un." - ); - } - - for (index = 0U; - index < (size_t)visible_rows && - top + index < count; - ++index) { - size_t absolute = - top + index; - - char date[9]; - char summary[128]; - - int row = - first_row + - (int)index; - - body_short_date( - records[absolute].observed_at, - date - ); - - body_summary_text( - &records[absolute], - summary, - sizeof(summary) - ); - - if (focus == 1 && - absolute == selected) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_printf(tui_terminal, - row, - 5, - " %-8s %.*s ", - date, - trainlog_terminal_columns(tui_terminal) - 20, - summary - ); - - if (focus == 1 && - absolute == selected) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - } - - body_draw_scrollbar( - first_row, - list_bottom - 1, - trainlog_terminal_columns(tui_terminal) - 5, - selected, - count, - (size_t)visible_rows - ); - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if (large_layout && - (key == TRAINLOG_KEY_TAB || - key == TRAINLOG_KEY_SHIFT_TAB)) { - focus = - focus == 0 - ? 1 - : 0; - continue; - } - - if (large_layout && - primary_top_nav_forward(key)) { - return; - } - - if (key == 'b' || - key == 'B' || - key == 27) { - return; - } - - if (large_layout && - focus == 0) { - if (key == TRAINLOG_KEY_LEFT) { - nav_selected = - nav_selected > 0 - ? nav_selected - 1 - : PRIMARY_NAV_COUNT - 1; - } else if (key == TRAINLOG_KEY_RIGHT) { - nav_selected = - nav_selected < PRIMARY_NAV_COUNT - 1 - ? nav_selected + 1 - : 0; - } else if ( - key == '\n' || - key == TRAINLOG_KEY_ENTER - ) { - if (primary_top_nav_activate( - nav_selected - )) { - return; - } - } - - continue; - } - - if (key == TRAINLOG_KEY_UP && - count > 0U) { - selected = - selected > 0U - ? selected - 1U - : 0U; - - continue; - } - - if (key == TRAINLOG_KEY_DOWN && - count > 0U) { - selected = - selected + 1U < count - ? selected + 1U - : count - 1U; - - continue; - } - - if (key == TRAINLOG_KEY_PAGE_UP && - count > 0U) { - size_t jump = - (size_t)visible_rows; - - selected = - selected > jump - ? selected - jump - : 0U; - - continue; - } - - if (key == TRAINLOG_KEY_PAGE_DOWN && - count > 0U) { - size_t jump = - (size_t)visible_rows; - - selected = - selected + jump < count - ? selected + jump - : count - 1U; - - continue; - } - - if ((key == '\n' || - key == TRAINLOG_KEY_ENTER) && - count > 0U) { - screen_body_observation_detail( - database, - records[selected].observation_id - ); - - continue; - } - - if ((key == 'e' || - key == 'E') && - count > 0U) { - (void)body_edit_record( - database, - records[selected].observation_id - ); - - continue; - } - - if (key == 'a' || - key == 'A') { - add_body_observation(database); - selected = 0U; - continue; - } - - if (key == 'v' || - key == 'V') { - screen_body_analytics( - database - ); - - continue; - } - - if (key == 'g' || - key == 'G') { - draw_shell( - "TRAINLOG — Corps — Vue globale", - "Une touche pour revenir" - ); - - draw_global_body_overlay( - database - ); - - trainlog_terminal_render(tui_terminal); - (void)trainlog_terminal_get_key(tui_terminal); - } - } -} - /* TRAINLOG_SYNC_TUI */ /* TRAINLOG_SHARED_SYNC_ENGINE_V1 */ @@ -11998,253 +2322,6 @@ static void sync_history_load( copied; } -static void screen_sync_run_detail( - const char *sync_id -) -{ - char path[ - PATH_MAX + 1U - ]; - - char text[ - SYNC_DETAIL_TEXT_CAPACITY - ]; - - char *lines[ - SYNC_DETAIL_LINE_CAPACITY - ]; - - size_t line_count = 0U; - size_t offset = 0U; - FILE *file; - size_t used; - - if ( - !sync_runs_path( - sync_id, - path, - sizeof(path) - ) - ) { - return; - } - - file = - fopen( - path, - "rb" - ); - - if (file == NULL) { - return; - } - - used = - fread( - text, - 1U, - sizeof(text) - 1U, - file - ); - - text[used] = '\0'; - - (void)fclose( - file - ); - - if (used > 0U) { - char *cursor = text; - - lines[line_count] = - cursor; - - ++line_count; - - while ( - *cursor != '\0' && - line_count < - SYNC_DETAIL_LINE_CAPACITY - ) { - if (*cursor == '\n') { - *cursor = '\0'; - - if ( - cursor[1] != '\0' - ) { - lines[line_count] = - cursor + 1; - - ++line_count; - } - } - - ++cursor; - } - } - - for (;;) { - bool large_layout = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - int panel_top = - large_layout - ? 8 - : 3; - - int panel_bottom = - trainlog_terminal_rows(tui_terminal) - 4; - - int first_row = - panel_top + 2; - - int visible = - panel_bottom - - first_row; - - int key; - size_t index; - - if (visible < 1) { - return; - } - - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, - trainlog_terminal_rows(tui_terminal) - 1, - trainlog_terminal_columns(tui_terminal) - 1); - - if (large_layout) { - section_ascii_header( - ":: S Y N C S H O W ::" - ); - - focused_panel( - panel_top, - 2, - panel_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "DETAIL SYNCHRONISATION", - true - ); - } else { - draw_shell( - "TRAINLOG — Sync show", - "↑↓ défiler PgUp/PgDn page b/Échap retour" - ); - } - - for ( - index = 0U; - index < (size_t)visible && - offset + index < - line_count; - ++index - ) { - trainlog_terminal_printf(tui_terminal, - first_row + - (int)index, - large_layout - ? 5 - : 4, - "%.*s", - trainlog_terminal_columns(tui_terminal) - - ( - large_layout - ? 10 - : 8 - ), - lines[ - offset + - index - ] - ); - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - "↑↓ défiler PgUp/PgDn page b/Échap retour" - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - if ( - key == 'b' || - key == 'B' || - key == 27 || - key == '\n' || - key == TRAINLOG_KEY_ENTER - ) { - return; - } - - if ( - key == TRAINLOG_KEY_UP && - offset > 0U - ) { - --offset; - } else if ( - key == TRAINLOG_KEY_DOWN && - offset + - (size_t)visible < - line_count - ) { - ++offset; - } else if ( - key == TRAINLOG_KEY_PAGE_UP - ) { - size_t jump = - (size_t)visible; - - offset = - offset > jump - ? offset - jump - : 0U; - } else if ( - key == TRAINLOG_KEY_PAGE_DOWN - ) { - size_t jump = - (size_t)visible; - - if ( - offset + jump < - line_count - ) { - offset += jump; - } - - if ( - line_count > - (size_t)visible && - offset + - (size_t)visible > - line_count - ) { - offset = - line_count - - (size_t)visible; - } - } - } -} - static double sync_bytes_to_gib( uint64_t bytes ) @@ -12258,704 +2335,5392 @@ static double sync_bytes_to_gib( ); } -static TrainlogStatus sync_screen_run( - TrainlogSyncDirection direction, - TrainlogSyncReport *report, - void *context -) -{ - (void)context; +typedef enum TrainlogSessionPhase { + TRAINLOG_SESSION_IDLE = 0, + TRAINLOG_SESSION_CHOOSE_TYPE, + TRAINLOG_SESSION_EXERCISE_PICKER, + TRAINLOG_SESSION_EQUIPMENT_PICKER, + TRAINLOG_SESSION_EQUIPMENT_CREATE, + TRAINLOG_SESSION_PLANNING, + TRAINLOG_SESSION_DRAFT, + TRAINLOG_SESSION_ACTUALS, + TRAINLOG_SESSION_GENERATOR_CONFIG, + TRAINLOG_SESSION_GENERATOR_WARNING, + TRAINLOG_SESSION_GENERATOR_PREVIEW, + TRAINLOG_SESSION_CONFIRM_REMOVE, + TRAINLOG_SESSION_CONFIRM_ABANDON, + TRAINLOG_SESSION_CONFIRM_LEAVE, + TRAINLOG_SESSION_MESSAGE +} TrainlogSessionPhase; - return trainlog_sync_run( - TRAINLOG_SYNC_TRIGGER_TUI, - false, - direction, - report - ); +typedef enum TrainlogSessionFormPurpose { + TRAINLOG_SESSION_FORM_NONE = 0, + TRAINLOG_SESSION_FORM_SET_METRIC, + TRAINLOG_SESSION_FORM_SET_WEIGHT, + TRAINLOG_SESSION_FORM_CONTINUOUS_DURATION, + TRAINLOG_SESSION_FORM_CONTINUOUS_SPEED, + TRAINLOG_SESSION_FORM_CONTINUOUS_DISTANCE, + TRAINLOG_SESSION_FORM_TARGET_SETS, + TRAINLOG_SESSION_FORM_TARGET_REPS, + TRAINLOG_SESSION_FORM_TARGET_DURATION, + TRAINLOG_SESSION_FORM_REST, + TRAINLOG_SESSION_FORM_TARGET_WEIGHT, + TRAINLOG_SESSION_FORM_TARGET_PERCENT_MAX, + TRAINLOG_SESSION_FORM_EQUIPMENT_NAME, + TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL, + TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE, + TRAINLOG_SESSION_FORM_EQUIPMENT_LOAD, + TRAINLOG_SESSION_FORM_GENERATED_SETS, + TRAINLOG_SESSION_FORM_GENERATED_REPS, + TRAINLOG_SESSION_FORM_GENERATED_PERCENT_MAX, + TRAINLOG_SESSION_FORM_MAX_WEIGHT +} TrainlogSessionFormPurpose; + +typedef struct TrainlogSessionController { + /* CONTRACT: desktop draft durability is exactly one process run. Route + * changes retain this controller; only explicit discard clears it. */ + bool has_draft; + bool dirty; + bool generated_preview; + bool generation_warning_acknowledged; + bool correcting; + bool replacing_occurrence; + TrainlogSessionDraftExercise drafts[MAX_SESSION_EXERCISES]; + size_t draft_count; + size_t selected; + size_t set_selected; + size_t set_field; + size_t planning_field; + TrainlogSessionType session_type; + TrainlogSessionPhase phase; + TrainlogSessionPhase return_phase; + TrainlogSessionFormPurpose form_purpose; + TrainlogFormField form; + TrainlogSetInput pending_set; + int pending_generated_sets; + TrainlogExercise picker[MAX_EXERCISES]; + size_t picker_count; + size_t picker_selected; + size_t equipment_count; + size_t equipment_selected; + TrainlogCustomEquipment pending_equipment; + char started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + char generation_reference_time[TRAINLOG_TIMESTAMP_MAX + 1U]; + char persisted_session_id[TRAINLOG_ID_MAX + 1U]; + char message[192]; + TrainlogAppRoute pending_route; + TrainlogGeneratedSession generated; + TrainlogGeneratorPreviewItem generated_items[TRAINLOG_GENERATOR_MAX_SELECTED]; + const TrainlogBodyZone *generation_zone; + const TrainlogSessionGenerationGoalPolicy *generation_goal; + size_t generation_zone_index; + size_t generation_goal_index; + size_t generation_duration_index; + int generation_duration_minutes; + TrainlogDurabilityState durability; +} TrainlogSessionController; + +typedef enum TrainlogEquipmentPhase { + TRAINLOG_EQUIPMENT_IDLE = 0, + TRAINLOG_EQUIPMENT_NAME, + TRAINLOG_EQUIPMENT_LABEL, + TRAINLOG_EQUIPMENT_TYPE, + TRAINLOG_EQUIPMENT_LOAD, + TRAINLOG_EQUIPMENT_CONFIRM_DISCARD, + TRAINLOG_EQUIPMENT_MESSAGE +} TrainlogEquipmentPhase; + +typedef struct TrainlogEquipmentController { + /* INVARIANT: this controller is the sole transient owner of a catalogue + * equipment definition. Invalid submissions retain every raw field. */ + TrainlogEquipmentPhase phase; + TrainlogEquipmentPhase return_phase; + TrainlogFormField form; + TrainlogCustomEquipment pending; + int load_choice; + bool dirty; + bool leaving; + TrainlogAppRoute pending_route; + char message[192]; +} TrainlogEquipmentController; + +typedef enum TrainlogExercisePhase { + TRAINLOG_EXERCISE_IDLE = 0, + TRAINLOG_EXERCISE_NAME, + TRAINLOG_EXERCISE_TRACKING, + TRAINLOG_EXERCISE_RECORDING, + TRAINLOG_EXERCISE_SPEED, + TRAINLOG_EXERCISE_DISTANCE, + TRAINLOG_EXERCISE_ZONES, + TRAINLOG_EXERCISE_MERGE_PICKER, + TRAINLOG_EXERCISE_MERGE_CONFIRM, + TRAINLOG_EXERCISE_MERGE_RESULT, + TRAINLOG_EXERCISE_CONFIRM_DISCARD, + TRAINLOG_EXERCISE_MESSAGE +} TrainlogExercisePhase; + +typedef struct TrainlogExerciseController { + /* CONTRACT: creation/editing owns one transient value set. An edit keeps + * exercise_id and passes the stored profile back unchanged; referenced + * profile restrictions remain enforced by the database transaction. */ + TrainlogExercisePhase phase; + TrainlogExercisePhase return_phase; + TrainlogFormField form; + TrainlogExercise pending; + char primary_zone[TRAINLOG_ZONE_ID_MAX + 1U]; + char secondary_zones[MAX_BODY_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; + size_t secondary_count; + size_t zone_selected; + bool editing; + bool inline_session; + bool dirty; + bool leaving; + TrainlogAppRoute pending_route; + char message[192]; + TrainlogExercise merge_candidates[MAX_EXERCISES]; + size_t merge_count; + size_t merge_selected; + TrainlogExercise merge_source; + TrainlogExercise merge_target; + TrainlogExerciseMergePreview merge_preview; +} TrainlogExerciseController; + +typedef enum TrainlogBodyPhase { + TRAINLOG_BODY_IDLE = 0, + TRAINLOG_BODY_FORM, + TRAINLOG_BODY_CONFIRM_DISCARD, + TRAINLOG_BODY_MESSAGE +} TrainlogBodyPhase; + +typedef struct TrainlogBodyController { + /* CONTRACT: one controller owns all fourteen raw metric fields until one + * insert/update succeeds. Editing copies the immutable record identity, + * timestamp and session link and never permits those fields to drift. */ + TrainlogBodyPhase phase; + TrainlogFormField form; + TrainlogBodyObservationInput pending; + char session_id[TRAINLOG_ID_MAX + 1U]; + char notes[TRAINLOG_NOTE_MAX + 1U]; + size_t field; + bool editing; + bool dirty; + bool leaving; + TrainlogAppRoute pending_route; + char message[192]; +} TrainlogBodyController; + +typedef enum TrainlogProfilePhase { + TRAINLOG_PROFILE_IDLE = 0, + TRAINLOG_PROFILE_FORMULA, + TRAINLOG_PROFILE_HEIGHT, + TRAINLOG_PROFILE_CONFIRM_DISCARD, + TRAINLOG_PROFILE_MESSAGE +} TrainlogProfilePhase; + +typedef struct TrainlogProfileController { + TrainlogProfilePhase phase; + TrainlogProfilePhase return_phase; + TrainlogFormField form; + TrainlogBodyAnalyticsProfile pending; + TrainlogAppRoute caller; + bool dirty; + bool leaving; + TrainlogAppRoute pending_route; + char message[192]; +} TrainlogProfileController; + +typedef TrainlogStatus (*TrainlogAppSyncProbe)(TrainlogSyncDeviceInfo *output); +typedef TrainlogStatus (*TrainlogAppSyncRun)(TrainlogSyncTrigger trigger, + bool require_request, TrainlogSyncDirection direction, + TrainlogSyncReport *output); + +typedef struct TrainlogSyncController { + /* Navigation only reads cached local journal state. Device discovery and + * the synchronous engine are invoked solely by explicit controller keys. */ + TrainlogSyncScreenState action; + TrainlogSyncDeviceInfo device; + TrainlogStatus probe_status; + bool probe_known; + TrainlogSyncReport report; + bool has_report; + bool running; + TrainlogSyncHistoryEntry history[SYNC_HISTORY_CAPACITY]; + size_t history_count; + size_t selected; + char detail_text[SYNC_DETAIL_TEXT_CAPACITY]; + char *detail_lines[SYNC_DETAIL_LINE_CAPACITY]; + size_t detail_line_count; + size_t detail_scroll; + bool showing_detail; + TrainlogAppSyncProbe probe; + TrainlogAppSyncRun run; +} TrainlogSyncController; + +typedef struct TrainlogAppContext { + TrainlogDatabase *database; + TrainlogTerminal *terminal; + TrainlogNavigationState navigation; + TrainlogSessionController session; + TrainlogEquipmentController equipment_controller; + TrainlogExerciseController exercise_controller; + TrainlogBodyController body_controller; + TrainlogProfileController profile_controller; + TrainlogSyncController sync_controller; + TrainlogFocusTarget focus; + TrainlogActionModel actions; + TrainlogOverlayStack overlays; + TrainlogShellLayout layout; + TrainlogSurface *header; + TrainlogSurface *sidebar; + TrainlogSurface *content; + TrainlogSurface *footer; + size_t navigation_selected; + TrainlogFocusTarget navigation_restore_focus; + size_t content_selected; + size_t content_scroll; + TrainlogListState list; + TrainlogSearchState search; + TrainlogListState saved_lists[TRAINLOG_ROUTE_COUNT]; + TrainlogSearchState saved_searches[TRAINLOG_ROUTE_COUNT]; + TrainlogExercise exercises[MAX_EXERCISES]; + TrainlogResolvedEquipment equipment[256]; + TrainlogSessionSummary sessions[MAX_SESSIONS]; + const char *stable_ids[MAX_BODY_OBSERVATIONS]; + size_t loaded_count; + int exercise_zone_filter; + bool list_error; + TrainlogExercise exercise_detail; + TrainlogExerciseBodyZone exercise_zones[MAX_BODY_ZONES]; + size_t exercise_zone_count; + TrainlogResolvedEquipment exercise_explicit_equipment[64]; + size_t exercise_explicit_equipment_count; + TrainlogResolvedEquipment exercise_historic_equipment[128]; + size_t exercise_historic_equipment_count; + size_t exercise_equipment_selected; + bool exercise_detail_metadata_error; + TrainlogResolvedEquipment equipment_detail; + TrainlogExercisePerformancePoint performance_points[MAX_SESSIONS]; + size_t performance_count; + TrainlogMeasuredMaxSummary max_summary; + size_t max_rounding_index; + bool performance_error; + TrainlogSessionSummary session_detail; + TrainlogPersistedExerciseDetail session_entries[MAX_SESSION_EXERCISES]; + size_t session_entry_count; + size_t session_entry_selected; + size_t session_set_scroll; + bool session_detail_error; + TrainlogBodyObservationRecord body_records[MAX_BODY_OBSERVATIONS]; + TrainlogBodyObservationRecord body_detail; + TrainlogBodyMetricPoint body_metric_points[MAX_BODY_METRIC_POINTS]; + size_t body_metric_count; + bool body_metric_partial; + size_t body_metric_selected; + TrainlogGlobalBodySeries body_global_series[14]; + bool body_global_enabled[14]; + bool body_global_initialized; + size_t body_global_selected; + char body_global_dates[MAX_GLOBAL_BODY_DATES][TRAINLOG_TIMESTAMP_MAX + 1U]; + size_t body_global_date_count; + bool body_global_partial; + bool body_snapshot_error; + TrainlogBodyAnalyticsProfile body_profile; + bool body_has_profile; + bool quit_confirmation; + bool running; +} TrainlogAppContext; + +static void app_shell_open_route(TrainlogAppContext *app, + TrainlogAppRoute route); +static void app_shell_load_exercise_detail_metadata(TrainlogAppContext *app); +static void app_shell_refresh_list(TrainlogAppContext *app); + +static void body_metric_slot(TrainlogBodyObservationInput *input, size_t field, + bool **present, double **value) +{ + *present = NULL; *value = NULL; +#define BODY_SLOT(index_, has_, value_) case index_: *present = &input->has_; \ + *value = &input->value_; break + switch (field) { + BODY_SLOT(0U, has_body_weight, body_weight_kg); + BODY_SLOT(1U, has_neck, neck_cm); + BODY_SLOT(2U, has_shoulders, shoulders_cm); + BODY_SLOT(3U, has_chest, chest_cm); + BODY_SLOT(4U, has_waist, waist_cm); + BODY_SLOT(5U, has_hips, hips_cm); + BODY_SLOT(6U, has_left_arm, left_arm_cm); + BODY_SLOT(7U, has_right_arm, right_arm_cm); + BODY_SLOT(8U, has_left_forearm, left_forearm_cm); + BODY_SLOT(9U, has_right_forearm, right_forearm_cm); + BODY_SLOT(10U, has_left_thigh, left_thigh_cm); + BODY_SLOT(11U, has_right_thigh, right_thigh_cm); + BODY_SLOT(12U, has_left_calf, left_calf_cm); + BODY_SLOT(13U, has_right_calf, right_calf_cm); + default: break; + } +#undef BODY_SLOT } -static void screen_sync( - TrainlogDatabase *database +static void body_controller_open_field(TrainlogBodyController *controller) +{ + bool *present; + double *value; + char text[64] = ""; + body_metric_slot(&controller->pending, controller->field, &present, &value); + if (controller->editing && present != NULL && *present) + (void)snprintf(text, sizeof(text), "%.10g", *value); + trainlog_form_init(&controller->form, text); + controller->phase = TRAINLOG_BODY_FORM; +} + +static void body_controller_start_add(TrainlogAppContext *app) +{ + TrainlogBodyController *controller = &app->body_controller; + (void)memset(controller, 0, sizeof(*controller)); + if (trainlog_id_generate("bo", controller->pending.observation_id, + sizeof(controller->pending.observation_id)) != TRAINLOG_STATUS_OK || + trainlog_time_now_rfc3339(controller->pending.observed_at, + sizeof(controller->pending.observed_at)) != TRAINLOG_STATUS_OK) { + controller->phase = TRAINLOG_BODY_MESSAGE; + (void)snprintf(controller->message, sizeof(controller->message), + "Impossible de préparer un identifiant ou une date de relevé."); + return; + } + body_controller_open_field(controller); + app->focus = TRAINLOG_FOCUS_EDITOR; +} + +static bool body_controller_start_edit(TrainlogAppContext *app, + const char *observation_id) +{ + TrainlogBodyController *controller = &app->body_controller; + TrainlogBodyObservationRecord record; + (void)memset(controller, 0, sizeof(*controller)); + if (trainlog_database_get_body_observation(app->database, observation_id, + &record) != TRAINLOG_STATUS_OK) return false; + body_input_from_record(&record, &controller->pending); + (void)snprintf(controller->session_id, sizeof(controller->session_id), + "%s", record.session_id); + (void)snprintf(controller->notes, sizeof(controller->notes), "%s", + record.notes); + controller->pending.session_id = controller->session_id[0] != '\0' + ? controller->session_id : NULL; + controller->pending.notes = controller->notes[0] != '\0' + ? controller->notes : NULL; + controller->editing = true; + body_controller_open_field(controller); + app->focus = TRAINLOG_FOCUS_EDITOR; + return true; +} + +static bool body_controller_persist(TrainlogAppContext *app) +{ + TrainlogBodyController *controller = &app->body_controller; + TrainlogStatus status = controller->editing + ? trainlog_database_update_body_observation(app->database, + &controller->pending) + : trainlog_database_insert_body_observation(app->database, + &controller->pending); + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf(controller->message, sizeof(controller->message), + "Enregistrement impossible : au moins une mesure positive est requise."); + return false; + } + controller->dirty = false; + controller->form.active = false; + controller->phase = TRAINLOG_BODY_MESSAGE; + (void)snprintf(controller->message, sizeof(controller->message), "%s", + controller->editing ? "Relevé corrigé; identité, date et séance conservées." + : "Relevé corporel enregistré."); + if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) + app_shell_refresh_list(app); + if (controller->editing && + app->navigation.current.route == TRAINLOG_ROUTE_BODY_DETAIL) + (void)trainlog_database_get_body_observation(app->database, + controller->pending.observation_id, &app->body_detail); + return true; +} + +static bool app_shell_dispatch_body_controller(TrainlogAppContext *app, int key) +{ + TrainlogBodyController *controller = &app->body_controller; + TrainlogFormResult result; + if (controller->phase == TRAINLOG_BODY_IDLE) return false; + if (controller->phase == TRAINLOG_BODY_MESSAGE) { + if (key == TRAINLOG_KEY_ENTER || key == '\n' || + key == TRAINLOG_KEY_ESCAPE) { + (void)memset(controller, 0, sizeof(*controller)); + app->focus = TRAINLOG_FOCUS_CONTENT; + } + return true; + } + if (controller->phase == TRAINLOG_BODY_CONFIRM_DISCARD) { + if (key == '1') { + bool leaving = controller->leaving; + TrainlogAppRoute route = controller->pending_route; + (void)memset(controller, 0, sizeof(*controller)); + app->focus = TRAINLOG_FOCUS_CONTENT; + if (leaving) app_shell_open_route(app, route); + } else if (key == '0' || key == TRAINLOG_KEY_ESCAPE) { + controller->phase = TRAINLOG_BODY_FORM; + controller->leaving = false; + } + return true; + } + result = trainlog_form_handle(&controller->form, key); + if (result == TRAINLOG_FORM_EDITED) { + controller->dirty = true; + controller->message[0] = '\0'; + } else if (result == TRAINLOG_FORM_SUBMIT) { + bool *present; + double *value; + double parsed = 0.0; + body_metric_slot(&controller->pending, controller->field, + &present, &value); + if (present == NULL || value == NULL) return true; + if (controller->form.text[0] == '\0') { + if (!controller->editing) { *present = false; *value = 0.0; } + } else if (controller->editing && + strcmp(controller->form.text, "-") == 0) { + *present = false; *value = 0.0; + } else if (!parse_double_positive(controller->form.text, &parsed)) { + (void)snprintf(controller->message, sizeof(controller->message), + "Nombre positif invalide; le texte saisi est conservé."); + return true; + } else { *present = true; *value = parsed; } + controller->dirty = true; + if (controller->field + 1U < 14U) { + ++controller->field; + body_controller_open_field(controller); + } else (void)body_controller_persist(app); + } else if (result == TRAINLOG_FORM_CANCEL) { + if (controller->dirty) controller->phase = TRAINLOG_BODY_CONFIRM_DISCARD; + else { (void)memset(controller, 0, sizeof(*controller)); + app->focus = TRAINLOG_FOCUS_CONTENT; } + } else if (result == TRAINLOG_FORM_OPEN_NAVIGATION) + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_NAVIGATION, app->focus, + app->navigation.current.stable_id); + else if (result == TRAINLOG_FORM_OPEN_ACTIONS) + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_ACTIONS, app->focus, + app->navigation.current.stable_id); + return true; +} + +static void profile_controller_start(TrainlogAppContext *app, + TrainlogAppRoute caller) +{ + TrainlogProfileController *controller = &app->profile_controller; + char text[8]; + (void)memset(controller, 0, sizeof(*controller)); + controller->caller = caller; + if (!body_analytics_profile_load(&controller->pending)) { + controller->pending.formula = TRAINLOG_BODY_ANALYTICS_FORMULA_MALE; + controller->pending.height_cm = 170.0; + } + controller->phase = TRAINLOG_PROFILE_FORMULA; + controller->return_phase = TRAINLOG_PROFILE_FORMULA; + (void)snprintf(text, sizeof(text), "%d", + controller->pending.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE + ? 2 : 1); + trainlog_form_init(&controller->form, text); + app->focus = TRAINLOG_FOCUS_EDITOR; +} + +static bool app_shell_dispatch_profile_controller(TrainlogAppContext *app, + int key) +{ + TrainlogProfileController *controller = &app->profile_controller; + TrainlogFormResult result; + if (controller->phase == TRAINLOG_PROFILE_IDLE) return false; + if (controller->phase == TRAINLOG_PROFILE_MESSAGE) { + if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == TRAINLOG_KEY_ESCAPE) { + TrainlogAppRoute caller = controller->caller; + (void)memset(controller, 0, sizeof(*controller)); + app->focus = TRAINLOG_FOCUS_CONTENT; + if (app->navigation.current.route == caller) + app->body_has_profile = body_analytics_profile_load( + &app->body_profile); + } + return true; + } + if (controller->phase == TRAINLOG_PROFILE_CONFIRM_DISCARD) { + if (key == '1') { + bool leaving = controller->leaving; + TrainlogAppRoute route = controller->pending_route; + (void)memset(controller, 0, sizeof(*controller)); + app->focus = TRAINLOG_FOCUS_CONTENT; + if (leaving) app_shell_open_route(app, route); + } else if (key == '0' || key == TRAINLOG_KEY_ESCAPE) + controller->phase = controller->return_phase; + return true; + } + result = trainlog_form_handle(&controller->form, key); + if (result == TRAINLOG_FORM_EDITED) controller->dirty = true; + else if (result == TRAINLOG_FORM_SUBMIT && + controller->phase == TRAINLOG_PROFILE_FORMULA) { + if (strcmp(controller->form.text, "1") != 0 && + strcmp(controller->form.text, "2") != 0) { + (void)snprintf(controller->message, sizeof(controller->message), + "Formule attendue : 1 homme ou 2 femme."); + return true; + } + controller->pending.formula = strcmp(controller->form.text, "2") == 0 + ? TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE + : TRAINLOG_BODY_ANALYTICS_FORMULA_MALE; + controller->phase = TRAINLOG_PROFILE_HEIGHT; + controller->return_phase = TRAINLOG_PROFILE_HEIGHT; + { char text[64]; (void)snprintf(text, sizeof(text), "%.1f", + controller->pending.height_cm); trainlog_form_init(&controller->form, text); } + } else if (result == TRAINLOG_FORM_SUBMIT) { + double height; + if (!parse_double_positive(controller->form.text, &height) || + height < 100.0 || height > 250.0) { + (void)snprintf(controller->message, sizeof(controller->message), + "Taille attendue entre 100 et 250 cm; texte conservé."); + return true; + } + controller->pending.height_cm = height; + if (!body_analytics_profile_save(&controller->pending)) { + (void)snprintf(controller->message, sizeof(controller->message), + "Impossible d’enregistrer le profil local; champs conservés."); + return true; + } + controller->dirty = false; + controller->phase = TRAINLOG_PROFILE_MESSAGE; + (void)snprintf(controller->message, sizeof(controller->message), + "Profil d’estimation corporelle enregistré localement."); + } else if (result == TRAINLOG_FORM_CANCEL) { + if (controller->dirty) { + controller->return_phase = controller->phase; + controller->phase = TRAINLOG_PROFILE_CONFIRM_DISCARD; + } + else { (void)memset(controller, 0, sizeof(*controller)); + app->focus = TRAINLOG_FOCUS_CONTENT; } + } else if (result == TRAINLOG_FORM_OPEN_NAVIGATION) + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_NAVIGATION, app->focus, + app->navigation.current.stable_id); + else if (result == TRAINLOG_FORM_OPEN_ACTIONS) + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_ACTIONS, app->focus, + app->navigation.current.stable_id); + return true; +} + +static void session_controller_sync_durability(TrainlogSessionController *session) +{ + session->durability.session_draft = session->has_draft; + session->durability.session_dirty = session->dirty; + session->durability.generator_configuration_dirty = + session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG && + (session->generation_zone != NULL || session->generation_goal != NULL); + session->durability.generator_preview = session->generated_preview; + session->durability.generator_preview_dirty = session->generated_preview; + session->durability.transient_form_dirty = session->form.active && + session->form.bytes > 0U; +} + +static void session_controller_clear_draft(TrainlogSessionController *session) +{ + /* CONTRACT: this is called only by explicit abandon or a successful save. + * Route changes retain the complete run-memory draft. */ + (void)memset(session->drafts, 0, sizeof(session->drafts)); + session->has_draft = false; + session->dirty = false; + session->correcting = false; + session->draft_count = 0U; + session->selected = 0U; + session->persisted_session_id[0] = '\0'; + session->started_at[0] = '\0'; + session_controller_sync_durability(session); +} + +static void session_controller_discard_generator(TrainlogSessionController *session) +{ + /* INVARIANT: no Back/navigation path calls this helper. Preview edits, + * raw configuration and warning acknowledgement survive keep-and-leave. */ + (void)memset(&session->generated, 0, sizeof(session->generated)); + (void)memset(session->generated_items, 0, sizeof(session->generated_items)); + session->generated_preview = false; + session->generation_warning_acknowledged = false; + session->generation_zone = NULL; + session->generation_goal = NULL; + session->generation_duration_minutes = 0; + session->generation_reference_time[0] = '\0'; + session_controller_sync_durability(session); +} + +static bool session_controller_load_picker(TrainlogAppContext *app) +{ + TrainlogSessionController *session = &app->session; + session->picker_count = 0U; + session->picker_selected = 0U; + if (trainlog_database_list_exercises(app->database, session->picker, + MAX_EXERCISES, &session->picker_count) != TRAINLOG_STATUS_OK) { + (void)snprintf(session->message, sizeof(session->message), + "Impossible de lire le catalogue d’exercices."); + session->phase = TRAINLOG_SESSION_MESSAGE; + return false; + } + if (session->picker_count == 0U) { + (void)snprintf(session->message, sizeof(session->message), + "Ajoutez d’abord au moins un exercice au catalogue."); + session->phase = TRAINLOG_SESSION_MESSAGE; + return false; + } + session->phase = TRAINLOG_SESSION_EXERCISE_PICKER; + return true; +} + +static void session_controller_load_equipment(TrainlogAppContext *app) +{ + TrainlogSessionController *session = &app->session; + size_t index; + session->equipment_count = equipment_collect(app->database, "", + app->equipment, sizeof(app->equipment) / sizeof(app->equipment[0]), + NULL, NULL); + session->equipment_selected = 0U; /* zero is the explicit none choice */ + if (session->selected < session->draft_count) { + const char *current = session->drafts[session->selected].input.equipment_id; + for (index = 0U; current[0] != '\0' && index < session->equipment_count; ++index) + if (strcmp(current, app->equipment[index].equipment_id) == 0) { + session->equipment_selected = index + 1U; + break; + } + } + session->phase = TRAINLOG_SESSION_EQUIPMENT_PICKER; +} + +static bool session_controller_choose_exercise(TrainlogSessionController *session) +{ + TrainlogSessionDraftExercise replacement; + TrainlogSessionDraftExercise *target; + const TrainlogExercise *exercise; + char preserved_id[TRAINLOG_ID_MAX + 1U] = ""; + char preserved_notes[TRAINLOG_NOTE_MAX + 1U] = ""; + if (session->picker_count == 0U || + session->picker_selected >= session->picker_count) return false; + if (!session->replacing_occurrence && + session->draft_count >= MAX_SESSION_EXERCISES) return false; + exercise = &session->picker[session->picker_selected]; + (void)memset(&replacement, 0, sizeof(replacement)); + if (session->replacing_occurrence && session->selected < session->draft_count) { + target = &session->drafts[session->selected]; + (void)snprintf(preserved_id, sizeof(preserved_id), "%s", + target->input.entry_id); + (void)snprintf(preserved_notes, sizeof(preserved_notes), "%s", target->notes); + } + (void)snprintf(replacement.input.entry_id, + sizeof(replacement.input.entry_id), "%s", preserved_id); + (void)snprintf(replacement.input.exercise_id, + sizeof(replacement.input.exercise_id), "%s", exercise->exercise_id); + (void)snprintf(replacement.name, sizeof(replacement.name), "%s", exercise->name); + (void)snprintf(replacement.notes, sizeof(replacement.notes), "%s", preserved_notes); + replacement.tracking_mode = exercise->tracking_mode; + replacement.input.recording_mode = exercise->recording_mode; + replacement.input.data_fields = exercise->data_fields; + replacement.input.load_mode = TRAINLOG_LOAD_NONE; + draft_bind_input(&replacement); + if (session->replacing_occurrence) session->drafts[session->selected] = replacement; + else { + session->selected = session->draft_count; + session->drafts[session->draft_count++] = replacement; + } + draft_bind_input(&session->drafts[session->selected]); + session->has_draft = true; + session->dirty = true; + session->replacing_occurrence = false; + session->phase = TRAINLOG_SESSION_DRAFT; + session_controller_sync_durability(session); + return true; +} + +static bool session_controller_actuals_valid(TrainlogSessionController *session) +{ + size_t index; + if (session->draft_count == 0U) { + (void)snprintf(session->message, sizeof(session->message), + "Ajoutez au moins un exercice avant d’enregistrer."); + return false; + } + for (index = 0U; index < session->draft_count; ++index) { + TrainlogSessionExerciseInput *input = &session->drafts[index].input; + bool valid = input->has_max_weight || + (input->recording_mode == TRAINLOG_RECORDING_CONTINUOUS + ? input->continuous_duration_seconds > 0 : input->set_count > 0U); + if (!valid) { + session->selected = index; + (void)snprintf(session->message, sizeof(session->message), + "Saisissez un résultat réel pour %.120s.", session->drafts[index].name); + return false; + } + } + return true; +} + +static TrainlogStatus session_controller_save(TrainlogAppContext *app) +{ + TrainlogSessionController *session = &app->session; + TrainlogStatus status; + if (!session_controller_actuals_valid(session)) return TRAINLOG_STATUS_INVALID_ARGUMENT; + if (session->correcting) status = persist_draft_replacement(app->database, + session->persisted_session_id, session->drafts, session->draft_count) + ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; + else status = persist_new_session_drafts(app->database, session->drafts, + session->draft_count, session->started_at, session->session_type); + if (status == TRAINLOG_STATUS_OK) session_controller_clear_draft(session); + return status; +} + +static bool session_controller_generate(TrainlogAppContext *app) +{ + TrainlogSessionController *session = &app->session; + TrainlogGenerationDatabaseRequest request; + char reference_time[TRAINLOG_TIMESTAMP_MAX + 1U]; + TrainlogStatus status; + if (session->generation_zone == NULL || session->generation_goal == NULL || + session->generation_duration_minutes <= 0) return false; + if (session->generation_reference_time[0] == '\0' && + trainlog_time_now_rfc3339(session->generation_reference_time, + sizeof(session->generation_reference_time)) != TRAINLOG_STATUS_OK) return false; + (void)snprintf(reference_time, sizeof(reference_time), "%s", + session->generation_reference_time); + (void)memset(&request, 0, sizeof(request)); + request.zone_id = session->generation_zone->zone_id; + request.goal_id = session->generation_goal->id; + request.duration_minutes = session->generation_duration_minutes; + request.reference_time = reference_time; + status = trainlog_session_generate_from_database(app->database, &request, + &session->generated); + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf(session->message, sizeof(session->message), + "Génération refusée (%d).", (int)status); + session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG; + session->phase = TRAINLOG_SESSION_MESSAGE; + return false; + } + if (session->generated.exercise_count == 0U) { + (void)snprintf(session->message, sizeof(session->message), + "Pas assez d’exercices résolus et compatibles; rien n’a été inventé."); + session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG; + session->phase = TRAINLOG_SESSION_MESSAGE; + return false; + } + if (!generator_prepare_preview(app->database, &session->generated, + session->generated_items)) return false; + session->generated_preview = true; + session->selected = 0U; + session->phase = session->generated.exposure.warning_level == + TRAINLOG_GENERATION_WARNING_NONE || session->generation_warning_acknowledged + ? TRAINLOG_SESSION_GENERATOR_PREVIEW : TRAINLOG_SESSION_GENERATOR_WARNING; + session_controller_sync_durability(session); + return true; +} + +static TrainlogStatus session_requalify_history(void *context, + const TrainlogGenerationHistoryRow *row) +{ + return trainlog_session_generation_analyzer_accept( + (TrainlogSessionGenerationAnalyzer *)context, row); +} + +static bool session_controller_requalify_generated(TrainlogAppContext *app, + size_t selected, + int sets, int repetitions) +{ + TrainlogSessionController *session = &app->session; + TrainlogGeneratedExercise *current; + TrainlogGenerationCandidate candidate; + TrainlogGenerationRequest request; + TrainlogSessionGenerationAnalyzer *analyzer = NULL; + TrainlogGeneratedSession result; + const char *secondary[TRAINLOG_GENERATOR_MAX_ZONES]; + const char *patterns[TRAINLOG_GENERATOR_MAX_PATTERNS]; + const char *sources[TRAINLOG_GENERATOR_MAX_SOURCE_REFS]; + size_t index; + TrainlogStatus status; + if (selected >= session->generated.exercise_count || + session->generation_zone == NULL || session->generation_goal == NULL || + session->generation_reference_time[0] == '\0') return false; + current = &session->generated.exercises[selected]; + for (index = 0U; index < current->secondary_zone_count; ++index) + secondary[index] = current->secondary_zone_ids[index]; + for (index = 0U; index < current->pattern_count; ++index) + patterns[index] = current->pattern_ids[index]; + for (index = 0U; index < current->source_ref_count; ++index) + sources[index] = current->source_ref_ids[index]; + (void)memset(&candidate, 0, sizeof(candidate)); + candidate.exercise_id = current->exercise_id; + candidate.equipment_id = current->equipment_id; + candidate.primary_zone_id = current->primary_zone_id; + candidate.secondary_zone_ids = secondary; + candidate.secondary_zone_count = current->secondary_zone_count; + candidate.pattern_ids = patterns; + candidate.pattern_count = current->pattern_count; + candidate.source_ref_ids = sources; + candidate.source_ref_count = current->source_ref_count; + candidate.confidence = current->confidence; + candidate.equipment_load_semantics = current->equipment_load_semantics; + (void)memset(&request, 0, sizeof(request)); + request.zone_id = session->generation_zone->zone_id; + request.goal_id = session->generation_goal->id; + request.duration_minutes = session->generation_duration_minutes; + request.reference_time = session->generation_reference_time; + request.candidates = &candidate; + request.candidate_count = 1U; + status = trainlog_session_generation_requalify_dose(&request, 0U, sets, + repetitions, &analyzer); + if (status == TRAINLOG_STATUS_OK) + status = trainlog_database_scan_generation_history(app->database, + session_requalify_history, analyzer); + if (status == TRAINLOG_STATUS_OK) + status = trainlog_session_generation_analyzer_finish(analyzer, &result); + trainlog_session_generation_analyzer_destroy(analyzer); + if (status != TRAINLOG_STATUS_OK || result.exercise_count != 1U) return false; + session->generated.estimated_duration_seconds += + result.exercises[0].estimated_seconds - current->estimated_seconds; + *current = result.exercises[0]; + return true; +} + +static bool session_controller_accept_generator(TrainlogAppContext *app) +{ + TrainlogSessionController *session = &app->session; + if (session->has_draft) { + (void)snprintf(session->message, sizeof(session->message), + "Une séance est déjà en cours. Reprenez-la ou gardez cet aperçu."); + session->return_phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; + session->phase = TRAINLOG_SESSION_MESSAGE; + return false; + } + if (!generator_build_drafts(app->database, &session->generated, session->drafts)) + return false; + session->draft_count = session->generated.exercise_count; + session->has_draft = true; + session->dirty = true; + session->session_type = TRAINLOG_SESSION_TRAINING; + session->correcting = false; + if (trainlog_time_now_rfc3339(session->started_at, + sizeof(session->started_at)) != TRAINLOG_STATUS_OK) return false; + /* CONTRACT: acceptance transfers planning metadata into the ordinary + * draft and creates zero performed sets. */ + session_controller_discard_generator(session); + session->phase = TRAINLOG_SESSION_DRAFT; + session->selected = 0U; + session_controller_sync_durability(session); + return true; +} + +static const TrainlogAppRoute shell_sections[] = { + TRAINLOG_ROUTE_HOME, TRAINLOG_ROUTE_SESSIONS, TRAINLOG_ROUTE_EXERCISES, + TRAINLOG_ROUTE_EQUIPMENT, TRAINLOG_ROUTE_STATS, TRAINLOG_ROUTE_SYNC, + TRAINLOG_ROUTE_SETTINGS +}; +static const char *const shell_section_labels[] = { + "Accueil", "Séances", "Exercices", "Équipements", "Statistiques", + "Synchronisation", "Paramètres" +}; + +static void app_shell_destroy_surfaces(TrainlogAppContext *app) +{ + /* INVARIANT: each plane has exactly one owner and destruction proceeds + * from transient/inner content toward persistent outer chrome. */ + trainlog_surface_destroy(app->content); app->content = NULL; + trainlog_surface_destroy(app->sidebar); app->sidebar = NULL; + trainlog_surface_destroy(app->footer); app->footer = NULL; + trainlog_surface_destroy(app->header); app->header = NULL; +} + +static bool app_shell_layout(TrainlogAppContext *app) +{ + trainlog_shell_layout_compute(trainlog_terminal_columns(app->terminal), + trainlog_terminal_rows(app->terminal), &app->layout); + if (!app->layout.usable) { app_shell_destroy_surfaces(app); return true; } + if (app->header == NULL) + app->header = trainlog_surface_create(app->terminal, "trainlog.header", + app->layout.header.y, app->layout.header.x, + app->layout.header.height, app->layout.header.width); + else if (!trainlog_surface_set_rect(app->header, app->layout.header.y, + app->layout.header.x, app->layout.header.height, app->layout.header.width)) + return false; + if (app->footer == NULL) + app->footer = trainlog_surface_create(app->terminal, "trainlog.footer", + app->layout.footer.y, app->layout.footer.x, + app->layout.footer.height, app->layout.footer.width); + else if (!trainlog_surface_set_rect(app->footer, app->layout.footer.y, + app->layout.footer.x, app->layout.footer.height, app->layout.footer.width)) + return false; + if (app->content == NULL) + app->content = trainlog_surface_create(app->terminal, "trainlog.content", + app->layout.content.y, app->layout.content.x, + app->layout.content.height, app->layout.content.width); + else if (!trainlog_surface_set_rect(app->content, app->layout.content.y, + app->layout.content.x, app->layout.content.height, app->layout.content.width)) + return false; + if (app->layout.sidebar_visible && app->sidebar == NULL) + app->sidebar = trainlog_surface_create(app->terminal, "trainlog.sidebar", + app->layout.sidebar.y, app->layout.sidebar.x, + app->layout.sidebar.height, app->layout.sidebar.width); + else if (app->layout.sidebar_visible && !trainlog_surface_set_rect(app->sidebar, + app->layout.sidebar.y, app->layout.sidebar.x, + app->layout.sidebar.height, app->layout.sidebar.width)) return false; + else if (!app->layout.sidebar_visible && app->sidebar != NULL) { + trainlog_surface_destroy(app->sidebar); app->sidebar = NULL; + /* CONTRACT: compact Navigation replaces the disappearing sidebar; + * retain both its selected section and its owned focus. */ + } + return app->header != NULL && app->footer != NULL && app->content != NULL && + (!app->layout.sidebar_visible || app->sidebar != NULL); +} + +static void app_shell_add_action(TrainlogAppContext *app, const char *identifier, + int key, const char *label, unsigned priority, + TrainlogIntent intent, TrainlogAppRoute route) +{ + TrainlogAction action = {identifier, key, label, true, priority, intent, route}; + (void)trainlog_actions_add(&app->actions, &action); +} + +static bool app_shell_is_list_route(TrainlogAppRoute route) +{ + return route == TRAINLOG_ROUTE_EXERCISES || route == TRAINLOG_ROUTE_EQUIPMENT || + route == TRAINLOG_ROUTE_SESSIONS_COMPLETED || + route == TRAINLOG_ROUTE_STATS_EXERCISE || route == TRAINLOG_ROUTE_MAX || + route == TRAINLOG_ROUTE_BODY; +} + +static void app_shell_refresh_list(TrainlogAppContext *app) +{ + TrainlogAppRoute route = app->navigation.current.route; + size_t index; + size_t count = 0U; + size_t capacity = 0U; + bool source_capped = false; + app->list_error = false; + if (route == TRAINLOG_ROUTE_EXERCISES || route == TRAINLOG_ROUTE_STATS_EXERCISE || + route == TRAINLOG_ROUTE_MAX) { + char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U] = ""; + const char *zone_id = NULL; + size_t zone_count = trainlog_body_zone_catalog_count(); + bool unclassified = app->exercise_zone_filter == (int)zone_count; + if (app->search.bytes > 0U && trainlog_catalog_normalize_name(app->search.text, + normalized, sizeof(normalized)) != TRAINLOG_STATUS_OK) normalized[0] = '\0'; + if (app->exercise_zone_filter >= 0 && + app->exercise_zone_filter < (int)zone_count) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at( + (size_t)app->exercise_zone_filter); + if (zone != NULL) zone_id = zone->zone_id; + } + if (trainlog_database_list_exercises_filtered(app->database, normalized, + zone_id, true, false, unclassified, app->exercises, + MAX_EXERCISES, &count) != TRAINLOG_STATUS_OK) app->list_error = true; + capacity = MAX_EXERCISES; + source_capped = count == capacity; + for (index = 0U; index < count; ++index) + app->stable_ids[index] = app->exercises[index].exercise_id; + } else if (route == TRAINLOG_ROUTE_EQUIPMENT) { + bool truncated = false; + bool failed = false; + count = equipment_collect(app->database, app->search.text, + app->equipment, sizeof(app->equipment) / sizeof(app->equipment[0]), + &truncated, &failed); + capacity = sizeof(app->equipment) / sizeof(app->equipment[0]); + source_capped = truncated; + app->list_error = failed; + for (index = 0U; index < count; ++index) + app->stable_ids[index] = app->equipment[index].equipment_id; + } else if (route == TRAINLOG_ROUTE_SESSIONS_COMPLETED) { + TrainlogSessionSummary all[MAX_SESSIONS]; + size_t all_count = 0U; + if (trainlog_database_list_sessions(app->database, all, MAX_SESSIONS, + &all_count) != TRAINLOG_STATUS_OK) app->list_error = true; + capacity = MAX_SESSIONS; + source_capped = all_count == capacity; + for (index = 0U; index < all_count && count < MAX_SESSIONS; ++index) { + char date[17]; + const char *type = session_type_history_label(all[index].session_type); + session_history_datetime(all[index].started_at, date); + if (app->search.bytes == 0U || equipment_text_matches(date, app->search.text) || + equipment_text_matches(type, app->search.text)) { + app->sessions[count] = all[index]; + app->stable_ids[count] = app->sessions[count].session_id; + ++count; + } + } + } else if (route == TRAINLOG_ROUTE_BODY) { + if (trainlog_database_list_body_observations(app->database, + app->body_records, MAX_BODY_OBSERVATIONS, &count) != TRAINLOG_STATUS_OK) + app->list_error = true; + capacity = MAX_BODY_OBSERVATIONS; + source_capped = count == capacity; + for (index = 0U; index < count; ++index) + app->stable_ids[index] = app->body_records[index].observation_id; + } + app->loaded_count = count; + trainlog_list_set_items(&app->list, app->stable_ids, count, + app->layout.content.height > 8 ? (size_t)(app->layout.content.height - 8) : 1U, + !source_capped, source_capped); +} + +static void app_shell_load_body_metric(TrainlogAppContext *app) +{ + app->body_metric_count = 0U; + app->body_snapshot_error = + trainlog_database_list_body_metric_points(app->database, + BODY_METRICS[app->body_metric_selected].metric, + app->body_metric_points, MAX_BODY_METRIC_POINTS, + &app->body_metric_count) != TRAINLOG_STATUS_OK; + app->body_metric_partial = app->body_metric_count == MAX_BODY_METRIC_POINTS; +} + +static void app_shell_load_body_global(TrainlogAppContext *app) +{ + size_t metric; + app->body_global_date_count = 0U; + app->body_snapshot_error = false; + app->body_global_partial = false; + if (!app->body_global_initialized) { + for (metric = 0U; metric < 14U; ++metric) + app->body_global_enabled[metric] = true; + app->body_global_initialized = true; + } + (void)memset(app->body_global_series, 0, + sizeof(app->body_global_series)); + (void)memset(app->body_global_dates, 0, sizeof(app->body_global_dates)); + for (metric = 0U; metric < 14U; ++metric) { + TrainlogGlobalBodySeries *series = &app->body_global_series[metric]; + size_t point; + series->symbol = GLOBAL_BODY_SYMBOLS[metric]; + series->role = GLOBAL_BODY_ROLES[metric]; + if (trainlog_database_list_body_metric_points(app->database, + BODY_METRICS[metric].metric, series->points, + MAX_BODY_METRIC_POINTS, &series->count) != TRAINLOG_STATUS_OK) { + app->body_snapshot_error = true; + series->count = 0U; + continue; + } + if (series->count == MAX_BODY_METRIC_POINTS) + app->body_global_partial = true; + if (series->count == 0U) continue; + series->baseline = series->points[0].value; + (void)trainlog_body_percent_change(series->baseline, + series->points[series->count - 1U].value, + &series->latest_percent); + for (point = 0U; point < series->count; ++point) + if (!global_date_add(app->body_global_dates, + &app->body_global_date_count, + series->points[point].observed_at)) + app->body_snapshot_error = true; + } + if (app->body_global_date_count > 0U) + qsort(app->body_global_dates, app->body_global_date_count, + sizeof(app->body_global_dates[0]), global_date_compare); +} + +static void app_shell_load_body_analytics(TrainlogAppContext *app) +{ + size_t count = 0U; + app->body_snapshot_error = + trainlog_database_list_body_observations(app->database, + app->body_records, MAX_BODY_OBSERVATIONS, + &count) != TRAINLOG_STATUS_OK; + app->loaded_count = count; + app->body_has_profile = body_analytics_profile_load(&app->body_profile); +} + +static void sync_controller_load_history(TrainlogSyncController *sync) +{ + char selected_id[TRAINLOG_ID_MAX + 1U] = ""; + size_t index; + if (sync->selected < sync->history_count) + (void)snprintf(selected_id, sizeof(selected_id), "%s", + sync->history[sync->selected].sync_id); + sync_history_load(sync->history, SYNC_HISTORY_CAPACITY, + &sync->history_count); + if (sync->history_count == 0U) sync->selected = 0U; + else if (selected_id[0] != '\0') { + for (index = 0U; index < sync->history_count; ++index) + if (strcmp(sync->history[index].sync_id, selected_id) == 0) { + sync->selected = index; + break; + } + if (index == sync->history_count && sync->selected >= sync->history_count) + sync->selected = sync->history_count - 1U; + } else if (sync->selected >= sync->history_count) + sync->selected = sync->history_count - 1U; +} + +static void sync_controller_open_detail(TrainlogSyncController *sync) +{ + const TrainlogSyncHistoryEntry *entry; + char path[PATH_MAX + 1U]; + FILE *file; + size_t used; + char *cursor; + sync->detail_line_count = 0U; + sync->detail_scroll = 0U; + sync->showing_detail = false; + if (sync->history_count == 0U || sync->selected >= sync->history_count) + return; + entry = &sync->history[sync->selected]; + if (entry->sync_id[0] == '\0') return; + /* CONTRACT: known legacy one-way records remain readable internal history, + * but their stored diagnostic prose predates the unified PC↔Android TUI and + * may expose prohibited directional modes. Present only parsed neutral + * metadata; never rewrite or reinterpret the retained artifact. */ + if (entry->direction_known && + entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL) { + (void)snprintf(sync->detail_text, sizeof(sync->detail_text), + "Synchronisation historique PC↔Android\n" + "Horodatage : %s\n" + "État : %s\n" + "Détail hérité masqué (entrée historique)", + entry->timestamp, entry->success ? "réussie" : "échouée"); + sync->detail_lines[sync->detail_line_count++] = sync->detail_text; + cursor = sync->detail_text; + while (*cursor != '\0' && + sync->detail_line_count < SYNC_DETAIL_LINE_CAPACITY) { + if (*cursor == '\n') { + *cursor = '\0'; + if (cursor[1] != '\0') + sync->detail_lines[sync->detail_line_count++] = cursor + 1; + } + ++cursor; + } + sync->showing_detail = true; + return; + } + if (!sync_runs_path(entry->sync_id, + path, sizeof(path))) return; + file = fopen(path, "rb"); + if (file == NULL) return; + used = fread(sync->detail_text, 1U, + sizeof(sync->detail_text) - 1U, file); + sync->detail_text[used] = '\0'; + (void)fclose(file); + if (used == 0U) return; + cursor = sync->detail_text; + sync->detail_lines[sync->detail_line_count++] = cursor; + while (*cursor != '\0' && + sync->detail_line_count < SYNC_DETAIL_LINE_CAPACITY) { + if (*cursor == '\n') { + *cursor = '\0'; + if (cursor[1] != '\0') + sync->detail_lines[sync->detail_line_count++] = cursor + 1; + } + ++cursor; + } + sync->showing_detail = true; +} + +static const char *sync_controller_status_error(TrainlogStatus status) +{ + switch (status) { + case TRAINLOG_STATUS_CONFLICT: + return "Synchronisation refusée : une autre exécution est active."; + case TRAINLOG_STATUS_NOT_FOUND: + return "Synchronisation impossible : aucun appareil MTP Trainlog détecté."; + case TRAINLOG_STATUS_INVALID_ARGUMENT: + return "Synchronisation refusée : données ou demande invalides."; + case TRAINLOG_STATUS_DATABASE_ERROR: + return "Synchronisation interrompue : erreur de base de données locale."; + case TRAINLOG_STATUS_SCHEMA_UNSUPPORTED: + return "Synchronisation refusée : version de données non prise en charge."; + case TRAINLOG_STATUS_SYSTEM_ERROR: + return "Synchronisation interrompue : erreur d’accès appareil ou fichier."; + case TRAINLOG_STATUS_OK: + default: + return "Synchronisation interrompue sans rapport de réussite."; + } +} + +static void app_shell_open_exercise_analytics(TrainlogAppContext *app, + TrainlogAppRoute route) +{ + app->performance_count = 0U; + app->performance_error = trainlog_database_list_exercise_performance( + app->database, app->exercise_detail.exercise_id, + app->performance_points, MAX_SESSIONS, &app->performance_count) != + TRAINLOG_STATUS_OK; + if (!app->performance_error && route == TRAINLOG_ROUTE_EXERCISE_MAX) + app->performance_error = trainlog_measured_max_summarize( + app->performance_points, app->performance_count, + &app->max_summary) != TRAINLOG_STATUS_OK; + (void)trainlog_navigation_open(&app->navigation, route, + app->exercise_detail.exercise_id); + app->content_scroll = 0U; +} + +static void equipment_controller_start(TrainlogAppContext *app) +{ + TrainlogEquipmentController *controller = &app->equipment_controller; + (void)memset(controller, 0, sizeof(*controller)); + controller->phase = TRAINLOG_EQUIPMENT_NAME; + controller->return_phase = TRAINLOG_EQUIPMENT_NAME; + controller->load_choice = 2; + trainlog_form_init(&controller->form, ""); + app->focus = TRAINLOG_FOCUS_EDITOR; +} + +static void equipment_controller_open_field(TrainlogEquipmentController *controller, + TrainlogEquipmentPhase phase, + const char *value) +{ + controller->phase = phase; + controller->return_phase = phase; + controller->message[0] = '\0'; + trainlog_form_init(&controller->form, value); +} + +static bool equipment_controller_save(TrainlogAppContext *app) +{ + TrainlogEquipmentController *controller = &app->equipment_controller; + TrainlogStatus status; + generate_custom_equipment_uuid(controller->pending.equipment_id); + (void)snprintf(controller->pending.load_semantics, + sizeof(controller->pending.load_semantics), "%s", + controller->load_choice == 1 ? "none" : + controller->load_choice == 2 ? "external" : "assistance"); + status = trainlog_database_create_custom_equipment(app->database, + &controller->pending); + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf(controller->message, sizeof(controller->message), "%s", + status == TRAINLOG_STATUS_CONFLICT + ? "Conflit de définition; les champs sont conservés." + : "Équipement non créé : définition invalide ou conflit."); + controller->phase = TRAINLOG_EQUIPMENT_LOAD; + return false; + } + controller->dirty = false; + controller->form.active = false; + controller->phase = TRAINLOG_EQUIPMENT_MESSAGE; + (void)snprintf(controller->message, sizeof(controller->message), + "Équipement personnel créé : %.120s", controller->pending.display_name); + app_shell_refresh_list(app); + (void)snprintf(app->list.selected_id, sizeof(app->list.selected_id), "%s", + controller->pending.equipment_id); + trainlog_list_set_items(&app->list, app->stable_ids, app->loaded_count, + app->list.visible_rows, app->list.total_known, app->list.more_available); + return true; +} + +static bool app_shell_dispatch_equipment_controller(TrainlogAppContext *app, + int key) +{ + TrainlogEquipmentController *controller = &app->equipment_controller; + TrainlogFormResult result; + if (controller->phase == TRAINLOG_EQUIPMENT_IDLE) return false; + if (controller->phase == TRAINLOG_EQUIPMENT_MESSAGE) { + if (key == TRAINLOG_KEY_ENTER || key == '\n' || + key == TRAINLOG_KEY_ESCAPE) { + controller->phase = TRAINLOG_EQUIPMENT_IDLE; + controller->message[0] = '\0'; + app->focus = TRAINLOG_FOCUS_CONTENT; + } + return true; + } + if (controller->phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) { + if (key == '1') { + bool leaving = controller->leaving; + TrainlogAppRoute pending_route = controller->pending_route; + (void)memset(controller, 0, sizeof(*controller)); + app->focus = TRAINLOG_FOCUS_CONTENT; + if (leaving) app_shell_open_route(app, pending_route); + } else if (key == '0' || key == TRAINLOG_KEY_ESCAPE) + controller->phase = controller->return_phase; + return true; + } + if (controller->phase == TRAINLOG_EQUIPMENT_LOAD) { + if (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_LEFT) + controller->load_choice = controller->load_choice > 1 + ? controller->load_choice - 1 : 3; + else if (key == TRAINLOG_KEY_DOWN || key == TRAINLOG_KEY_RIGHT) + controller->load_choice = controller->load_choice < 3 + ? controller->load_choice + 1 : 1; + else if (key >= '1' && key <= '3') + controller->load_choice = key - '0'; + else if (key == TRAINLOG_KEY_ENTER || key == '\n') + (void)equipment_controller_save(app); + else if (key == TRAINLOG_KEY_ESCAPE) { + controller->leaving = false; + controller->return_phase = TRAINLOG_EQUIPMENT_LOAD; + controller->phase = TRAINLOG_EQUIPMENT_CONFIRM_DISCARD; + } else if (key == TRAINLOG_KEY_F6) + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_NAVIGATION, app->focus, + app->navigation.current.stable_id); + else if (key == TRAINLOG_KEY_F7) + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_ACTIONS, app->focus, + app->navigation.current.stable_id); + return true; + } + result = trainlog_form_handle(&controller->form, key); + if (result == TRAINLOG_FORM_OPEN_NAVIGATION) { + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_NAVIGATION, app->focus, + app->navigation.current.stable_id); + return true; + } + if (result == TRAINLOG_FORM_OPEN_ACTIONS) { + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_ACTIONS, app->focus, + app->navigation.current.stable_id); + return true; + } + if (result == TRAINLOG_FORM_CANCEL) { + controller->leaving = false; + controller->return_phase = controller->phase; + controller->phase = TRAINLOG_EQUIPMENT_CONFIRM_DISCARD; + return true; + } + if (result == TRAINLOG_FORM_EDITED) { + controller->dirty = true; + controller->message[0] = '\0'; + return true; + } + if (result != TRAINLOG_FORM_SUBMIT && result != TRAINLOG_FORM_NEXT) + return true; + if ((controller->phase == TRAINLOG_EQUIPMENT_NAME || + controller->phase == TRAINLOG_EQUIPMENT_TYPE) && + controller->form.text[0] == '\0') { + (void)snprintf(controller->message, sizeof(controller->message), + "Ce champ est obligatoire; la saisie est conservée."); + return true; + } + if (controller->phase == TRAINLOG_EQUIPMENT_NAME) { + (void)snprintf(controller->pending.display_name, + sizeof(controller->pending.display_name), "%s", controller->form.text); + equipment_controller_open_field(controller, TRAINLOG_EQUIPMENT_LABEL, + controller->pending.label_name); + } else if (controller->phase == TRAINLOG_EQUIPMENT_LABEL) { + (void)snprintf(controller->pending.label_name, + sizeof(controller->pending.label_name), "%s", controller->form.text); + equipment_controller_open_field(controller, TRAINLOG_EQUIPMENT_TYPE, + controller->pending.equipment_type); + } else { + (void)snprintf(controller->pending.equipment_type, + sizeof(controller->pending.equipment_type), "%s", controller->form.text); + controller->form.active = false; + controller->phase = TRAINLOG_EQUIPMENT_LOAD; + } + return true; +} + +static void exercise_controller_start_create(TrainlogAppContext *app, + bool inline_session) +{ + TrainlogExerciseController *controller = &app->exercise_controller; + (void)memset(controller, 0, sizeof(*controller)); + controller->phase = TRAINLOG_EXERCISE_NAME; + controller->return_phase = TRAINLOG_EXERCISE_NAME; + controller->pending.tracking_mode = TRAINLOG_TRACKING_REPS; + controller->pending.recording_mode = TRAINLOG_RECORDING_SETS; + controller->inline_session = inline_session; + trainlog_form_init(&controller->form, ""); + app->focus = TRAINLOG_FOCUS_EDITOR; +} + +static void exercise_controller_start_edit(TrainlogAppContext *app) +{ + TrainlogExerciseController *controller = &app->exercise_controller; + (void)memset(controller, 0, sizeof(*controller)); + controller->pending = app->exercise_detail; + controller->editing = true; + controller->phase = TRAINLOG_EXERCISE_NAME; + controller->return_phase = TRAINLOG_EXERCISE_NAME; + if (!load_exercise_body_zones(app->database, + controller->pending.exercise_id, controller->primary_zone, + controller->secondary_zones, &controller->secondary_count)) { + controller->phase = TRAINLOG_EXERCISE_MESSAGE; + (void)snprintf(controller->message, sizeof(controller->message), + "Impossible de lire les zones actuelles."); + } else trainlog_form_init(&controller->form, controller->pending.name); + app->focus = TRAINLOG_FOCUS_EDITOR; +} + +static bool exercise_merge_matches(const TrainlogExercise *exercise, + const char *query) +{ + char normalized_name[(TRAINLOG_NAME_MAX * 4U) + 1U]; + char normalized_query[(TRAINLOG_NAME_MAX * 4U) + 1U]; + if (query == NULL || query[0] == '\0') return true; + if (trainlog_catalog_normalize_name(exercise->name, normalized_name, + sizeof(normalized_name)) != TRAINLOG_STATUS_OK || + trainlog_catalog_normalize_name(query, normalized_query, + sizeof(normalized_query)) != TRAINLOG_STATUS_OK) return false; + return strstr(normalized_name, normalized_query) != NULL; +} + +static size_t exercise_merge_match_count(const TrainlogExerciseController *controller) +{ + size_t index, count = 0U; + for (index = 0U; index < controller->merge_count; ++index) + if (exercise_merge_matches(&controller->merge_candidates[index], + controller->form.text)) ++count; + return count; +} + +static TrainlogExercise *exercise_merge_match_at(TrainlogExerciseController *controller, + size_t selected) +{ + size_t index, match = 0U; + for (index = 0U; index < controller->merge_count; ++index) { + if (!exercise_merge_matches(&controller->merge_candidates[index], + controller->form.text)) continue; + if (match == selected) return &controller->merge_candidates[index]; + ++match; + } + return NULL; +} + +static bool exercise_merge_candidate_is_compatible( + TrainlogDatabase *database, + const TrainlogExercise *source, + const TrainlogExercise *target ) { - size_t selected = 0U; - int nav_selected = 6; - int focus = 1; - TrainlogSyncScreenState action_state; + TrainlogExerciseBodyZone source_zones[MAX_BODY_ZONES]; + TrainlogExerciseBodyZone target_zones[MAX_BODY_ZONES]; + size_t source_count = 0U, target_count = 0U, index; + const char *source_primary = NULL, *target_primary = NULL; + if (source->tracking_mode != target->tracking_mode || + source->recording_mode != target->recording_mode || + source->data_fields != target->data_fields) return false; + if (trainlog_database_list_exercise_body_zones(database, + source->exercise_id, source_zones, MAX_BODY_ZONES, + &source_count) != TRAINLOG_STATUS_OK || + trainlog_database_list_exercise_body_zones(database, + target->exercise_id, target_zones, MAX_BODY_ZONES, + &target_count) != TRAINLOG_STATUS_OK) return false; + for (index = 0U; index < source_count; ++index) + if (source_zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY) + source_primary = source_zones[index].zone_id; + for (index = 0U; index < target_count; ++index) + if (target_zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY) + target_primary = target_zones[index].zone_id; + return source_primary == NULL || target_primary == NULL || + strcmp(source_primary, target_primary) == 0; +} - TrainlogSyncDeviceInfo device; - TrainlogStatus probe_status = - TRAINLOG_STATUS_NOT_FOUND; +static void exercise_controller_start_merge(TrainlogAppContext *app) +{ + TrainlogExerciseController *controller = &app->exercise_controller; + TrainlogExercise all[MAX_EXERCISES]; + size_t index, count = 0U; + TrainlogStatus status = trainlog_database_list_exercises(app->database, all, + MAX_EXERCISES, &count); + (void)memset(controller, 0, sizeof(*controller)); + controller->merge_source = app->exercise_detail; + if (status != TRAINLOG_STATUS_OK) { + controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT; + (void)snprintf(controller->message, sizeof(controller->message), + "Impossible de charger les cibles de fusion."); + } else { + for (index = 0U; index < count; ++index) + if (strcmp(all[index].exercise_id, + controller->merge_source.exercise_id) != 0 && + exercise_merge_candidate_is_compatible(app->database, + &controller->merge_source, &all[index])) + controller->merge_candidates[controller->merge_count++] = all[index]; + controller->phase = TRAINLOG_EXERCISE_MERGE_PICKER; + trainlog_form_init(&controller->form, ""); + } + (void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_EXERCISE_MERGE, + TRAINLOG_FOCUS_CONTENT, controller->merge_source.exercise_id); + app->focus = TRAINLOG_FOCUS_OVERLAY; +} - bool refresh_device = true; - - (void)database; - - (void)memset( - &device, - 0, - sizeof(device) - ); - - trainlog_sync_screen_state_init( - &action_state - ); - - for (;;) { - TrainlogSyncHistoryEntry - history[SYNC_HISTORY_CAPACITY]; - - size_t history_count = 0U; - - bool large_layout = - trainlog_terminal_columns(tui_terminal) >= 100 && - trainlog_terminal_rows(tui_terminal) >= 30; - - int key; - - if (refresh_device) { - probe_status = - trainlog_sync_probe( - &device - ); - - refresh_device = false; +static bool exercise_controller_save(TrainlogAppContext *app) +{ + TrainlogExerciseController *controller = &app->exercise_controller; + const char *secondary_ids[MAX_BODY_ZONES]; + TrainlogExercise created; + TrainlogStatus status; + char normalized[(TRAINLOG_NAME_MAX * 4U) + 1U]; + size_t index; + if (!controller->editing && + controller->pending.recording_mode == TRAINLOG_RECORDING_SETS && + controller->primary_zone[0] == '\0') { + (void)snprintf(controller->message, sizeof(controller->message), + "Une zone principale est requise pour un exercice par séries."); + return false; + } + for (index = 0U; index < controller->secondary_count; ++index) + secondary_ids[index] = controller->secondary_zones[index]; + if (controller->editing) { + status = trainlog_catalog_normalize_name(controller->pending.name, + normalized, sizeof(normalized)); + if (status == TRAINLOG_STATUS_OK) + status = trainlog_database_update_exercise_profiled(app->database, + controller->pending.exercise_id, controller->pending.name, + normalized, controller->pending.tracking_mode, + controller->pending.recording_mode, + controller->pending.data_fields, + controller->primary_zone[0] != '\0' + ? controller->primary_zone : NULL, + secondary_ids, controller->secondary_count); + created = controller->pending; + } else status = trainlog_catalog_create_exercise_profiled_with_zones( + app->database, controller->pending.name, + controller->pending.tracking_mode, + controller->pending.recording_mode, + controller->pending.data_fields, + controller->primary_zone[0] != '\0' ? controller->primary_zone : NULL, + secondary_ids, controller->secondary_count, &created); + if (status != TRAINLOG_STATUS_OK) { + (void)snprintf(controller->message, sizeof(controller->message), "%s", + status == TRAINLOG_STATUS_CONFLICT + ? "Conflit de nom ou de profil; tous les champs sont conservés." + : "Exercice non enregistré; tous les champs sont conservés."); + return false; + } + controller->dirty = false; + controller->form.active = false; + controller->pending = created; + if (controller->inline_session) { + TrainlogSessionController *session = &app->session; + if (!session_controller_load_picker(app)) { + controller->phase = TRAINLOG_EXERCISE_MESSAGE; + (void)snprintf(controller->message, sizeof(controller->message), + "Exercice créé, mais le sélecteur n’a pas pu être rechargé."); + return true; } + for (index = 0U; index < session->picker_count; ++index) + if (strcmp(session->picker[index].exercise_id, + created.exercise_id) == 0) { + session->picker_selected = index; + break; + } + (void)memset(controller, 0, sizeof(*controller)); + app->focus = TRAINLOG_FOCUS_CONTENT; + return true; + } + if (controller->editing) { + app->exercise_detail = created; + app_shell_load_exercise_detail_metadata(app); + } + else { + app_shell_refresh_list(app); + (void)snprintf(app->list.selected_id, sizeof(app->list.selected_id), + "%s", created.exercise_id); + trainlog_list_set_items(&app->list, app->stable_ids, app->loaded_count, + app->list.visible_rows, app->list.total_known, + app->list.more_available); + } + controller->phase = TRAINLOG_EXERCISE_MESSAGE; + (void)snprintf(controller->message, sizeof(controller->message), + "Exercice %s : %.120s", controller->editing ? "modifié" : "créé", + created.name); + return true; +} - sync_history_load( - history, - SYNC_HISTORY_CAPACITY, - &history_count - ); - - if ( - history_count > 0U && - selected >= history_count - ) { - selected = - history_count - 1U; +static bool app_shell_dispatch_exercise_controller(TrainlogAppContext *app, + int key) +{ + TrainlogExerciseController *controller = &app->exercise_controller; + TrainlogFormResult result; + size_t zone_count = trainlog_body_zone_catalog_count(); + if (controller->phase == TRAINLOG_EXERCISE_IDLE) return false; + if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) { + if (key == TRAINLOG_KEY_ENTER || key == '\n' || + key == TRAINLOG_KEY_ESCAPE) { + (void)memset(controller, 0, sizeof(*controller)); + app->focus = TRAINLOG_FOCUS_CONTENT; } - - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_box(tui_terminal, 0, 0, - trainlog_terminal_rows(tui_terminal) - 1, - trainlog_terminal_columns(tui_terminal) - 1); - - if (large_layout) { - size_t index; - size_t top = 0U; - - int history_top = 18; - - int history_bottom = - trainlog_terminal_rows(tui_terminal) - 4; - - int visible_rows = - history_bottom - - history_top - - 2; - - section_ascii_header( - ":: S Y N C ::" - ); - - primary_top_navbar( - 6, - nav_selected, - focus == 0 - ); - - focused_panel( - 11, - 2, - 16, - trainlog_terminal_columns(tui_terminal) - 3, - "APPAREIL CONNECTE", - false - ); - - trainlog_terminal_printf(tui_terminal, 15, 5, - "Actions : a Android→PC | p PC→Android | b PC↔Android"); - - if ( - probe_status == - TRAINLOG_STATUS_OK && - device.connected && - device.storage_ready - ) { - trainlog_terminal_printf(tui_terminal, - 12, - 5, - "✓ MTP direct connecté" - ); - - trainlog_terminal_printf(tui_terminal, - 13, - 5, - "%s %s", - device.device.vendor, - device.device.model - ); - - trainlog_terminal_printf(tui_terminal, - 14, - 5, - "Stockage interne : %.2f GiB libres / %.2f GiB", - sync_bytes_to_gib( - device.storage - .free_space_bytes - ), - sync_bytes_to_gib( - device.storage - .max_capacity_bytes - ) - ); - } else if ( - probe_status == - TRAINLOG_STATUS_CONFLICT - ) { - trainlog_terminal_printf(tui_terminal, - 13, - 5, - "Service de synchronisation occupé." - ); + return true; + } + if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) { + if (key == '1') { + TrainlogSessionController *session = &app->session; + bool inline_session = controller->inline_session; + bool leaving = controller->leaving; + TrainlogAppRoute pending_route = controller->pending_route; + (void)memset(controller, 0, sizeof(*controller)); + if (inline_session && !leaving) + session->phase = TRAINLOG_SESSION_EXERCISE_PICKER; + app->focus = TRAINLOG_FOCUS_CONTENT; + if (leaving) app_shell_open_route(app, pending_route); + } else if (key == '0' || key == TRAINLOG_KEY_ESCAPE) + controller->phase = controller->return_phase; + return true; + } + if (controller->phase == TRAINLOG_EXERCISE_ZONES) { + const TrainlogBodyZone *zone = zone_count > 0U + ? trainlog_body_zone_catalog_at(controller->zone_selected) : NULL; + size_t secondary_index; + if (key == TRAINLOG_KEY_UP && controller->zone_selected > 0U) + --controller->zone_selected; + else if (key == TRAINLOG_KEY_DOWN && + controller->zone_selected + 1U < zone_count) + ++controller->zone_selected; + else if ((key == 'n' || key == 'N')) { + controller->primary_zone[0] = '\0'; + controller->secondary_count = 0U; + controller->dirty = true; + } else if ((key == 'p' || key == 'P') && zone != NULL && !zone->is_group) { + (void)snprintf(controller->primary_zone, + sizeof(controller->primary_zone), "%s", zone->zone_id); + if (secondary_zone_contains(controller->secondary_zones, + controller->secondary_count, zone->zone_id, + &secondary_index)) { + for (size_t move = secondary_index; + move + 1U < controller->secondary_count; ++move) + (void)memcpy(controller->secondary_zones[move], + controller->secondary_zones[move + 1U], + sizeof(controller->secondary_zones[move])); + --controller->secondary_count; + } + controller->dirty = true; + } else if (key == ' ' && zone != NULL && !zone->is_group && + controller->primary_zone[0] != '\0' && + strcmp(controller->primary_zone, zone->zone_id) != 0) { + if (secondary_zone_contains(controller->secondary_zones, + controller->secondary_count, zone->zone_id, + &secondary_index)) { + for (size_t move = secondary_index; + move + 1U < controller->secondary_count; ++move) + (void)memcpy(controller->secondary_zones[move], + controller->secondary_zones[move + 1U], + sizeof(controller->secondary_zones[move])); + --controller->secondary_count; + } else if (controller->secondary_count < MAX_BODY_ZONES) { + (void)snprintf(controller->secondary_zones[ + controller->secondary_count], TRAINLOG_ZONE_ID_MAX + 1U, + "%s", zone->zone_id); + ++controller->secondary_count; + } + controller->dirty = true; + } else if (key == TRAINLOG_KEY_ENTER || key == '\n') + (void)exercise_controller_save(app); + else if (key == TRAINLOG_KEY_ESCAPE) { + controller->leaving = false; + controller->return_phase = TRAINLOG_EXERCISE_ZONES; + controller->phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD; + } else if (key == TRAINLOG_KEY_F6) + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_NAVIGATION, app->focus, + app->navigation.current.stable_id); + else if (key == TRAINLOG_KEY_F7) + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_ACTIONS, app->focus, + app->navigation.current.stable_id); + return true; + } + if (controller->phase == TRAINLOG_EXERCISE_TRACKING || + controller->phase == TRAINLOG_EXERCISE_RECORDING || + controller->phase == TRAINLOG_EXERCISE_SPEED || + controller->phase == TRAINLOG_EXERCISE_DISTANCE) { + bool directional = key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN || + key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT; + if (directional || key == '0' || key == '1' || key == '2') { + if (controller->phase == TRAINLOG_EXERCISE_TRACKING) { + if (key == '1') controller->pending.tracking_mode = TRAINLOG_TRACKING_REPS; + else if (key == '2') controller->pending.tracking_mode = TRAINLOG_TRACKING_DURATION; + else controller->pending.tracking_mode = + controller->pending.tracking_mode == TRAINLOG_TRACKING_REPS + ? TRAINLOG_TRACKING_DURATION : TRAINLOG_TRACKING_REPS; + } else if (controller->phase == TRAINLOG_EXERCISE_RECORDING) { + if (key == '1') controller->pending.recording_mode = TRAINLOG_RECORDING_SETS; + else if (key == '2') controller->pending.recording_mode = TRAINLOG_RECORDING_CONTINUOUS; + else controller->pending.recording_mode = + controller->pending.recording_mode == TRAINLOG_RECORDING_SETS + ? TRAINLOG_RECORDING_CONTINUOUS : TRAINLOG_RECORDING_SETS; + } else if (controller->phase == TRAINLOG_EXERCISE_SPEED) { + if (key == '0') controller->pending.data_fields &= + ~TRAINLOG_EXERCISE_DATA_SPEED_KMH; + else if (key == '1') controller->pending.data_fields |= + TRAINLOG_EXERCISE_DATA_SPEED_KMH; + else controller->pending.data_fields ^= + TRAINLOG_EXERCISE_DATA_SPEED_KMH; } else { - trainlog_terminal_printf(tui_terminal, - 13, - 5, - "Aucun appareil MTP Trainlog détecté." - ); + if (key == '0') controller->pending.data_fields &= + ~TRAINLOG_EXERCISE_DATA_DISTANCE_KM; + else if (key == '1') controller->pending.data_fields |= + TRAINLOG_EXERCISE_DATA_DISTANCE_KM; + else controller->pending.data_fields ^= + TRAINLOG_EXERCISE_DATA_DISTANCE_KM; } - - focused_panel( - history_top, - 2, - history_bottom, - trainlog_terminal_columns(tui_terminal) - 3, - "HISTORIQUE DES SYNCHRONISATIONS", - focus == 1 - ); - - if ( - history_count == 0U - ) { - trainlog_terminal_printf(tui_terminal, - history_top + 2, - 5, - "Aucune synchronisation enregistrée." - ); - } else { - if (visible_rows < 1) { - visible_rows = 1; - } - - if ( - selected >= - (size_t)visible_rows - ) { - top = - selected - - (size_t)visible_rows + - 1U; - } - - for ( - index = 0U; - index < - (size_t)visible_rows && - top + index < - history_count; - ++index - ) { - size_t absolute = - top + index; - - int row = - history_top + - 2 + - (int)index; - - if ( - focus == 1 && - absolute == selected - ) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_printf(tui_terminal, - row, - 5, - " %-16s %c %-17.17s %-*.*s ", - history[absolute] - .timestamp, - history[absolute] - .success - ? '+' - : '!', - trainlog_sync_history_direction_label( - &history[absolute] - ), - trainlog_terminal_columns(tui_terminal) - 47, - trainlog_terminal_columns(tui_terminal) - 47, - history[absolute] - .summary - ); - - if ( - focus == 1 && - absolute == selected - ) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - } - } - - trainlog_terminal_style_on(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - - trainlog_terminal_printf(tui_terminal, - trainlog_terminal_rows(tui_terminal) - 2, - 2, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 4, - trainlog_sync_screen_footer( - trainlog_terminal_columns(tui_terminal) - ) - ); - - trainlog_terminal_style_off(tui_terminal, - trainlog_theme_style( - TRAINLOG_COLOR_MUTED - ) - ); - } else { - draw_shell( - "TRAINLOG — Sync", - trainlog_sync_screen_footer( - trainlog_terminal_columns(tui_terminal) - ) - ); - - if ( - probe_status == - TRAINLOG_STATUS_OK && - device.connected - ) { - trainlog_terminal_printf(tui_terminal, - 4, - 4, - "✓ %s %s", - device.device.vendor, - device.device.model - ); - } else { - trainlog_terminal_printf(tui_terminal, - 4, - 4, - "Aucun appareil MTP." - ); - } - - trainlog_terminal_printf(tui_terminal, 5, 4, - "a Android→PC | p PC→Android | b PC↔Android"); - - if ( - history_count == 0U - ) { - trainlog_terminal_printf(tui_terminal, - 7, - 4, - "Aucune synchronisation." - ); - } else { - size_t index; - - size_t limit = - history_count < 8U - ? history_count - : 8U; - - for ( - index = 0U; - index < limit; - ++index - ) { - if ( - index == selected - ) { - trainlog_terminal_style_on(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - - trainlog_terminal_printf(tui_terminal, - 7 + (int)index, - 4, - "%-16s %c %-17.17s %.*s", - history[index] - .timestamp, - history[index] - .success - ? '+' - : '!', - trainlog_sync_history_direction_label( - &history[index] - ), - trainlog_terminal_columns(tui_terminal) - 43, - history[index] - .summary - ); - - if ( - index == selected - ) { - trainlog_terminal_style_off(tui_terminal, - TRAINLOG_TEXT_REVERSE | - trainlog_theme_style( - TRAINLOG_COLOR_ACCENT - ) - ); - } - } - } - } - - if (action_state.confirming) { - int middle = - trainlog_terminal_rows(tui_terminal) / 2; - - focused_panel( - middle - 2, - 4, - middle + 2, - trainlog_terminal_columns(tui_terminal) - 5, - "CONFIRMATION", - true - ); - trainlog_terminal_printf(tui_terminal, - middle, - 7, - "%.*s", - trainlog_terminal_columns(tui_terminal) - 14, - trainlog_sync_screen_confirmation( - action_state.direction - ) - ); - trainlog_terminal_printf(tui_terminal, - middle + 1, - 7, - "Entrée confirmer · Échap annuler" - ); - } - - trainlog_terminal_render(tui_terminal); - key = trainlog_terminal_get_key(tui_terminal); - - { - bool was_confirming = - action_state.confirming; - - TrainlogSyncScreenAction action = - trainlog_sync_screen_dispatch( - &action_state, - key - ); - - if (action.effect == TRAINLOG_SYNC_SCREEN_EXIT) { - return; - } - - if (action.effect == TRAINLOG_SYNC_SCREEN_REFRESH) { - refresh_device = true; - continue; - } - - if ( - action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM || - action.effect == TRAINLOG_SYNC_SCREEN_CANCEL - ) { - continue; - } - - if (action.effect == TRAINLOG_SYNC_SCREEN_RUN) { - TrainlogSyncReport report; - TrainlogStatus status; - - (void)memset( - &report, - 0, - sizeof(report) - ); - - status_line( - action.direction == TRAINLOG_SYNC_ANDROID_TO_PC - ? "Étape Android→PC : import en cours..." - : action.direction == TRAINLOG_SYNC_PC_TO_ANDROID - ? "Étape PC→Android : publication en cours..." - : "Étape PC↔Android : import puis publication...", - TRAINLOG_COLOR_WARNING - ); - - trainlog_terminal_render(tui_terminal); - - status = - trainlog_sync_screen_execute( - &action, - sync_screen_run, - NULL, - &report - ); - - if ( - status == - TRAINLOG_STATUS_OK && - report.success - ) { - status_line( - report.summary, - TRAINLOG_COLOR_SUCCESS - ); + controller->dirty = true; + } else if (key == TRAINLOG_KEY_ENTER || key == '\n') { + if (controller->phase == TRAINLOG_EXERCISE_TRACKING) + controller->phase = TRAINLOG_EXERCISE_RECORDING; + else if (controller->phase == TRAINLOG_EXERCISE_RECORDING) { + if (controller->pending.recording_mode == TRAINLOG_RECORDING_CONTINUOUS) { + controller->pending.tracking_mode = TRAINLOG_TRACKING_DURATION; + controller->phase = TRAINLOG_EXERCISE_SPEED; } else { - char error_summary[ - TRAINLOG_SYNC_ERROR_MAX + 32U - ]; - - (void)snprintf( - error_summary, - sizeof(error_summary), - "%s — %s", - trainlog_sync_screen_direction_label( - action.direction - ), - report.error[0] != '\0' - ? report.error - : "Synchronisation échouée." - ); - - status_line( - error_summary, - TRAINLOG_COLOR_ERROR - ); + controller->pending.data_fields = 0U; + controller->phase = TRAINLOG_EXERCISE_ZONES; } - - trainlog_terminal_render(tui_terminal); - (void)trainlog_terminal_get_key(tui_terminal); - - refresh_device = true; - continue; - } - - /* While confirmation is visible, unrelated keys (including the - * retired s shortcut) cannot leak into history/navigation. */ - if (was_confirming) { - continue; - } + } else if (controller->phase == TRAINLOG_EXERCISE_SPEED) + controller->phase = TRAINLOG_EXERCISE_DISTANCE; + else controller->phase = TRAINLOG_EXERCISE_ZONES; + } else if (key == TRAINLOG_KEY_ESCAPE) { + controller->return_phase = controller->phase; + controller->phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD; + } else if (key == TRAINLOG_KEY_F6) + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_NAVIGATION, app->focus, + app->navigation.current.stable_id); + else if (key == TRAINLOG_KEY_F7) + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_ACTIONS, app->focus, + app->navigation.current.stable_id); + return true; + } + result = trainlog_form_handle(&controller->form, key); + if (result == TRAINLOG_FORM_OPEN_NAVIGATION || + result == TRAINLOG_FORM_OPEN_ACTIONS) { + (void)trainlog_overlays_push(&app->overlays, + result == TRAINLOG_FORM_OPEN_NAVIGATION + ? TRAINLOG_OVERLAY_NAVIGATION : TRAINLOG_OVERLAY_ACTIONS, + app->focus, app->navigation.current.stable_id); + } else if (result == TRAINLOG_FORM_CANCEL) { + controller->leaving = false; + controller->return_phase = TRAINLOG_EXERCISE_NAME; + controller->phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD; + } else if (result == TRAINLOG_FORM_EDITED) { + controller->dirty = true; + controller->message[0] = '\0'; + } else if (result == TRAINLOG_FORM_SUBMIT || result == TRAINLOG_FORM_NEXT) { + if (controller->form.text[0] == '\0') + (void)snprintf(controller->message, sizeof(controller->message), + "Le nom est obligatoire; la saisie est conservée."); + else { + (void)snprintf(controller->pending.name, + sizeof(controller->pending.name), "%s", controller->form.text); + controller->form.active = false; + controller->phase = controller->editing + ? TRAINLOG_EXERCISE_ZONES : TRAINLOG_EXERCISE_TRACKING; } + } + return true; +} - if ( - large_layout && - ( - key == TRAINLOG_KEY_TAB || - key == TRAINLOG_KEY_SHIFT_TAB - ) - ) { - focus = - focus == 0 - ? 1 - : 0; +static void app_shell_release_session_detail(TrainlogAppContext *app) +{ + trainlog_database_free_session_details(app->session_entries, + app->session_entry_count); + (void)memset(app->session_entries, 0, sizeof(app->session_entries)); + app->session_entry_count = 0U; +} - continue; +static void app_shell_open_session_detail(TrainlogAppContext *app, + const char *session_id) +{ + app_shell_release_session_detail(app); + app->session_detail_error = trainlog_database_get_session_details(app->database, + session_id, &app->session_detail, app->session_entries, + MAX_SESSION_EXERCISES, &app->session_entry_count) != TRAINLOG_STATUS_OK; + app->session_entry_selected = 0U; + app->session_set_scroll = 0U; + (void)trainlog_navigation_open(&app->navigation, TRAINLOG_ROUTE_SESSION_DETAIL, + session_id); +} + +static void app_shell_actions(TrainlogAppContext *app) +{ + TrainlogAppRoute route = app->navigation.current.route; + TrainlogSessionController *session = &app->session; + trainlog_actions_clear(&app->actions); + if (app->body_controller.phase != TRAINLOG_BODY_IDLE) { + TrainlogBodyPhase phase = app->body_controller.phase; + if (phase == TRAINLOG_BODY_CONFIRM_DISCARD) { + app_shell_add_action(app, "body.form.discard", '1', + "1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route); + app_shell_add_action(app, "body.form.resume", '0', + "0 Reprendre", 2U, TRAINLOG_INTENT_BACK, route); + } else if (phase == TRAINLOG_BODY_MESSAGE) + app_shell_add_action(app, "body.form.close", TRAINLOG_KEY_ENTER, + "Entrée Retour", 1U, TRAINLOG_INTENT_BACK, route); + else { + app_shell_add_action(app, "body.form.next", TRAINLOG_KEY_ENTER, + app->body_controller.field == 13U ? "Entrée Enregistrer" + : "Entrée Champ suivant", + 1U, TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "body.form.cancel", TRAINLOG_KEY_ESCAPE, + "Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route); } - - if ( - large_layout && - focus == 0 - ) { - if ( - key == TRAINLOG_KEY_LEFT - ) { - nav_selected = - nav_selected > 0 - ? nav_selected - 1 - : PRIMARY_NAV_COUNT - 1; - } else if ( - key == TRAINLOG_KEY_RIGHT - ) { - nav_selected = - nav_selected < PRIMARY_NAV_COUNT - 1 - ? nav_selected + 1 - : 0; - } else if ( - key == '\n' || - key == TRAINLOG_KEY_ENTER - ) { - if (nav_selected == 6) { - focus = 1; - } else if ( - primary_top_nav_activate( - nav_selected - ) - ) { - return; - } - } - - continue; + app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6, + "F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route); + app_shell_add_action(app, "actions", TRAINLOG_KEY_F7, + "F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route); + app_shell_add_action(app, "help", '?', "? Aide", 82U, + TRAINLOG_INTENT_OPEN_HELP, route); + return; + } + if (app->profile_controller.phase != TRAINLOG_PROFILE_IDLE) { + TrainlogProfilePhase phase = app->profile_controller.phase; + if (phase == TRAINLOG_PROFILE_CONFIRM_DISCARD) { + app_shell_add_action(app, "profile.discard", '1', "1 Abandonner", + 1U, TRAINLOG_INTENT_DISCARD, route); + app_shell_add_action(app, "profile.resume", '0', "0 Reprendre", + 2U, TRAINLOG_INTENT_BACK, route); + } else if (phase == TRAINLOG_PROFILE_MESSAGE) + app_shell_add_action(app, "profile.close", TRAINLOG_KEY_ENTER, + "Entrée Retour", 1U, TRAINLOG_INTENT_BACK, route); + else { + app_shell_add_action(app, "profile.next", TRAINLOG_KEY_ENTER, + phase == TRAINLOG_PROFILE_HEIGHT ? "Entrée Enregistrer" + : "Entrée Continuer", + 1U, TRAINLOG_INTENT_SAVE, route); + app_shell_add_action(app, "profile.cancel", TRAINLOG_KEY_ESCAPE, + "Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route); } - - if ( - history_count > 0U && - ( - key == '\n' || - key == TRAINLOG_KEY_ENTER - ) - ) { - if ( - history[selected] - .sync_id[0] != '\0' - ) { - screen_sync_run_detail( - history[selected] - .sync_id - ); - } - - continue; + app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6, + "F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route); + app_shell_add_action(app, "actions", TRAINLOG_KEY_F7, + "F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route); + app_shell_add_action(app, "help", '?', "? Aide", 82U, + TRAINLOG_INTENT_OPEN_HELP, route); + return; + } + if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) { + TrainlogExercisePhase phase = app->exercise_controller.phase; + if (phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) { + app_shell_add_action(app, "exercise.form.discard", '1', + "1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route); + app_shell_add_action(app, "exercise.form.resume", '0', + "0 Reprendre", 2U, TRAINLOG_INTENT_BACK, route); + } else if (phase == TRAINLOG_EXERCISE_MESSAGE) + app_shell_add_action(app, "exercise.form.close", TRAINLOG_KEY_ENTER, + "Entrée Retour", 1U, TRAINLOG_INTENT_BACK, route); + else { + app_shell_add_action(app, "exercise.form.next", TRAINLOG_KEY_ENTER, + phase == TRAINLOG_EXERCISE_ZONES ? "Entrée Enregistrer" + : "Entrée Continuer", + 1U, phase == TRAINLOG_EXERCISE_ZONES ? TRAINLOG_INTENT_SAVE + : TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "exercise.form.cancel", TRAINLOG_KEY_ESCAPE, + "Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route); } + app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6, + "F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route); + app_shell_add_action(app, "actions", TRAINLOG_KEY_F7, + "F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route); + app_shell_add_action(app, "help", '?', "? Aide", 82U, + TRAINLOG_INTENT_OPEN_HELP, route); + return; + } + if (app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE) { + TrainlogEquipmentPhase phase = app->equipment_controller.phase; + if (phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) { + app_shell_add_action(app, "equipment.create.discard", '1', + "1 Abandonner", 1U, TRAINLOG_INTENT_DISCARD, route); + app_shell_add_action(app, "equipment.create.resume", '0', + "0 Reprendre", 2U, TRAINLOG_INTENT_BACK, route); + } else if (phase == TRAINLOG_EQUIPMENT_MESSAGE) + app_shell_add_action(app, "equipment.create.close", TRAINLOG_KEY_ENTER, + "Entrée Catalogue", 1U, TRAINLOG_INTENT_BACK, route); + else { + app_shell_add_action(app, "equipment.create.next", TRAINLOG_KEY_ENTER, + phase == TRAINLOG_EQUIPMENT_LOAD ? "Entrée Enregistrer" + : "Entrée Champ suivant", + 1U, phase == TRAINLOG_EQUIPMENT_LOAD ? TRAINLOG_INTENT_SAVE + : TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "equipment.create.cancel", TRAINLOG_KEY_ESCAPE, + "Échap Abandonner…", 2U, TRAINLOG_INTENT_DISCARD, route); + } + app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6, + "F6 Navigation", 80U, TRAINLOG_INTENT_OPEN_NAVIGATION, route); + app_shell_add_action(app, "actions", TRAINLOG_KEY_F7, + "F7 Actions", 81U, TRAINLOG_INTENT_OPEN_ACTIONS, route); + app_shell_add_action(app, "help", '?', "? Aide", 82U, + TRAINLOG_INTENT_OPEN_HELP, route); + return; + } + if (route == TRAINLOG_ROUTE_HOME) + app_shell_add_action(app, "session", '1', "1 Séance", 1U, + TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL); + if (route == TRAINLOG_ROUTE_SESSIONS) + app_shell_add_action(app, "generate", 'g', "g Programmer", 1U, + TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_GENERATOR); + if (route == TRAINLOG_ROUTE_BODY || + route == TRAINLOG_ROUTE_SYNC || route == TRAINLOG_ROUTE_SETTINGS) + app_shell_add_action(app, "open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U, + TRAINLOG_INTENT_PRIMARY, route); + if ((route == TRAINLOG_ROUTE_SESSION_MANUAL || + route == TRAINLOG_ROUTE_SESSION_CURRENT) && + session->phase == TRAINLOG_SESSION_DRAFT) { + app_shell_add_action(app, "session.occurrence.add", 'a', "a Ajouter", 1U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "session.occurrence.edit", 'e', "e Séries/résultat", 2U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "session.occurrence.replace", 'r', "r Remplacer", 3U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "session.occurrence.equipment", 'i', "i Équipement", 4U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "session.occurrence.plan", 'p', "p Objectif prévu", 5U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "session.occurrence.remove", 'd', "d Retirer", 6U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "session.finish", 'f', "f Enregistrer", 7U, + TRAINLOG_INTENT_SAVE, route); + app_shell_add_action(app, "session.abandon", 'q', "q Abandonner", 8U, + TRAINLOG_INTENT_DISCARD, route); + } + if ((route == TRAINLOG_ROUTE_SESSION_MANUAL || + route == TRAINLOG_ROUTE_SESSION_CURRENT) && + session->phase == TRAINLOG_SESSION_EXERCISE_PICKER) + app_shell_add_action(app, "session.exercise.create", 'n', + "n Créer un exercice", 1U, TRAINLOG_INTENT_NONE, route); + if ((route == TRAINLOG_ROUTE_SESSION_MANUAL || + route == TRAINLOG_ROUTE_SESSION_CURRENT) && + session->phase == TRAINLOG_SESSION_ACTUALS) { + TrainlogSessionDraftExercise *draft = session->selected < session->draft_count + ? &session->drafts[session->selected] : NULL; + app_shell_add_action(app, "session.actual.edit", 'e', "e Modifier", 1U, + TRAINLOG_INTENT_NONE, route); + if (draft != NULL && draft->input.recording_mode == TRAINLOG_RECORDING_SETS && + !draft->input.has_max_weight) { + app_shell_add_action(app, "session.set.add", 'a', "a Ajouter série", 2U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "session.set.remove", 'd', "d Supprimer série", 3U, + TRAINLOG_INTENT_NONE, route); + } + if (draft != NULL && draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS && + (draft->input.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U) + app_shell_add_action(app, "session.continuous.speed", 's', "s Vitesse", 2U, + TRAINLOG_INTENT_NONE, route); + if (draft != NULL && draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS && + (draft->input.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U) + app_shell_add_action(app, "session.continuous.distance", 'k', "k Distance", 3U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "session.actual.done", 'f', "f Terminer édition", 4U, + TRAINLOG_INTENT_BACK, route); + } + if (route == TRAINLOG_ROUTE_SESSION_GENERATOR) { + if (session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG) + app_shell_add_action(app, "generator.generate", 'g', "g Générer", 1U, + TRAINLOG_INTENT_PRIMARY, route); + if (session->phase == TRAINLOG_SESSION_GENERATOR_WARNING) { + app_shell_add_action(app, "generator.warning.continue", 'c', + "c Continuer", 1U, TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "generator.warning.zone", 'z', + "z Autre zone", 2U, TRAINLOG_INTENT_NONE, route); + } + if (session->phase == TRAINLOG_SESSION_GENERATOR_PREVIEW) { + app_shell_add_action(app, "generator.accept", 'a', "a Accepter", 1U, + TRAINLOG_INTENT_PRIMARY, route); + app_shell_add_action(app, "generator.remove", 'd', "d Retirer", 2U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "generator.edit.dose", 'e', "e Modifier dose", 3U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "generator.load.percent", '%', "% %MAX", 4U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "generator.load.auto", 'u', "u Charge auto", 5U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "generator.load.none", 'x', "x Sans charge", 6U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "generator.regenerate", 'g', "g Régénérer", 4U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "generator.move.up", '<', "< Monter", 5U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "generator.move.down", '>', "> Descendre", 6U, + TRAINLOG_INTENT_NONE, route); + } + if (session->generated_preview) + app_shell_add_action(app, "generator.discard", 'q', + "q Abandonner la proposition", 6U, TRAINLOG_INTENT_DISCARD, route); + } + if (app_shell_is_list_route(route)) { + app_shell_add_action(app, "list.open", TRAINLOG_KEY_ENTER, "Entrée Ouvrir", 1U, + TRAINLOG_INTENT_PRIMARY, route); + if (route != TRAINLOG_ROUTE_BODY) + app_shell_add_action(app, "list.search", '/', "/ Rechercher", 2U, + TRAINLOG_INTENT_OPEN_SEARCH, route); + } + if (route == TRAINLOG_ROUTE_BODY) { + app_shell_add_action(app, "body.add", 'a', "a Ajouter", 1U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "body.edit", 'e', "e Modifier", 2U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "body.analytics", 'v', "v Analyse", 3U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "body.metric", 'm', "m Historique mesure", 4U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "body.graph", 'g', "g Vue 12 mois", 5U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "body.global", 'o', "o Vue globale", 6U, + TRAINLOG_INTENT_NONE, route); + } + if (route == TRAINLOG_ROUTE_BODY_DETAIL) + app_shell_add_action(app, "body.detail.edit", 'e', "e Modifier", 1U, + TRAINLOG_INTENT_NONE, route); + if (route == TRAINLOG_ROUTE_BODY_METRIC) { + app_shell_add_action(app, "body.metric.previous", TRAINLOG_KEY_LEFT, + "← Mesure précédente", 1U, TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "body.metric.next", TRAINLOG_KEY_RIGHT, + "→ Mesure suivante", 2U, TRAINLOG_INTENT_NONE, route); + } + if (route == TRAINLOG_ROUTE_BODY_GLOBAL) + app_shell_add_action(app, "body.global.toggle", ' ', + "Espace Afficher/masquer", 1U, TRAINLOG_INTENT_NONE, route); + if (route == TRAINLOG_ROUTE_BODY_ANALYTICS) + app_shell_add_action(app, "body.profile", 'p', "p Profil estimation", + 1U, TRAINLOG_INTENT_NONE, route); + if (route == TRAINLOG_ROUTE_SYNC) { + /* WHY: one registry entry is the source of truth for both the footer + * and F7 palette, preventing directional or duplicate launch affordances. */ + app_shell_add_action(app, "sync.now", 's', + "s Synchroniser maintenant · PC↔Android", 1U, + TRAINLOG_INTENT_NONE, route); + app_shell_add_action(app, "sync.refresh", 'r', "r Actualiser appareil", + 2U, TRAINLOG_INTENT_REFRESH, route); + } + if (route == TRAINLOG_ROUTE_EQUIPMENT) + app_shell_add_action(app, "equipment.create", 'n', "n Créer", 1U, + TRAINLOG_INTENT_NONE, route); + if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL) { + app_shell_add_action(app, "exercise.performance", 'p', "p Performances", 1U, + TRAINLOG_INTENT_PRIMARY, route); + app_shell_add_action(app, "exercise.max", 'm', "m MAX", 2U, + TRAINLOG_INTENT_PRIMARY, route); + app_shell_add_action(app, "exercise.knowledge", 'k', "k Connaissances", 3U, + TRAINLOG_INTENT_PRIMARY, route); + app_shell_add_action(app, "exercise.edit", 'e', "e Modifier", 4U, + TRAINLOG_INTENT_PRIMARY, route); + app_shell_add_action(app, "exercise.merge", 'u', "u Fusionner avec…", 5U, + TRAINLOG_INTENT_NONE, route); + } + if (route == TRAINLOG_ROUTE_EXERCISES) + app_shell_add_action(app, "exercise.create", 'a', "a Créer", 1U, + TRAINLOG_INTENT_NONE, route); + if (route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE) + app_shell_add_action(app, "knowledge.close", 'k', "k Fermer", 1U, + TRAINLOG_INTENT_BACK, TRAINLOG_ROUTE_EXERCISE_DETAIL); + if (route == TRAINLOG_ROUTE_EXERCISE_MAX) + app_shell_add_action(app, "max.rounding", 'r', "r Arrondi", 1U, + TRAINLOG_INTENT_NONE, route); + if (route == TRAINLOG_ROUTE_SESSION_DETAIL) + app_shell_add_action(app, "session.edit", 'e', "e Modifier", 1U, + TRAINLOG_INTENT_NONE, route); + /* CONTRACT: destination aliases live in the same model as contextual + * actions. Dispatcher, footer selection and F7 therefore cannot drift. */ + app_shell_add_action(app, "home", '0', "0 Accueil", 100U, + TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_HOME); + app_shell_add_action(app, "session.alias", '1', "1 Séance", 101U, + TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSION_MANUAL); + app_shell_add_action(app, "completed.alias", '2', "2 Effectuées", 102U, + TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSIONS_COMPLETED); + app_shell_add_action(app, "exercises.alias", '3', "3 Exercices", 103U, + TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EXERCISES); + app_shell_add_action(app, "equipment.alias", '4', "4 Équipements", 104U, + TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EQUIPMENT); + app_shell_add_action(app, "body.alias", '5', "5 Mensurations", 105U, + TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_BODY); + app_shell_add_action(app, "sync.alias", '6', "6 Synchronisation", 106U, + TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SYNC); + app_shell_add_action(app, "navigation", TRAINLOG_KEY_F6, "F6 Navigation", 80U, + TRAINLOG_INTENT_OPEN_NAVIGATION, route); + app_shell_add_action(app, "actions", TRAINLOG_KEY_F7, "F7 Actions", 81U, + TRAINLOG_INTENT_OPEN_ACTIONS, route); + app_shell_add_action(app, "help", '?', "? Aide", 82U, + TRAINLOG_INTENT_OPEN_HELP, route); + app_shell_add_action(app, "back", TRAINLOG_KEY_ESCAPE, + route == TRAINLOG_ROUTE_HOME ? "q Quitter" : "Échap Retour", 83U, + route == TRAINLOG_ROUTE_HOME ? TRAINLOG_INTENT_QUIT : TRAINLOG_INTENT_BACK, + route); +} - if ( - key == TRAINLOG_KEY_UP && - history_count > 0U - ) { - selected = - selected > 0U - ? selected - 1U - : history_count - 1U; - } else if ( - key == TRAINLOG_KEY_DOWN && - history_count > 0U - ) { - selected = - selected + 1U < - history_count - ? selected + 1U - : 0U; - } else if ( - key == '0' || - key == TRAINLOG_KEY_HOME - ) { +static void app_shell_render_footer(TrainlogAppContext *app) +{ + const TrainlogOverlay *overlay = trainlog_overlays_top(&app->overlays); + const TrainlogAction *first; + const TrainlogAction *second; + trainlog_surface_erase(app->footer); + trainlog_surface_set_role(app->footer, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_NORMAL); + if (overlay != NULL) { + if (overlay->type == TRAINLOG_OVERLAY_CONFIRMATION && app->quit_confirmation) { + trainlog_surface_printf(app->footer, 0, 2, + "↑↓ choisir Entrée valider"); + trainlog_surface_printf(app->footer, 1, 2, + "Annuler conserve l’état de cette exécution"); return; - } else if ( - key == '1' || - key == TRAINLOG_KEY_F1 || - key == '2' || - key == TRAINLOG_KEY_F2 || - key == '3' || - key == TRAINLOG_KEY_F3 || - key == '4' || - key == TRAINLOG_KEY_F4 || - key == '5' || - key == TRAINLOG_KEY_F5 || - key == '6' - ) { - if ( - primary_top_nav_forward( - key - ) - ) { - return; + } + trainlog_surface_printf(app->footer, 0, 2, "↑↓ choisir Entrée valider"); + trainlog_surface_printf(app->footer, 1, 2, "Échap fermer F7 Actions ? Aide"); + return; + } + first = trainlog_actions_at_priority(&app->actions, 0U); + second = trainlog_actions_at_priority(&app->actions, 1U); + if (first != NULL) trainlog_surface_printf(app->footer, 0, 2, "%s", first->label); + if (second != NULL) trainlog_surface_printf(app->footer, 0, 24, "%s", second->label); + trainlog_surface_printf(app->footer, 1, 2, + "F6 Navigation F7 Actions ? Aide %s", + app->navigation.current.route == TRAINLOG_ROUTE_HOME ? "q Quitter" : "Échap Retour"); +} + +static void app_shell_render_sidebar(TrainlogAppContext *app) +{ + size_t index; + if (app->sidebar == NULL) return; + trainlog_surface_erase(app->sidebar); + trainlog_surface_set_role(app->sidebar, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_NORMAL); + for (index = 0U; index < sizeof(shell_sections) / sizeof(shell_sections[0]); ++index) { + bool active = trainlog_route_section(app->navigation.current.route) == shell_sections[index]; + bool focused = app->focus == TRAINLOG_FOCUS_NAVIGATION && + app->navigation_selected == index; + trainlog_surface_set_role(app->sidebar, + focused || active ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT, + focused ? TRAINLOG_RGB_SURFACE0 : TRAINLOG_RGB_MANTLE, + focused ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->sidebar, 1 + (int)index * 2, 1, + "%c%c %-17s", focused ? '>' : ' ', active ? '[' : ' ', + shell_section_labels[index]); + if (active && app->layout.sidebar_expanded) { + const char *subroute = NULL; + switch (app->navigation.current.route) { + case TRAINLOG_ROUTE_SESSION_CURRENT: subroute = "· Séance en cours"; break; + case TRAINLOG_ROUTE_SESSION_GENERATOR: subroute = "· Programmer"; break; + case TRAINLOG_ROUTE_SESSION_MANUAL: subroute = "· Nouvelle séance"; break; + case TRAINLOG_ROUTE_SESSIONS_COMPLETED: subroute = "· Effectuées"; break; + case TRAINLOG_ROUTE_SESSION_DETAIL: subroute = "· Détail séance"; break; + case TRAINLOG_ROUTE_EXERCISE_DETAIL: subroute = "· Fiche exercice"; break; + case TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE: subroute = "· Connaissances"; break; + case TRAINLOG_ROUTE_EXERCISE_PERFORMANCE: subroute = "· Performances"; break; + case TRAINLOG_ROUTE_EXERCISE_MAX: subroute = "· MAX exercice"; break; + case TRAINLOG_ROUTE_EQUIPMENT_DETAIL: subroute = "· Fiche équipement"; break; + case TRAINLOG_ROUTE_STATS_EXERCISE: subroute = "· Par exercice"; break; + case TRAINLOG_ROUTE_BODY: subroute = "· Mensurations"; break; + case TRAINLOG_ROUTE_BODY_DETAIL: subroute = "· Relevé corporel"; break; + case TRAINLOG_ROUTE_BODY_METRIC: subroute = "· Historique mesure"; break; + case TRAINLOG_ROUTE_BODY_TRENDS: subroute = "· Évolution 12 mois"; break; + case TRAINLOG_ROUTE_BODY_GLOBAL: subroute = "· Vue globale"; break; + case TRAINLOG_ROUTE_BODY_ANALYTICS: subroute = "· Analyse corporelle"; break; + case TRAINLOG_ROUTE_MAX: subroute = "· Capacités / MAX"; break; + default: break; + } + if (subroute != NULL) + trainlog_surface_printf(app->sidebar, + 2 + (int)index * 2, 3, "%s", subroute); + } + } +} + +static void app_shell_render_session_controller(TrainlogAppContext *app) +{ + TrainlogSessionController *session = &app->session; + int row = 3; + size_t index; + if (session->phase == TRAINLOG_SESSION_CHOOSE_TYPE) { + trainlog_surface_printf(app->content, row++, 2, "Choisissez le type de séance :"); + trainlog_surface_printf(app->content, row++, 4, "%c Entraînement", + session->session_type == TRAINLOG_SESSION_TRAINING ? '>' : ' '); + trainlog_surface_printf(app->content, row, 4, "%c Test max explicite", + session->session_type == TRAINLOG_SESSION_MAX_TEST ? '>' : ' '); + } else if (session->phase == TRAINLOG_SESSION_EXERCISE_PICKER) { + trainlog_surface_printf(app->content, row++, 2, + "%s — choisir un exercice", session->replacing_occurrence + ? "Remplacer l’occurrence" : "Ajouter une occurrence"); + for (index = 0U; index < session->picker_count && + row < app->layout.content.height; ++index) + trainlog_surface_printf(app->content, row++, 4, "%c %.52s", + index == session->picker_selected ? '>' : ' ', + session->picker[index].name); + } else if (session->phase == TRAINLOG_SESSION_EQUIPMENT_PICKER) { + trainlog_surface_printf(app->content, row++, 2, + "Équipement de l’occurrence — ID stable de l’occurrence inchangé"); + trainlog_surface_printf(app->content, row++, 4, "%c Aucun équipement", + session->equipment_selected == 0U ? '>' : ' '); + for (index = 0U; index < session->equipment_count && + row < app->layout.content.height; ++index) + trainlog_surface_printf(app->content, row++, 4, "%c %.46s %s", + session->equipment_selected == index + 1U ? '>' : ' ', + app->equipment[index].display_name, + equipment_origin_label(app->equipment[index].origin)); + trainlog_surface_printf(app->content, app->layout.content.height - 2, 2, + "Entrée choisir · x aucun · n créer puis reprendre (phase catalogue)"); + } else if (session->phase == TRAINLOG_SESSION_EQUIPMENT_CREATE) { + const char *label = session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_NAME + ? "Nom convivial" : session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL + ? "Nom étiquette (optionnel)" : + session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE + ? "Type" : "Charge : 1 aucune · 2 externe · 3 assistance"; + trainlog_surface_printf(app->content, row++, 2, + "Créer un équipement personnel — retour à l’occurrence"); + trainlog_surface_printf(app->content, row++, 4, "%s", label); + trainlog_surface_printf(app->content, row++, 4, "> %s", session->form.text); + if (session->message[0] != '\0') + trainlog_surface_printf(app->content, row + 1, 4, "%s", session->message); + } else if (session->phase == TRAINLOG_SESSION_PLANNING && + session->selected < session->draft_count) { + TrainlogSessionDraftExercise *draft = &session->drafts[session->selected]; + static const char *const labels[] = {"Séries cibles", "Répétitions cibles", + "Durée cible (s)", "Repos (s)", "Charge/assistance cible (kg)"}; + char values[5][48]; + (void)snprintf(values[0], sizeof(values[0]), "%d", draft->input.target_sets); + (void)snprintf(values[1], sizeof(values[1]), "%d", draft->input.target_reps); + (void)snprintf(values[2], sizeof(values[2]), "%d", + draft->input.target_duration_seconds); + (void)snprintf(values[3], sizeof(values[3]), "%d", draft->input.rest_seconds); + if (draft->input.target_has_weight) + (void)snprintf(values[4], sizeof(values[4]), "%.2f", + draft->input.target_weight_kg); + else (void)snprintf(values[4], sizeof(values[4]), "—"); + if (draft->input.recording_mode != TRAINLOG_RECORDING_SETS) { + (void)snprintf(values[0], sizeof(values[0]), "non applicable"); + (void)snprintf(values[3], sizeof(values[3]), "non applicable"); + } + if (draft->tracking_mode != TRAINLOG_TRACKING_REPS) + (void)snprintf(values[1], sizeof(values[1]), "non applicable"); + if (draft->tracking_mode != TRAINLOG_TRACKING_DURATION) + (void)snprintf(values[2], sizeof(values[2]), "non applicable"); + if (draft->input.load_mode == TRAINLOG_LOAD_NONE) + (void)snprintf(values[4], sizeof(values[4]), "non applicable"); + trainlog_surface_printf(app->content, row++, 2, + "Objectif prévu — séparé des valeurs réalisées"); + for (index = 0U; index < 5U; ++index) + trainlog_surface_printf(app->content, row++, 4, "%c %-32s %s", + index == session->planning_field ? '>' : ' ', labels[index], values[index]); + if (row < app->layout.content.height - 2) + trainlog_surface_printf(app->content, row++, 4, + "%% sur la charge : calcul utilisateur 1..100%% du dernier MAX compatible"); + if (session->form.active) + trainlog_surface_printf(app->content, app->layout.content.height - 3, 2, + "> %s", session->form.text); + } else if (session->phase == TRAINLOG_SESSION_ACTUALS && + session->selected < session->draft_count) { + TrainlogSessionDraftExercise *draft = &session->drafts[session->selected]; + trainlog_surface_printf(app->content, row++, 2, "%s", draft->name); + if (draft->input.has_max_weight || session->session_type == TRAINLOG_SESSION_MAX_TEST) + trainlog_surface_printf(app->content, row++, 4, + "MAX explicite : %s%.2f kg", draft->input.has_max_weight ? "" : "à saisir · ", + draft->input.has_max_weight ? draft->input.max_weight_kg : 0.0); + else if (draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS) + { + trainlog_surface_printf(app->content, row++, 4, + "Activité continue : %d s · aucune série fictive", + draft->input.continuous_duration_seconds); + if ((draft->input.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U) + trainlog_surface_printf(app->content, row++, 4, "Vitesse : %s%.2f km/h", + draft->input.continuous_has_speed ? "" : "— ", + draft->input.continuous_has_speed + ? draft->input.continuous_speed_kmh : 0.0); + if ((draft->input.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U) + trainlog_surface_printf(app->content, row++, 4, "Distance : %s%.2f km", + draft->input.continuous_has_distance ? "" : "— ", + draft->input.continuous_has_distance + ? draft->input.continuous_distance_km : 0.0); + trainlog_surface_printf(app->content, row++, 4, + "e durée · s vitesse · k distance"); + } + else { + trainlog_surface_printf(app->content, row++, 2, + "Séries réalisées — cibles séparées : %d×%d, repos %d s", + draft->input.target_sets, draft->input.target_reps, + draft->input.rest_seconds); + for (index = 0U; index < draft->input.set_count && + row < app->layout.content.height; ++index) { + char metric[64]; + draft_format_set_metric(draft, &draft->sets[index], metric, + sizeof(metric)); + trainlog_surface_printf(app->content, row++, 4, + "%c %zu. %-16s %s%.2f kg", index == session->set_selected ? '>' : ' ', + index + 1U, metric, draft->sets[index].has_weight ? "" : "— ", + draft->sets[index].has_weight ? draft->sets[index].weight_kg : 0.0); + } + if (draft->input.set_count == 0U) + trainlog_surface_printf(app->content, row++, 4, + "Aucune série réelle. a pour saisir la première."); + } + if (session->form.active) { + trainlog_surface_printf(app->content, app->layout.content.height - 3, 2, + "> %s%s", session->form.text, + session->form.capacity_error ? " [200 octets maximum]" : ""); + } + } else if (session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG) { + trainlog_surface_printf(app->content, row++, 2, "Paramètres de la proposition"); + trainlog_surface_printf(app->content, row++, 4, "Zone : %s", + session->generation_zone != NULL ? session->generation_zone->display_name : "—"); + trainlog_surface_printf(app->content, row++, 4, "Objectif : %s", + session->generation_goal != NULL ? + generator_goal_label(session->generation_goal->id) : "—"); + trainlog_surface_printf(app->content, row++, 4, "Durée : %d min", + session->generation_duration_minutes); + trainlog_surface_printf(app->content, row + 1, 2, + "z zone · o objectif · t durée · g générer"); + } else if (session->phase == TRAINLOG_SESSION_GENERATOR_WARNING) { + TrainlogBodyZoneRecentExposure *exposure = &session->generated.exposure; + trainlog_surface_printf(app->content, row++, 2, "Zone travaillée récemment"); + trainlog_surface_printf(app->content, row++, 4, + "24 h : %zu séries principales, %zu secondaires (%zu séances)", + exposure->within_24h.primary_set_count, + exposure->within_24h.secondary_set_count, + exposure->within_24h.session_count); + trainlog_surface_printf(app->content, row++, 4, + "72 h : %zu séries principales, %zu secondaires (%zu séances)", + exposure->within_72h.primary_set_count, + exposure->within_72h.secondary_set_count, + exposure->within_72h.session_count); + trainlog_surface_printf(app->content, row + 1, 2, + "c/Entrée continuer · z changer de zone · Échap conserver et revenir"); + } else if (session->phase == TRAINLOG_SESSION_GENERATOR_PREVIEW) { + trainlog_surface_printf(app->content, row++, 2, + "%zu exercice(s) · cible %d min · estimation %d min", + session->generated.exercise_count, + session->generation_duration_minutes, + session->generated.estimated_duration_seconds / 60); + if (session->generation_duration_minutes * 60 - + session->generated.estimated_duration_seconds >= 300) + trainlog_surface_printf(app->content, row++, 2, + "Proposition plus courte : aucun remplissage artificiel."); + trainlog_surface_printf(app->content, row++, 2, + "Échauffement et retour au calme absents en V1."); + for (index = 0U; index < session->generated.exercise_count && + row + 1 < app->layout.content.height; ++index) { + TrainlogGeneratedExercise *item = &session->generated.exercises[index]; + trainlog_surface_printf(app->content, row++, 4, "%c %zu. %.38s", + index == session->selected ? '>' : ' ', index + 1U, + session->generated_items[index].exercise_name); + trainlog_surface_printf(app->content, row++, 7, + "%d×%d · repos %d s · %.30s", item->target_sets, + item->target_repetitions, item->rest_seconds, + session->generated_items[index].equipment_name); + if (index == session->selected && row < app->layout.content.height) { + if (item->has_target_weight) + trainlog_surface_printf(app->content, row++, 7, + "%.2f kg · %s", item->target_weight_kg, + item->rationale_count > 0U && strcmp(item->rationale_codes[0], + "user_selected_max_percentage") == 0 + ? "pourcentage MAX choisi" : "charge automatique observée"); + else trainlog_surface_printf(app->content, row++, 7, + item->rationale_count > 0U && strcmp(item->rationale_codes[0], + "compatible_max_unavailable") == 0 + ? "MAX compatible indisponible" + : "Aucune charge numérique qualifiée"); } } + } else if (session->phase == TRAINLOG_SESSION_CONFIRM_REMOVE) { + trainlog_surface_printf(app->content, row++, 2, + "Retirer cette occurrence de la séance ?"); + trainlog_surface_printf(app->content, row, 4, + "1 confirmer · 0/Échap annuler — le catalogue reste intact"); + } else if (session->phase == TRAINLOG_SESSION_CONFIRM_ABANDON) { + trainlog_surface_printf(app->content, row++, 2, + "Abandonner la séance en cours ?"); + trainlog_surface_printf(app->content, row, 4, + "1 confirmer · 0/Échap annuler"); + } else if (session->phase == TRAINLOG_SESSION_CONFIRM_LEAVE) { + trainlog_surface_printf(app->content, row++, 2, + "Proposition non acceptée conservée en mémoire."); + trainlog_surface_printf(app->content, row, 4, + "Entrée conserver et revenir · d Abandonner la proposition"); + } else if (session->phase == TRAINLOG_SESSION_MESSAGE) { + trainlog_surface_printf(app->content, row++, 2, "%s", session->message); + trainlog_surface_printf(app->content, row, 2, "Entrée pour continuer"); + } else { + trainlog_surface_printf(app->content, row++, 2, "Type : %s", + session_type_label(session->session_type)); + if (session->draft_count == 0U) + trainlog_surface_printf(app->content, row, 2, + "Aucune occurrence. a pour choisir un exercice."); + for (index = 0U; index < session->draft_count && + row < app->layout.content.height; ++index) { + char summary[128]; + draft_set_summary(&session->drafts[index], summary, sizeof(summary)); + trainlog_surface_printf(app->content, row++, 2, "%c %zu. %-28.28s %.38s", + index == session->selected ? '>' : ' ', index + 1U, + session->drafts[index].name, summary); + } + if (session->message[0] != '\0') + trainlog_surface_printf(app->content, app->layout.content.height - 2, + 2, "%s", session->message); + } +} + +static void app_shell_render_equipment_controller(TrainlogAppContext *app) +{ + TrainlogEquipmentController *controller = &app->equipment_controller; + const char *label; + int row = 4; + trainlog_surface_printf(app->content, row++, 2, + "Créer un équipement personnel"); + if (controller->phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) { + trainlog_surface_printf(app->content, row++, 2, + "Abandonner cette définition non enregistrée ?"); + trainlog_surface_printf(app->content, row, 4, + "1 abandonner · 0/Échap reprendre"); + return; + } + if (controller->phase == TRAINLOG_EQUIPMENT_MESSAGE) { + trainlog_surface_printf(app->content, row++, 2, "%s", controller->message); + trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir au catalogue"); + return; + } + trainlog_surface_printf(app->content, row++, 2, "Nom : %s", + controller->pending.display_name[0] != '\0' + ? controller->pending.display_name : "—"); + trainlog_surface_printf(app->content, row++, 2, "Étiquette : %s", + controller->pending.label_name[0] != '\0' + ? controller->pending.label_name : "—"); + trainlog_surface_printf(app->content, row++, 2, "Type : %s", + controller->pending.equipment_type[0] != '\0' + ? controller->pending.equipment_type : "—"); + trainlog_surface_printf(app->content, row++, 2, + "Charge : %s", controller->load_choice == 1 ? "aucune" : + controller->load_choice == 2 ? "externe" : "assistance"); + row += 1; + if (controller->phase == TRAINLOG_EQUIPMENT_LOAD) { + trainlog_surface_printf(app->content, row++, 2, + "↑↓ ou 1–3 choisir · Entrée enregistrer"); + } else { + label = controller->phase == TRAINLOG_EQUIPMENT_NAME ? "Nom convivial" : + controller->phase == TRAINLOG_EQUIPMENT_LABEL + ? "Nom d’étiquette (optionnel)" : "Type"; + trainlog_surface_printf(app->content, row++, 2, "%s", label); + trainlog_surface_printf(app->content, row++, 4, "> %s%s", + controller->form.text, + controller->form.capacity_error ? " [200 octets maximum]" : ""); + } + if (controller->message[0] != '\0') + trainlog_surface_printf(app->content, row + 1, 2, "%s", + controller->message); +} + +static void app_shell_render_exercise_controller(TrainlogAppContext *app) +{ + TrainlogExerciseController *controller = &app->exercise_controller; + int row = 4; + trainlog_surface_printf(app->content, row++, 2, "%s un exercice", + controller->editing ? "Modifier" : "Créer"); + if (controller->phase == TRAINLOG_EXERCISE_CONFIRM_DISCARD) { + trainlog_surface_printf(app->content, row++, 2, + "Abandonner les modifications non enregistrées ?"); + trainlog_surface_printf(app->content, row, 4, + "1 abandonner · 0/Échap reprendre"); + return; + } + if (controller->phase == TRAINLOG_EXERCISE_MESSAGE) { + trainlog_surface_printf(app->content, row++, 2, "%s", controller->message); + trainlog_surface_printf(app->content, row, 2, "Entrée pour revenir"); + return; + } + trainlog_surface_printf(app->content, row++, 2, "Nom : %s", + controller->pending.name[0] != '\0' ? controller->pending.name : "—"); + trainlog_surface_printf(app->content, row++, 2, "Suivi : %s", + controller->pending.tracking_mode == TRAINLOG_TRACKING_REPS + ? "répétitions" : "durée"); + trainlog_surface_printf(app->content, row++, 2, "Organisation : %s", + controller->pending.recording_mode == TRAINLOG_RECORDING_CONTINUOUS + ? "continue" : "séries"); + if (controller->editing) + trainlog_surface_printf(app->content, row++, 2, + "Identifiant stable conservé : %s", + controller->pending.exercise_id); + row += 1; + if (controller->phase == TRAINLOG_EXERCISE_NAME) { + trainlog_surface_printf(app->content, row++, 2, "Nom d’affichage"); + trainlog_surface_printf(app->content, row++, 4, "> %s%s", + controller->form.text, + controller->form.capacity_error ? " [200 octets maximum]" : ""); + } else if (controller->phase == TRAINLOG_EXERCISE_TRACKING) + trainlog_surface_printf(app->content, row++, 2, + "Suivi : 1 répétitions · 2 durée · ↑↓ choisir · Entrée continuer"); + else if (controller->phase == TRAINLOG_EXERCISE_RECORDING) + trainlog_surface_printf(app->content, row++, 2, + "Organisation : 1 séries · 2 continu · ↑↓ choisir · Entrée continuer"); + else if (controller->phase == TRAINLOG_EXERCISE_SPEED) + trainlog_surface_printf(app->content, row++, 2, + "Mesurer la vitesse : %s · 0 non · 1 oui · ↑↓ choisir", + (controller->pending.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) + != 0U ? "oui" : "non"); + else if (controller->phase == TRAINLOG_EXERCISE_DISTANCE) + trainlog_surface_printf(app->content, row++, 2, + "Mesurer la distance : %s · 0 non · 1 oui · ↑↓ choisir", + (controller->pending.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) + != 0U ? "oui" : "non"); + else if (controller->phase == TRAINLOG_EXERCISE_ZONES) { + size_t index; + const TrainlogBodyZone *primary = trainlog_body_zone_catalog_lookup( + controller->primary_zone); + trainlog_surface_printf(app->content, row++, 2, + "Zone principale : %s · secondaires : %zu", + primary != NULL ? primary->display_name : "Non renseignée", + controller->secondary_count); + for (index = controller->zone_selected; + index < trainlog_body_zone_catalog_count() && + row < app->layout.content.height - 2; ++index) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index); + trainlog_surface_printf(app->content, row++, 4, "%c %s%s", + index == controller->zone_selected ? '>' : ' ', + zone != NULL ? zone->display_name : "?", + zone != NULL && zone->is_group ? " [groupe]" : ""); + } + trainlog_surface_printf(app->content, + app->layout.content.height - 2, 2, + "p principale · Espace secondaire · n effacer · Entrée enregistrer"); + } + if (controller->message[0] != '\0') + trainlog_surface_printf(app->content, + app->layout.content.height - 3, 2, "%s", controller->message); +} + +static int app_shell_graph_height(const TrainlogAppContext *app, int top) +{ + int available = app->layout.content.height - top - 2; + if (available > 7) return 7; + return available; +} + +static void app_shell_draw_performance_graph( + TrainlogAppContext *app, + TrainlogLoadMode mode, + TrainlogTrackingMode tracking_mode, + int top, + int height, + bool measured_max) +{ + size_t indices[EXERCISE_GRAPH_POINTS]; + size_t selected_count = 0U; + size_t index; + double minimum; + double maximum; + const int left = 11; + int width = app->layout.content.width - left - 2; + + /* CONTRACT: graph coordinates are relative to the bounded content plane. + * Header/footer/sidebar ownership is therefore preserved at every valid + * resize, including the minimum 72x20 viewport. */ + if (height < 3 || width < 12 || top < 1 || + top + height >= app->layout.content.height) return; + for (index = 0U; index < app->performance_count && + selected_count < EXERCISE_GRAPH_POINTS; ++index) { + const TrainlogExercisePerformancePoint *point = + &app->performance_points[index]; + if (point->has_performance != 0 && point->load_mode == mode && + (!measured_max || point->session_type == TRAINLOG_SESSION_MAX_TEST)) + indices[selected_count++] = index; + } + if (selected_count == 0U) { + trainlog_surface_printf(app->content, top, 2, + "Aucune performance réussie pour ce mode."); + return; + } + minimum = exercise_graph_value(&app->performance_points[indices[0]]); + maximum = minimum; + for (index = 1U; index < selected_count; ++index) { + double value = exercise_graph_value( + &app->performance_points[indices[index]]); + if (value < minimum) minimum = value; + if (value > maximum) maximum = value; + } + if (maximum == minimum) { minimum -= 1.0; maximum += 1.0; } + trainlog_surface_set_role(app->content, + mode == TRAINLOG_LOAD_ASSISTANCE ? TRAINLOG_COLOR_WARNING + : TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, top - 1, left, "%s", + mode == TRAINLOG_LOAD_ASSISTANCE + ? (measured_max ? "Assistance mesurée (kg) — moins = mieux" + : "Assistance (kg) — moins = mieux") + : mode == TRAINLOG_LOAD_EXTERNAL + ? (measured_max ? "MAX mesuré (kg)" : "Charge du meilleur set (kg)") + : tracking_mode == TRAINLOG_TRACKING_DURATION + ? (measured_max ? "Durée MAX mesurée" : "Meilleure durée") + : (measured_max ? "Répétitions MAX mesurées" : "Meilleures répétitions")); + trainlog_surface_printf(app->content, top, 2, "%.1f", maximum); + trainlog_surface_printf(app->content, top + height - 1, 2, "%.1f", minimum); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + { + int previous_x = -1; + int previous_y = -1; + size_t order; + for (order = selected_count; order > 0U; --order) { + size_t chronological = selected_count - order; + const TrainlogExercisePerformancePoint *point = + &app->performance_points[indices[order - 1U]]; + double ratio = (exercise_graph_value(point) - minimum) / + (maximum - minimum); + int x = selected_count == 1U ? left + width / 2 : left + + (int)((chronological * (size_t)(width - 1)) / + (selected_count - 1U)); + int y = top + height - 1 - + (int)(ratio * (double)(height - 1)); + if (previous_x >= 0 && x > previous_x) { + int line_x; + for (line_x = previous_x + 1; line_x < x; ++line_x) { + int line_y = previous_y + ((y - previous_y) * + (line_x - previous_x)) / (x - previous_x); + trainlog_surface_draw(app->content, line_y, line_x, '.'); + } + } + trainlog_surface_draw(app->content, y, x, + chronological + 1U == selected_count ? 'O' : '*'); + previous_x = x; + previous_y = y; + } + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + { + char oldest[11]; + char newest[11]; + exercise_short_date(app->performance_points[ + indices[selected_count - 1U]].started_at, oldest); + exercise_short_date(app->performance_points[indices[0]].started_at, newest); + trainlog_surface_printf(app->content, top + height, left, "%s", oldest); + trainlog_surface_printf(app->content, top + height, + left + width - 10, "%s", newest); + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); +} + +static void app_shell_draw_body_points(TrainlogAppContext *app, + const TrainlogBodyMetricPoint *points, size_t count, int top, int height, + const char *unit) +{ + size_t start; + size_t index; + double minimum; + double maximum; + int left = 11; + int width = app->layout.content.width - left - 2; + if (count == 0U) { + trainlog_surface_printf(app->content, top, 2, + "Aucune donnée pour cette mesure."); + return; + } + if (height < 3 || width < 10 || top + height >= app->layout.content.height) + return; + start = count > (size_t)width ? count - (size_t)width : 0U; + minimum = points[start].value; + maximum = minimum; + for (index = start + 1U; index < count; ++index) { + if (points[index].value < minimum) minimum = points[index].value; + if (points[index].value > maximum) maximum = points[index].value; + } + if (maximum == minimum) { minimum -= 1.0; maximum += 1.0; } + trainlog_surface_printf(app->content, top, 2, "%.1f", maximum); + trainlog_surface_printf(app->content, top + height - 1, 2, "%.1f", minimum); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_GRAPH, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + for (index = start; index < count; ++index) { + double ratio = (points[index].value - minimum) / (maximum - minimum); + int x = left + (int)(index - start); + int y = top + height - 1 - (int)(ratio * (double)(height - 1)); + trainlog_surface_draw(app->content, y, x, '*'); + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, top + height, left, + "%zu point(s) · %s", count, unit); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); +} + +static void app_shell_render_body_controller(TrainlogAppContext *app) +{ + TrainlogBodyController *controller = &app->body_controller; + const char *label = controller->field < 14U + ? BODY_METRICS[controller->field].label : "Mesure"; + trainlog_surface_printf(app->content, 4, 2, "%s — champ %zu/14", + controller->editing ? "Modifier le relevé" : "Nouveau relevé", + controller->field + 1U); + if (controller->phase == TRAINLOG_BODY_CONFIRM_DISCARD) { + trainlog_surface_printf(app->content, 7, 2, + "Abandonner toutes les modifications ?"); + trainlog_surface_printf(app->content, 9, 2, + "1 abandonner · 0/Échap reprendre"); + } else if (controller->phase == TRAINLOG_BODY_MESSAGE) { + trainlog_surface_printf(app->content, 7, 2, "%s", controller->message); + trainlog_surface_printf(app->content, 9, 2, "Entrée pour revenir"); + } else { + trainlog_surface_printf(app->content, 7, 2, "%s (%s)", label, + BODY_METRICS[controller->field].unit); + trainlog_surface_printf(app->content, 9, 2, "> %s", + controller->form.text); + trainlog_surface_printf(app->content, 11, 2, "%s", + controller->editing + ? "vide conserve · - efface · valeur positive remplace" + : "vide signifie mesure non faite · valeur positive ajoute"); + if (controller->message[0] != '\0') + trainlog_surface_printf(app->content, 13, 2, "%s", + controller->message); + } +} + +static void app_shell_render_profile_controller(TrainlogAppContext *app) +{ + TrainlogProfileController *controller = &app->profile_controller; + trainlog_surface_printf(app->content, 4, 2, + "Profil d’estimation corporelle"); + if (controller->phase == TRAINLOG_PROFILE_CONFIRM_DISCARD) { + trainlog_surface_printf(app->content, 7, 2, + "Abandonner les modifications du profil ?"); + trainlog_surface_printf(app->content, 9, 2, + "1 abandonner · 0/Échap reprendre"); + } else if (controller->phase == TRAINLOG_PROFILE_MESSAGE) { + trainlog_surface_printf(app->content, 7, 2, "%s", controller->message); + } else { + trainlog_surface_printf(app->content, 7, 2, "%s", + controller->phase == TRAINLOG_PROFILE_FORMULA + ? "Formule : 1 homme · 2 femme" + : "Taille en cm : 100 à 250"); + trainlog_surface_printf(app->content, 9, 2, "> %s", + controller->form.text); + if (controller->message[0] != '\0') + trainlog_surface_printf(app->content, 11, 2, "%s", + controller->message); + } +} + +static void app_shell_render_body_detail(TrainlogAppContext *app) +{ + const TrainlogBodyObservationRecord *record = &app->body_detail; + const bool present[14] = {record->has_body_weight, record->has_neck, + record->has_shoulders, record->has_chest, record->has_waist, + record->has_hips, record->has_left_arm, record->has_right_arm, + record->has_left_forearm, record->has_right_forearm, + record->has_left_thigh, record->has_right_thigh, + record->has_left_calf, record->has_right_calf}; + const double values[14] = {record->body_weight_kg, record->neck_cm, + record->shoulders_cm, record->chest_cm, record->waist_cm, + record->hips_cm, record->left_arm_cm, record->right_arm_cm, + record->left_forearm_cm, record->right_forearm_cm, + record->left_thigh_cm, record->right_thigh_cm, + record->left_calf_cm, record->right_calf_cm}; + size_t index; + int row = 4; + trainlog_surface_printf(app->content, row++, 2, "Date : %s", + record->observed_at); + trainlog_surface_printf(app->content, row++, 2, "Séance liée : %s", + record->session_id[0] != '\0' ? record->session_id : "aucune"); + for (index = app->content_scroll; index < 14U && + row < app->layout.content.height - 1; ++index) { + if (present[index]) + trainlog_surface_printf(app->content, row++, 2, "%-22s %.2f %s", + BODY_METRICS[index].label, values[index], BODY_METRICS[index].unit); + else trainlog_surface_printf(app->content, row++, 2, "%-22s —", + BODY_METRICS[index].label); + } +} + +static void app_shell_surface_segment(TrainlogSurface *surface, int x1, int y1, + int x2, int y2, uint32_t symbol) +{ + int x; + if (surface == NULL || x2 < x1) return; + if (x1 == x2) { trainlog_surface_draw(surface, y2, x2, symbol); return; } + for (x = x1; x <= x2; ++x) { + int y = y1 + ((y2 - y1) * (x - x1)) / (x2 - x1); + trainlog_surface_draw(surface, y, x, symbol); + } +} + +static void app_shell_render_body_global(TrainlogAppContext *app) +{ + size_t metric; + double minimum = 100.0; + double maximum = 100.0; + int graph_top = 4; + int graph_height = app->layout.content.height >= 20 ? 7 : 5; + int left = 8; + int width = app->layout.content.width - left - 3; + for (metric = 0U; metric < 14U; ++metric) { + TrainlogGlobalBodySeries *series = &app->body_global_series[metric]; + size_t point; + if (!app->body_global_enabled[metric]) continue; + for (point = 0U; point < series->count; ++point) { + double value; + if (trainlog_body_index100(series->baseline, + series->points[point].value, &value) != TRAINLOG_STATUS_OK) + continue; + if (value < minimum) minimum = value; + if (value > maximum) maximum = value; + } + } + if (maximum == minimum) { minimum = 99.0; maximum = 101.0; } + if (width >= 10 && app->body_global_date_count > 0U) { + trainlog_surface_printf(app->content, graph_top, 2, "%.1f", maximum); + trainlog_surface_printf(app->content, graph_top + graph_height - 1, 2, + "%.1f", minimum); + for (metric = 0U; metric < 14U; ++metric) { + TrainlogGlobalBodySeries *series = &app->body_global_series[metric]; + size_t point; + int previous_x = -1; + int previous_y = -1; + if (!app->body_global_enabled[metric]) continue; + trainlog_surface_set_role(app->content, series->role, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + for (point = 0U; point < series->count; ++point) { + double value; + size_t date; + int x; + int y; + if (trainlog_body_index100(series->baseline, + series->points[point].value, + &value) != TRAINLOG_STATUS_OK) continue; + date = global_date_index(app->body_global_dates, + app->body_global_date_count, + series->points[point].observed_at); + x = app->body_global_date_count == 1U ? left + width / 2 + : left + (int)((date * (size_t)(width - 1)) / + (app->body_global_date_count - 1U)); + y = normalized_graph_row(value, minimum, maximum, + graph_top, graph_height); + if (previous_x >= 0) + app_shell_surface_segment(app->content, previous_x, + previous_y, x, y, + (uint32_t)(unsigned char)series->symbol); + else trainlog_surface_draw(app->content, y, x, + (uint32_t)(unsigned char)series->symbol); + previous_x = x; + previous_y = y; + } + } + } else trainlog_surface_printf(app->content, graph_top, 2, + "Aucune série corporelle disponible."); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + for (metric = 0U; metric < 14U && + graph_top + graph_height + 2 + (int)(metric % 7U) < + app->layout.content.height; ++metric) { + int row = graph_top + graph_height + 2 + (int)(metric % 7U); + int column = metric < 7U ? 2 : app->layout.content.width / 2; + trainlog_surface_printf(app->content, row, column, "%c [%c] %-15s %+.1f%%", + app->body_global_selected == metric ? '>' : ' ', + app->body_global_enabled[metric] ? 'x' : ' ', + BODY_METRICS[metric].label, + app->body_global_series[metric].latest_percent); + } + if (app->body_global_partial) + trainlog_surface_printf(app->content, app->layout.content.height - 1, 2, + "Limite locale atteinte : certaines séries peuvent être partielles."); +} + +static void app_shell_render_body_trends(TrainlogAppContext *app) +{ + TrainlogDashboardMonthValue monthly[14][DASHBOARD_MONTH_COUNT]; + TrainlogDashboardMonth months[DASHBOARD_MONTH_COUNT]; + double baseline[14] = {0.0}; + double minimum = 0.0; + double maximum = 0.0; + long current_key; + long first_key; + size_t metric; + size_t plotted = 0U; + int top = 5; + int height = app_shell_graph_height(app, top); + int left = 8; + int width = app->layout.content.width - left - 3; + (void)memset(monthly, 0, sizeof(monthly)); + trainlog_surface_printf(app->content, 3, 2, + "Évolution corporelle normalisée — 12 mois"); + if (!dashboard_current_month_key(¤t_key)) { + trainlog_surface_printf(app->content, top, 2, + "Impossible de déterminer le mois courant."); + return; + } + first_key = current_key - (long)(DASHBOARD_MONTH_COUNT - 1U); + for (metric = 0U; metric < DASHBOARD_MONTH_COUNT; ++metric) + dashboard_month_from_key(first_key + (long)metric, &months[metric]); + for (metric = 0U; metric < 14U; ++metric) { + TrainlogGlobalBodySeries *series = &app->body_global_series[metric]; + size_t point; + size_t first = 0U; + size_t visible = 0U; + bool have_first = false; + for (point = 0U; point < series->count; ++point) { + int year; + int month; + long offset; + if (!dashboard_parse_year_month(series->points[point].observed_at, + &year, &month)) continue; + offset = dashboard_month_key(year, month) - first_key; + if (offset < 0L || offset >= (long)DASHBOARD_MONTH_COUNT) continue; + monthly[metric][(size_t)offset].present = true; + monthly[metric][(size_t)offset].value = series->points[point].value; + } + for (point = 0U; point < DASHBOARD_MONTH_COUNT; ++point) + if (monthly[metric][point].present) { + if (!have_first) { first = point; have_first = true; } + ++visible; + } + if (visible < 2U) continue; + baseline[metric] = monthly[metric][first].value; + ++plotted; + for (point = 0U; point < DASHBOARD_MONTH_COUNT; ++point) { + double percent; + if (monthly[metric][point].present && + trainlog_body_percent_change(baseline[metric], + monthly[metric][point].value, + &percent) == TRAINLOG_STATUS_OK) { + if (percent < minimum) minimum = percent; + if (percent > maximum) maximum = percent; + } + } + } + if (plotted == 0U || height < 3 || width < 12) { + trainlog_surface_printf(app->content, top, 2, + "Premières courbes après 2 mois relevés pour une même mesure."); + return; + } + if (minimum == maximum) { minimum = -1.0; maximum = 1.0; } + trainlog_surface_printf(app->content, top, 2, "%+.1f%%", maximum); + trainlog_surface_printf(app->content, top + height - 1, 2, "%+.1f%%", minimum); + for (metric = 0U; metric < 14U; ++metric) { + size_t month; + int previous_x = -1; + int previous_y = -1; + long previous_month = -2L; + if (baseline[metric] <= 0.0) continue; + trainlog_surface_set_role(app->content, GLOBAL_BODY_ROLES[metric], + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + for (month = 0U; month < DASHBOARD_MONTH_COUNT; ++month) { + double percent; + int x; + int y; + if (!monthly[metric][month].present) { + previous_x = -1; previous_y = -1; previous_month = -2L; + continue; + } + if (trainlog_body_percent_change(baseline[metric], + monthly[metric][month].value, + &percent) != TRAINLOG_STATUS_OK) continue; + x = left + (int)((month * (size_t)(width - 1)) / + (DASHBOARD_MONTH_COUNT - 1U)); + y = normalized_graph_row(percent, minimum, maximum, top, height); + if (previous_x >= 0 && previous_month + 1L == (long)month) + app_shell_surface_segment(app->content, previous_x, previous_y, + x, y, (uint32_t)(unsigned char)GLOBAL_BODY_SYMBOLS[metric]); + else trainlog_surface_draw(app->content, y, x, + (uint32_t)(unsigned char)GLOBAL_BODY_SYMBOLS[metric]); + previous_x = x; previous_y = y; previous_month = (long)month; + } + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + if (top + height < app->layout.content.height) + trainlog_surface_printf(app->content, top + height, left, + "%02d/%02d … %02d/%02d", months[0].month, + months[0].year % 100, months[11].month, months[11].year % 100); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + if (app->body_global_partial) + trainlog_surface_printf(app->content, app->layout.content.height - 1, 2, + "Limite locale atteinte : vue 12 mois potentiellement partielle."); +} + +static void app_shell_render_body_analytics(TrainlogAppContext *app) +{ + const TrainlogBodyObservationRecord *latest = app->loaded_count > 0U + ? &app->body_records[0] : NULL; + TrainlogBodyAnalyticsResult current; + TrainlogBodyAnalyticsResult oldest_estimate; + const TrainlogBodyObservationRecord *oldest_weight = + body_analytics_oldest_weight(app->body_records, app->loaded_count); + const TrainlogBodyObservationRecord *oldest_waist = + body_analytics_oldest_waist(app->body_records, app->loaded_count); + bool has_current = latest != NULL && trainlog_body_analytics_calculate( + app->body_has_profile ? &app->body_profile : NULL, latest, + ¤t) == TRAINLOG_STATUS_OK; + bool has_oldest_estimate = app->body_has_profile && + body_analytics_oldest_estimate(&app->body_profile, app->body_records, + app->loaded_count, &oldest_estimate); + int row = 4; + if (latest == NULL) { + trainlog_surface_printf(app->content, row, 2, "Aucun relevé corporel."); + return; + } + trainlog_surface_printf(app->content, row++, 2, "Page %zu/2 · relevé %s", + app->content_selected + 1U, latest->observed_at); + trainlog_surface_printf(app->content, row++, 2, "Profil : %s", + app->body_has_profile + ? (app->body_profile.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE + ? "formule femme" : "formule homme") + : "non configuré"); + if (app->body_has_profile) + trainlog_surface_printf(app->content, row++, 2, "Taille : %.1f cm", + app->body_profile.height_cm); +#define BODY_ANALYTIC(label_, has_, value_, suffix_) \ + trainlog_surface_printf(app->content, row++, 2, "%-27s %s%.2f %s", \ + label_, (has_) ? "" : "— ", (has_) ? (value_) : 0.0, suffix_) + row += 1; + if (app->content_selected == 0U) { + BODY_ANALYTIC("Graisse estimée", has_current && current.has_body_fat_estimate, + current.body_fat_percent, "%"); + BODY_ANALYTIC("Masse grasse estimée", has_current && current.has_fat_mass_estimate, + current.fat_mass_kg, "kg"); + BODY_ANALYTIC("Masse maigre estimée", has_current && current.has_lean_mass_estimate, + current.lean_mass_kg, "kg"); + if (latest->has_body_weight) + trainlog_surface_printf(app->content, row++, 2, + "Poids %.1f kg · variation %+.1f kg", latest->body_weight_kg, + oldest_weight != NULL ? latest->body_weight_kg - + oldest_weight->body_weight_kg : 0.0); + if (latest->has_waist) + trainlog_surface_printf(app->content, row++, 2, + "Tour de taille %.1f cm · variation %+.1f cm", latest->waist_cm, + oldest_waist != NULL ? latest->waist_cm - + oldest_waist->waist_cm : 0.0); + if (has_current && current.has_body_fat_estimate && has_oldest_estimate) + trainlog_surface_printf(app->content, row++, 2, + "Variation graisse estimée %+.2f point(s)", + current.body_fat_percent - oldest_estimate.body_fat_percent); + trainlog_surface_printf(app->content, row + 1, 2, + "Estimation anthropométrique : tendance, pas mesure directe."); + } else { + BODY_ANALYTIC("Taille / hanches", has_current && current.has_waist_hip_ratio, + current.waist_hip_ratio, ""); + BODY_ANALYTIC("Épaules / taille", has_current && current.has_shoulder_waist_ratio, + current.shoulder_waist_ratio, ""); + BODY_ANALYTIC("Poitrine / taille", has_current && current.has_chest_waist_ratio, + current.chest_waist_ratio, ""); + BODY_ANALYTIC("Asymétrie bras", has_current && current.has_arm_asymmetry, + current.arm_asymmetry_percent, "%"); + BODY_ANALYTIC("Asymétrie avant-bras", has_current && current.has_forearm_asymmetry, + current.forearm_asymmetry_percent, "%"); + BODY_ANALYTIC("Asymétrie cuisses", has_current && current.has_thigh_asymmetry, + current.thigh_asymmetry_percent, "%"); + BODY_ANALYTIC("Asymétrie mollets", has_current && current.has_calf_asymmetry, + current.calf_asymmetry_percent, "%"); + } +#undef BODY_ANALYTIC +} + +static void app_shell_render_sync(TrainlogAppContext *app) +{ + TrainlogSyncController *sync = &app->sync_controller; + size_t index; + int row = 4; + if (sync->showing_detail) { + trainlog_surface_printf(app->content, row++, 2, "Détail %s", + sync->history[sync->selected].sync_id); + for (index = sync->detail_scroll; index < sync->detail_line_count && + row < app->layout.content.height; ++index) + trainlog_surface_printf(app->content, row++, 2, "%.*s", + app->layout.content.width - 4, sync->detail_lines[index]); + return; + } + if (!sync->probe_known) + trainlog_surface_printf(app->content, row++, 2, + "État appareil non actualisé · r pour rechercher."); + else if (sync->probe_status == TRAINLOG_STATUS_OK && sync->device.connected) { + trainlog_surface_printf(app->content, row++, 2, "Appareil : %s %s", + sync->device.device.vendor, sync->device.device.model); + trainlog_surface_printf(app->content, row++, 2, + "Stockage : %.2f GiB libres / %.2f GiB", + sync_bytes_to_gib(sync->device.storage.free_space_bytes), + sync_bytes_to_gib(sync->device.storage.max_capacity_bytes)); + } else trainlog_surface_printf(app->content, row++, 2, + sync->probe_status == TRAINLOG_STATUS_CONFLICT + ? "Service de synchronisation occupé." + : "Aucun appareil MTP Trainlog détecté."); + if (sync->running) { + trainlog_surface_printf(app->content, row + 1, 2, + "Synchronisation en cours… PC↔Android"); + trainlog_surface_printf(app->content, row + 3, 2, + "Une seule exécution peut être active."); + return; + } + if (sync->action.confirming) { + trainlog_surface_printf(app->content, row + 1, 2, "%s", + trainlog_sync_screen_confirmation(sync->action.direction)); + trainlog_surface_printf(app->content, row + 3, 2, + "Entrée confirme une exécution · Échap annule"); + return; + } + if (sync->has_report) { + trainlog_surface_printf(app->content, row++, 2, "%s", + sync->report.success ? sync->report.summary : + sync->report.error[0] != '\0' ? sync->report.error + : "Synchronisation échouée."); + } + row += 1; + trainlog_surface_printf(app->content, row++, 2, + "Historique des synchronisations"); + if (sync->history_count == 0U) + trainlog_surface_printf(app->content, row, 2, + "Aucune synchronisation enregistrée."); + for (index = 0U; index < sync->history_count && + row < app->layout.content.height; ++index) { + const TrainlogSyncHistoryEntry *entry = &sync->history[index]; + /* CONTRACT: legacy journal directions remain parseable internal + * metadata, but the TUI exposes one PC↔Android sync operation. Old + * directional summaries used the same forbidden mode labels. */ + const char *summary = entry->direction_known && + entry->direction != TRAINLOG_SYNC_BIDIRECTIONAL + ? "entrée historique" : entry->summary; + trainlog_surface_printf(app->content, row++, 2, "%c %-16s %c %-17.17s %.*s", + index == sync->selected ? '>' : ' ', entry->timestamp, + entry->success ? '+' : '!', + "PC↔Android", + app->layout.content.width > 45 ? app->layout.content.width - 45 : 8, + summary); + } +} + +static void app_shell_render_content(TrainlogAppContext *app) +{ + TrainlogAppRoute route = app->navigation.current.route; + int width = app->layout.content.width; + /* CONTRACT: renderers consume controller-owned immutable snapshots and + * write coordinates relative to this persistent content plane. SQL, + * device discovery and sync execution stay in explicit controller actions. */ + trainlog_surface_erase(app->content); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_ACCENT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD); + trainlog_surface_printf(app->content, 1, 2, "%s", trainlog_route_title(route)); + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + if (app->body_controller.phase != TRAINLOG_BODY_IDLE) { + app_shell_render_body_controller(app); + } else if (app->profile_controller.phase != TRAINLOG_PROFILE_IDLE) { + app_shell_render_profile_controller(app); + } else if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) { + app_shell_render_exercise_controller(app); + } else if (app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE) { + app_shell_render_equipment_controller(app); + } else if (route == TRAINLOG_ROUTE_SESSION_MANUAL || + route == TRAINLOG_ROUTE_SESSION_CURRENT || + route == TRAINLOG_ROUTE_SESSION_GENERATOR) { + app_shell_render_session_controller(app); + } else if (route == TRAINLOG_ROUTE_HOME) { + trainlog_surface_printf(app->content, 4, 2, "Votre entraînement, au même endroit."); + trainlog_surface_printf(app->content, 7, 2, "1 Reprendre ou commencer une séance"); + trainlog_surface_printf(app->content, 9, 2, "g Programmer une séance"); + trainlog_surface_printf(app->content, 11, 2, "5 Ajouter ou consulter des mensurations"); + } else if (route == TRAINLOG_ROUTE_SESSIONS) { + static const char *const rows[] = {"Séance en cours", "Programmer une séance", + "Nouvelle séance manuelle", "Séances effectuées"}; + size_t index; + for (index = 0U; index < 4U; ++index) { + trainlog_surface_set_role(app->content, + app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, + app->content_selected == index ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, 4 + (int)index * 2, 2, + "%s %s", app->content_selected == index ? "▶ [SÉLECTION]" : " ", rows[index]); + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + } else if (route == TRAINLOG_ROUTE_STATS) { + static const char *const rows[] = {"Par exercice", "Mensurations", "Capacités / MAX"}; + size_t index; + for (index = 0U; index < 3U; ++index) { + trainlog_surface_set_role(app->content, + app->content_selected == index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, + app->content_selected == index ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, 4 + (int)index * 2, 2, + "%s %s", app->content_selected == index ? "▶ [SÉLECTION]" : " ", rows[index]); + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + } else if (route == TRAINLOG_ROUTE_SESSION_DETAIL) { + int row = 3; + if (app->session_detail_error) trainlog_surface_printf(app->content, row, 2, + "Impossible de charger la séance."); + else { + TrainlogPersistedExerciseDetail *entry = app->session_entry_count > 0U + ? &app->session_entries[app->session_entry_selected] : NULL; + trainlog_surface_printf(app->content, row++, 2, "Début : %s", + app->session_detail.started_at); + trainlog_surface_printf(app->content, row++, 2, "Fin : %s", + app->session_detail.ended_at[0] != '\0' + ? app->session_detail.ended_at : "séance ouverte"); + trainlog_surface_printf(app->content, row++, 2, "Type : %s", + session_type_label(app->session_detail.session_type)); + row += 1; + if (entry == NULL) trainlog_surface_printf(app->content, row, 2, + "Aucun exercice dans cette séance."); + else { + char duration[64] = ""; + trainlog_surface_printf(app->content, row++, 2, + "Exercice %zu/%zu — %s", app->session_entry_selected + 1U, + app->session_entry_count, entry->name); + trainlog_surface_printf(app->content, row++, 2, + "Occurrence : %s", entry->entry_id); + if (entry->equipment_id[0] != '\0') + trainlog_surface_printf(app->content, row++, 2, + "Équipement : %s (i pour la fiche)", entry->equipment_id); + if (entry->has_max_weight != 0) + trainlog_surface_printf(app->content, row++, 2, + "MAX réellement mesuré : %.2f kg", entry->max_weight_kg); + else if (entry->recording_mode == TRAINLOG_RECORDING_CONTINUOUS) { + (void)trainlog_duration_format(entry->continuous_duration_seconds, + duration, sizeof(duration)); + trainlog_surface_printf(app->content, row++, 2, + "Réalisé : activité continue · %s", duration); + if (entry->has_continuous_speed != 0) + trainlog_surface_printf(app->content, row++, 2, + "Vitesse : %.1f km/h", entry->continuous_speed_kmh); + if (entry->has_continuous_distance != 0) + trainlog_surface_printf(app->content, row++, 2, + "Distance : %.2f km", entry->continuous_distance_km); + } else { + size_t index; + trainlog_surface_printf(app->content, row++, 2, + "Plan : %d série(s) · repos %d s%s", entry->target_sets, + entry->rest_seconds, entry->has_target_weight != 0 + ? " · charge/assistance cible définie" : ""); + trainlog_surface_printf(app->content, row++, 2, + "Réalisé : %zu série(s)", entry->actual_set_count); + for (index = app->session_set_scroll; + index < entry->actual_set_count && row < app->layout.content.height; + ++index) { + char metric[64]; char weight[48]; + if (entry->tracking_mode == TRAINLOG_TRACKING_DURATION) + (void)trainlog_duration_format(entry->actual_sets[index].duration_seconds, + metric, sizeof(metric)); + else (void)snprintf(metric, sizeof(metric), "%d reps", + entry->actual_sets[index].reps); + if (entry->actual_sets[index].has_weight) + (void)snprintf(weight, sizeof(weight), "%.2f kg", + entry->actual_sets[index].weight_kg); + else (void)snprintf(weight, sizeof(weight), "—"); + trainlog_surface_printf(app->content, row++, 4, + "%zu. %-18s %s", index + 1U, metric, weight); + } + } + } + } + } else if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) { + const TrainlogResolvedEquipment *item = &app->equipment_detail; + trainlog_surface_printf(app->content, 4, 2, "%s", item->display_name); + trainlog_surface_printf(app->content, 6, 2, "Identifiant : %s", item->equipment_id); + trainlog_surface_printf(app->content, 7, 2, "Étiquette : %s", + item->label_name[0] != '\0' ? item->label_name : "—"); + trainlog_surface_printf(app->content, 8, 2, "Type : %s", + item->equipment_type[0] != '\0' ? item->equipment_type : "—"); + trainlog_surface_printf(app->content, 9, 2, "Charge : %s", + item->load_semantics[0] != '\0' ? item->load_semantics : "—"); + trainlog_surface_printf(app->content, 10, 2, "Origine : %s", + equipment_origin_label(item->origin)); + } else if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL) { + const TrainlogExercise *item = &app->exercise_detail; + const TrainlogBodyZone *primary = NULL; + const TrainlogBodyZone *group = NULL; + char secondary[256] = ""; + size_t index; + int row = 4; + for (index = 0U; index < app->exercise_zone_count; ++index) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup( + app->exercise_zones[index].zone_id); + if (app->exercise_zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY) + primary = zone; + else if (zone != NULL) { + size_t used = strlen(secondary); + if (used < sizeof(secondary) - 1U) + (void)snprintf(secondary + used, sizeof(secondary) - used, + "%s%s", used > 0U ? ", " : "", zone->display_name); + } + } + if (primary != NULL && primary->parent_zone_id != NULL) + group = trainlog_body_zone_catalog_lookup(primary->parent_zone_id); + trainlog_surface_printf(app->content, row++, 2, "%s", item->name); + trainlog_surface_printf(app->content, row++, 2, "Identifiant : %s", item->exercise_id); + trainlog_surface_printf(app->content, row++, 2, "Suivi : %s · organisation : %s", + item->tracking_mode == TRAINLOG_TRACKING_REPS ? "répétitions" : "durée", + item->recording_mode == TRAINLOG_RECORDING_CONTINUOUS ? "continue" : "séries"); + if (app->exercise_detail_metadata_error) + trainlog_surface_printf(app->content, row++, 2, + "Métadonnées de zones/usage partiellement indisponibles."); + else { + trainlog_surface_printf(app->content, row++, 2, + "Zone principale : %s", primary != NULL + ? primary->display_name : "Non renseignée"); + trainlog_surface_printf(app->content, row++, 2, + "Secondaires : %s", secondary[0] != '\0' ? secondary : "Aucune"); + trainlog_surface_printf(app->content, row++, 2, + "Groupe : %s", group != NULL ? group->display_name : "Aucun"); + } + trainlog_surface_printf(app->content, row++, 2, + "Compatibles (manifeste) : %zu · utilisés historiquement : %zu", + app->exercise_explicit_equipment_count, + app->exercise_historic_equipment_count); + for (index = 0U; index < app->exercise_explicit_equipment_count && + row < app->layout.content.height - 2; ++index) + trainlog_surface_printf(app->content, row++, 4, "%c %.48s [compatible]", + app->exercise_equipment_selected == index ? '>' : ' ', + app->exercise_explicit_equipment[index].display_name); + for (index = 0U; index < app->exercise_historic_equipment_count && + row < app->layout.content.height - 2; ++index) { + size_t absolute = app->exercise_explicit_equipment_count + index; + trainlog_surface_printf(app->content, row++, 4, "%c %.48s [usage]", + app->exercise_equipment_selected == absolute ? '>' : ' ', + app->exercise_historic_equipment[index].display_name); + } + trainlog_surface_printf(app->content, app->layout.content.height - 2, 2, + "Entrée fiche équipement · p Performances · m MAX · k Connaissances · e Modifier"); + } else if (route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE) { + const TrainlogExerciseKnowledge *record = trainlog_exercise_knowledge_lookup( + app->exercise_detail.exercise_id); + const TrainlogKnowledgeInterpretation *value = record == NULL ? NULL + : record->interpretation != NULL ? record->interpretation + : record->conditional_interpretation; + char lines[KNOWLEDGE_LINES_MAX][KNOWLEDGE_LINE_MAX]; + size_t count = 0U; + size_t index; + int wrap = app->layout.content.width - 6; + knowledge_add_wrapped(lines, &count, app->exercise_detail.name, wrap); + if (record == NULL || value == NULL) + knowledge_add_wrapped(lines, &count, record == NULL + ? "Aucune fiche scientifique pour cet identifiant." + : "Interprétation scientifique non résolue.", wrap); + else { + knowledge_add_wrapped(lines, &count, + record->interpretation != NULL ? "Connaissances validées" + : "Interprétation conditionnelle — à confirmer", wrap); + knowledge_add_ids(lines, &count, "Mouvement :", value->pattern_ids, false, wrap); + knowledge_add_wrapped(lines, &count, "Confiance :", wrap); + knowledge_add_wrapped(lines, &count, value->confidence, wrap); + knowledge_add_ids(lines, &count, "Muscles principaux :", + value->primary_muscle_ids, true, wrap); + knowledge_add_ids(lines, &count, "Secondaires :", + value->secondary_muscle_ids, true, wrap); + knowledge_add_ids(lines, &count, "Stabilisateurs :", + value->stabilizer_muscle_ids, true, wrap); + knowledge_add_zones(lines, &count, value, wrap); + knowledge_add_plain_ids(lines, &count, "Sources :", value->source_refs, wrap); + } + if (app->content_scroll >= count) + app->content_scroll = count > 0U ? count - 1U : 0U; + for (index = app->content_scroll; index < count && + 3 + (int)(index - app->content_scroll) < app->layout.content.height; ++index) + trainlog_surface_printf(app->content, + 3 + (int)(index - app->content_scroll), 2, "%s", lines[index]); + } else if (route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE) { + const TrainlogExercisePerformancePoint *latest = NULL; + const TrainlogExercisePerformancePoint *best = NULL; + TrainlogLoadMode mode = TRAINLOG_LOAD_NONE; + size_t index; + int row = 4; + char latest_text[128]; + char best_text[128]; + if (app->performance_error) { + trainlog_surface_printf(app->content, row, 2, + "Impossible de lire l’historique."); + } else { + for (index = 0U; index < app->performance_count; ++index) + if (app->performance_points[index].has_performance != 0) { + latest = &app->performance_points[index]; mode = latest->load_mode; break; + } + for (index = 0U; latest != NULL && index < app->performance_count; ++index) + if (app->performance_points[index].has_performance != 0 && + app->performance_points[index].load_mode == mode && + exercise_point_better(&app->performance_points[index], best)) + best = &app->performance_points[index]; + exercise_format_performance(latest, latest_text, sizeof(latest_text)); + exercise_format_performance(best, best_text, sizeof(best_text)); + trainlog_surface_printf(app->content, row++, 2, "%s", + app->exercise_detail.name); + trainlog_surface_printf(app->content, row++, 2, "Séances enregistrées : %zu", + app->performance_count); + trainlog_surface_printf(app->content, row++, 2, "Dernier meilleur set : %s", + latest != NULL ? latest_text : "aucun"); + trainlog_surface_printf(app->content, row++, 2, "Meilleur set enregistré : %s", + best != NULL ? best_text : "aucun"); + if (latest != NULL) { + int graph_top = row + 1; + int graph_height = app_shell_graph_height(app, graph_top); + app_shell_draw_performance_graph(app, mode, + app->exercise_detail.tracking_mode, graph_top, + graph_height, false); + if (graph_height >= 3) row = graph_top + graph_height + 2; + else row += 2; + } else row += 2; + for (index = app->content_scroll; index < app->performance_count && + row < app->layout.content.height; ++index) { + char date[11]; char summary[128]; + exercise_short_date(app->performance_points[index].started_at, date); + exercise_format_performance(&app->performance_points[index], summary, + sizeof(summary)); + trainlog_surface_printf(app->content, row++, 2, "%s %-13s %s", date, + exercise_load_mode_label(app->performance_points[index].load_mode), summary); + } + } + } else if (route == TRAINLOG_ROUTE_EXERCISE_MAX) { + static const double increments[] = {0.5, 1.0, 2.5, 5.0}; + const TrainlogMeasuredMaxSummary *summary = &app->max_summary; + int row = 4; + trainlog_surface_printf(app->content, row++, 2, "%s", app->exercise_detail.name); + if (app->performance_error) trainlog_surface_printf(app->content, row, 2, + "Impossible de lire les tests de MAX."); + else if (!summary->found) { + trainlog_surface_printf(app->content, row++, 2, + "Aucun MAX mesuré réussi."); + trainlog_surface_printf(app->content, row, 2, + "Seules les séances explicitement « Test de max » comptent."); + } else { + char current[128]; char record[128]; + exercise_format_performance(&summary->current, current, sizeof(current)); + exercise_format_performance(&summary->record, record, sizeof(record)); + trainlog_surface_printf(app->content, row++, 2, "Tests MAX : %zu · réussis : %zu", + summary->test_count, summary->successful_test_count); + trainlog_surface_printf(app->content, row++, 2, "Actuel : %s", current); + trainlog_surface_printf(app->content, row++, 2, "Record même mode : %s", record); + if (summary->current.load_mode == TRAINLOG_LOAD_EXTERNAL) { + static const double percentages[] = {60.0, 70.0, 80.0, 90.0}; + double working[4]; size_t index; bool valid = true; + for (index = 0U; index < 4U; ++index) + if (trainlog_measured_max_working_load(&summary->current, + percentages[index], increments[app->max_rounding_index], + &working[index]) != TRAINLOG_STATUS_OK) valid = false; + if (valid) trainlog_surface_printf(app->content, row++, 2, + "Travail : 60%% %.1f · 70%% %.1f · 80%% %.1f · 90%% %.1f kg", + working[0], working[1], working[2], working[3]); + trainlog_surface_printf(app->content, row++, 2, + "Arrondi %.1f kg · aucun 1RM estimé", + increments[app->max_rounding_index]); + } else trainlog_surface_printf(app->content, row++, 2, + summary->current.load_mode == TRAINLOG_LOAD_ASSISTANCE + ? "Assistance : moins de kg = mieux · pourcentages non applicables" + : "Sans charge externe · pourcentages non applicables"); + { + int graph_top = row + 1; + int graph_height = app_shell_graph_height(app, graph_top); + app_shell_draw_performance_graph(app, summary->current.load_mode, + app->exercise_detail.tracking_mode, graph_top, + graph_height, true); + } + } + } else if (route == TRAINLOG_ROUTE_BODY_DETAIL) { + app_shell_render_body_detail(app); + } else if (route == TRAINLOG_ROUTE_BODY_METRIC) { + int graph_height = app_shell_graph_height(app, 6); + trainlog_surface_printf(app->content, 4, 2, "%s — ←/→ change la mesure", + BODY_METRICS[app->body_metric_selected].label); + if (app->body_snapshot_error) + trainlog_surface_printf(app->content, 6, 2, + "Impossible de lire cet historique."); + else app_shell_draw_body_points(app, app->body_metric_points, + app->body_metric_count, 6, graph_height, + BODY_METRICS[app->body_metric_selected].unit); + if (app->body_metric_partial) + trainlog_surface_printf(app->content, + app->layout.content.height - 1, 2, + "Limite locale atteinte : historique partiel."); + } else if (route == TRAINLOG_ROUTE_BODY_TRENDS) { + app_shell_render_body_trends(app); + } else if (route == TRAINLOG_ROUTE_BODY_GLOBAL) { + app_shell_render_body_global(app); + } else if (route == TRAINLOG_ROUTE_BODY_ANALYTICS) { + app_shell_render_body_analytics(app); + } else if (route == TRAINLOG_ROUTE_SYNC) { + app_shell_render_sync(app); + } else if (app_shell_is_list_route(route)) { + size_t index; + int row = 5; + trainlog_surface_printf(app->content, 3, 2, "Recherche : %s%s", + app->search.bytes > 0U ? app->search.text : "—", + app->search.focused ? " [saisie]" : ""); + if (app->list_error) trainlog_surface_printf(app->content, row++, 2, + "Impossible de charger cette liste."); + else if (app->loaded_count == 0U) trainlog_surface_printf(app->content, row++, 2, + "Aucun résultat."); + for (index = app->list.viewport_start; index < app->loaded_count && + index < app->list.viewport_start + app->list.visible_rows; ++index) { + char secondary[48] = ""; + char session_line[80] = ""; + char body_line[160] = ""; + char clipped[TRAINLOG_NAME_MAX + 8U]; + const char *name; + int label_cells = app->layout.content.width - 28; + if (route == TRAINLOG_ROUTE_EQUIPMENT) { + name = app->equipment[index].display_name; + (void)snprintf(secondary, sizeof(secondary), "%s", + equipment_origin_label(app->equipment[index].origin)); + } else if (route == TRAINLOG_ROUTE_SESSIONS_COMPLETED) { + char date[17]; + session_history_datetime(app->sessions[index].started_at, date); + (void)snprintf(session_line, sizeof(session_line), + "%s %s", date, + session_type_history_label(app->sessions[index].session_type)); + name = session_line; + (void)snprintf(secondary, sizeof(secondary), "%zu exercice(s)", + app->sessions[index].exercise_count); + } else if (route == TRAINLOG_ROUTE_BODY) { + char date[9]; + body_short_date(app->body_records[index].observed_at, date); + body_summary_text(&app->body_records[index], body_line, + sizeof(body_line)); + name = body_line; + (void)snprintf(secondary, sizeof(secondary), "%s", date); + } else { + name = app->exercises[index].name; + (void)snprintf(secondary, sizeof(secondary), "%s", + app->exercises[index].tracking_mode == TRAINLOG_TRACKING_REPS + ? "reps" : "durée"); + } + if (label_cells < 12) label_cells = 12; + if (label_cells > 48) label_cells = 48; + trainlog_shell_format_list_label(name, label_cells, clipped, + sizeof(clipped)); + trainlog_surface_set_role(app->content, + index == app->list.selected_index ? TRAINLOG_COLOR_ACCENT : TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, + index == app->list.selected_index ? TRAINLOG_TEXT_BOLD : TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->content, row++, 2, "%s %-*s %s", + index == app->list.selected_index ? "> [SÉLECTION]" : " ", + label_cells, clipped, secondary); + } + trainlog_surface_set_role(app->content, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL); + if (app->loaded_count > 0U) { + if (app->list.total_known) + trainlog_surface_printf(app->content, + app->layout.content.height - 2, 2, "Position %zu/%zu", + app->list.selected_index + 1U, app->loaded_count); + else trainlog_surface_printf(app->content, + app->layout.content.height - 2, 2, + "Position %zu/%zu+ · limite locale atteinte", + app->list.selected_index + 1U, app->loaded_count); + } + } else if (route == TRAINLOG_ROUTE_SETTINGS) { + trainlog_surface_printf(app->content, 4, 2, "Profil d’estimation corporelle"); + if (app->body_has_profile) + trainlog_surface_printf(app->content, 6, 2, "%s · %.1f cm", + app->body_profile.formula == TRAINLOG_BODY_ANALYTICS_FORMULA_FEMALE + ? "Formule femme" : "Formule homme", + app->body_profile.height_cm); + else trainlog_surface_printf(app->content, 6, 2, + "Profil non configuré."); + trainlog_surface_printf(app->content, 8, 2, + "Entrée pour consulter ou modifier le profil."); + } else { + trainlog_surface_printf(app->content, 4, 2, + "Entrée ouvre la vue complète existante dans ce contexte."); + if (width < 80) trainlog_surface_printf(app->content, 6, 2, + "Mode compact · F6 ouvre Navigation"); + } +} + +static void app_shell_render_overlay(TrainlogAppContext *app) +{ + const TrainlogOverlay *overlay = trainlog_overlays_top(&app->overlays); + TrainlogRect rect; + TrainlogSurface *surface; + size_t index; + if (overlay == NULL) return; + rect = trainlog_shell_overlay_rect(&app->layout, + overlay->type == TRAINLOG_OVERLAY_NAVIGATION ? 36 : 72, + overlay->type == TRAINLOG_OVERLAY_HELP ? 14 : + overlay->type == TRAINLOG_OVERLAY_EXERCISE_MERGE ? 16 : 12); + surface = trainlog_surface_create(app->terminal, "trainlog.overlay", + rect.y, rect.x, rect.height, rect.width); + if (surface == NULL) return; + trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT, + TRAINLOG_RGB_SURFACE0, TRAINLOG_TEXT_NORMAL); + if (overlay->type == TRAINLOG_OVERLAY_NAVIGATION) { + trainlog_surface_printf(surface, 1, 2, "Navigation"); + for (index = 0U; index < sizeof(shell_sections) / sizeof(shell_sections[0]) && + 3 + (int)index < rect.height; ++index) + trainlog_surface_printf(surface, 3 + (int)index, 2, "%c %s", + overlay->selected == index ? '>' : ' ', + shell_section_labels[index]); + } else if (overlay->type == TRAINLOG_OVERLAY_ACTIONS) { + size_t visible = rect.height > 4 ? (size_t)(rect.height - 4) : 1U; + size_t start = overlay->selected >= visible + ? overlay->selected - visible + 1U : 0U; + trainlog_surface_printf(surface, 1, 2, "Actions disponibles"); + for (index = start; index < app->actions.count && + index < start + visible; ++index) + trainlog_surface_printf(surface, 3 + (int)(index - start), 2, "%c %s", + overlay->selected == index ? '>' : ' ', app->actions.items[index].label); + if (app->actions.count > visible) + trainlog_surface_printf(surface, rect.height - 1, 2, + "Action %zu/%zu%s%s", overlay->selected + 1U, + app->actions.count, start > 0U ? " · ↑" : "", + start + visible < app->actions.count ? " · ↓" : ""); + } else if (overlay->type == TRAINLOG_OVERLAY_EXERCISE_MERGE) { + TrainlogExerciseController *controller = &app->exercise_controller; + if (controller->phase == TRAINLOG_EXERCISE_MERGE_PICKER) { + size_t matches = exercise_merge_match_count(controller); + size_t visible = rect.height > 7 ? (size_t)(rect.height - 7) : 1U; + size_t start = controller->merge_selected >= visible + ? controller->merge_selected - visible + 1U : 0U; + trainlog_surface_printf(surface, 1, 2, "Fusionner la source : %.48s", + controller->merge_source.name); + trainlog_surface_printf(surface, 3, 2, "Recherche cible : %s_", + controller->form.text); + for (index = start; index < matches && index < start + visible; ++index) { + TrainlogExercise *candidate = exercise_merge_match_at(controller, index); + if (candidate != NULL) + trainlog_surface_printf(surface, 5 + (int)(index - start), 2, + "%c %.58s", controller->merge_selected == index ? '>' : ' ', + candidate->name); + } + if (matches == 0U) trainlog_surface_printf(surface, 5, 2, + "Aucune cible correspondante."); + trainlog_surface_printf(surface, rect.height - 1, 2, + "Entrée prévisualiser · Échap annuler"); + } else if (controller->phase == TRAINLOG_EXERCISE_MERGE_CONFIRM) { + trainlog_surface_printf(surface, 1, 2, "Confirmer la fusion"); + trainlog_surface_printf(surface, 3, 2, "SOURCE : %.54s", + controller->merge_source.name); + trainlog_surface_printf(surface, 4, 2, " vers CANONIQUE : %.45s", + controller->merge_target.name); + trainlog_surface_printf(surface, 6, 2, + "Impact source : %zu occurrence(s), %zu série(s), %zu continu, %zu MAX.", + controller->merge_preview.occurrences, + controller->merge_preview.performed_sets, + controller->merge_preview.continuous_activities, + controller->merge_preview.max_results); + trainlog_surface_printf(surface, 7, 2, + "Associations : %zu équipement(s), %zu zone(s).", + controller->merge_preview.associated_equipment, + controller->merge_preview.body_zones); + trainlog_surface_printf(surface, 9, 2, "%c Annuler", + overlay->selected == 0U ? '>' : ' '); + trainlog_surface_printf(surface, 10, 2, "%c Fusionner source vers canonique", + overlay->selected == 1U ? '>' : ' '); + } else { + trainlog_surface_printf(surface, 1, 2, "Résultat de la fusion"); + trainlog_surface_printf(surface, 3, 2, "%.64s", controller->message); + trainlog_surface_printf(surface, 6, 2, "Entrée fermer"); + } + } else if (overlay->type == TRAINLOG_OVERLAY_CONFIRMATION && + app->quit_confirmation) { + trainlog_surface_printf(surface, 1, 2, "Quitter TRAINLOG ?"); + trainlog_surface_printf(surface, 3, 2, + "Le brouillon et les propositions gardés seulement pour ce run seront perdus."); + trainlog_surface_printf(surface, 5, 2, "%c Annuler", + overlay->selected == 0U ? '>' : ' '); + trainlog_surface_printf(surface, 6, 2, "%c Quitter et perdre cet état", + overlay->selected == 1U ? '>' : ' '); + } else { + trainlog_surface_printf(surface, 1, 2, "Aide contextuelle"); + trainlog_surface_printf(surface, 3, 2, "0/Home Accueil · 1/F1 Séance · 2/F2 Effectuées"); + trainlog_surface_printf(surface, 5, 2, "3/F3 Exercices · 4/F4 Équipements · 5/F5 Mensurations"); + trainlog_surface_printf(surface, 7, 2, "6 Synchronisation · F6 Navigation · F7 Actions"); + trainlog_surface_printf(surface, 9, 2, "Tab change le focus · Échap revient"); + } + trainlog_surface_move_top(surface); + app_shell_render_footer(app); + trainlog_terminal_render(app->terminal); + trainlog_surface_destroy(surface); +} + +static void app_shell_render(TrainlogAppContext *app) +{ + if (!app_shell_layout(app)) { app->running = false; return; } + if (!app->layout.usable) { + trainlog_terminal_erase(app->terminal); + trainlog_terminal_printf(app->terminal, 1, 2, + "Terminal trop petit — %dx%d, minimum 72x20.", + app->layout.columns, app->layout.rows); + trainlog_terminal_printf(app->terminal, 3, 2, "q pour quitter"); + trainlog_terminal_render(app->terminal); return; + } + trainlog_surface_erase(app->header); + trainlog_surface_set_role(app->header, TRAINLOG_COLOR_ACCENT, + TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_BOLD); + trainlog_surface_printf(app->header, 0, 2, "TRAINLOG"); + trainlog_surface_set_role(app->header, TRAINLOG_COLOR_MUTED, + TRAINLOG_RGB_MANTLE, TRAINLOG_TEXT_NORMAL); + trainlog_surface_printf(app->header, 1, 2, "%s", trainlog_route_title(app->navigation.current.route)); + if (!app->layout.sidebar_visible) { + int controls_x = app->layout.columns > 35 ? app->layout.columns - 34 : 2; + trainlog_surface_printf(app->header, 0, controls_x, "%c F6 Navigation", + app->focus == TRAINLOG_FOCUS_NAVIGATION ? '>' : ' '); + trainlog_surface_printf(app->header, 1, controls_x, "%c F7 Actions", + app->focus == TRAINLOG_FOCUS_ACTIONS ? '>' : ' '); + } + app_shell_render_sidebar(app); app_shell_render_content(app); + app_shell_render_footer(app); + trainlog_surface_move_top(app->footer); + trainlog_terminal_render(app->terminal); + if (trainlog_overlays_top(&app->overlays) != NULL) app_shell_render_overlay(app); +} + +static void app_shell_open_route(TrainlogAppContext *app, TrainlogAppRoute route) +{ + TrainlogAppRoute previous = app->navigation.current.route; + TrainlogSessionController *session = &app->session; + if (route != previous && + app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE && + app->exercise_controller.phase != TRAINLOG_EXERCISE_MESSAGE && + app->exercise_controller.phase != TRAINLOG_EXERCISE_CONFIRM_DISCARD) { + /* CONTRACT: selecting a destination never silently replaces the one + * transient exercise editor. Raw fields remain owned until discard. */ + app->exercise_controller.return_phase = app->exercise_controller.phase; + app->exercise_controller.pending_route = route; + app->exercise_controller.leaving = true; + app->exercise_controller.phase = TRAINLOG_EXERCISE_CONFIRM_DISCARD; + return; + } + if (route != previous && + app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE && + app->equipment_controller.phase != TRAINLOG_EQUIPMENT_MESSAGE && + app->equipment_controller.phase != TRAINLOG_EQUIPMENT_CONFIRM_DISCARD) { + app->equipment_controller.return_phase = app->equipment_controller.phase; + app->equipment_controller.pending_route = route; + app->equipment_controller.leaving = true; + app->equipment_controller.phase = TRAINLOG_EQUIPMENT_CONFIRM_DISCARD; + return; + } + if (route != previous && app->body_controller.phase == TRAINLOG_BODY_FORM) { + app->body_controller.pending_route = route; + app->body_controller.leaving = true; + app->body_controller.phase = TRAINLOG_BODY_CONFIRM_DISCARD; + return; + } + if (route != previous && + (app->profile_controller.phase == TRAINLOG_PROFILE_FORMULA || + app->profile_controller.phase == TRAINLOG_PROFILE_HEIGHT)) { + app->profile_controller.pending_route = route; + app->profile_controller.leaving = true; + app->profile_controller.return_phase = app->profile_controller.phase; + app->profile_controller.phase = TRAINLOG_PROFILE_CONFIRM_DISCARD; + return; + } + if (session->form.active && route != previous) { + (void)snprintf(session->message, sizeof(session->message), + "Terminez ou annulez le champ actif avant de changer de rubrique."); + return; + } + if (previous == TRAINLOG_ROUTE_SESSION_GENERATOR && + route != TRAINLOG_ROUTE_SESSION_GENERATOR && + (session->generated_preview || session->generation_zone != NULL) && + session->phase != TRAINLOG_SESSION_CONFIRM_LEAVE) { + session->return_phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; + session->pending_route = route; + session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE; + return; + } + if (app_shell_is_list_route(previous)) { + app->saved_lists[previous] = app->list; + app->saved_searches[previous] = app->search; + app->saved_searches[previous].focused = false; + } + (void)trainlog_navigation_open(&app->navigation, route, NULL); + app->focus = TRAINLOG_FOCUS_CONTENT; + app->content_selected = 0U; + app->content_scroll = 0U; + if (route == TRAINLOG_ROUTE_SESSION_MANUAL || + route == TRAINLOG_ROUTE_SESSION_CURRENT) { + session->message[0] = '\0'; + session->phase = session->has_draft ? TRAINLOG_SESSION_DRAFT + : TRAINLOG_SESSION_CHOOSE_TYPE; + session->session_type = session->has_draft ? session->session_type + : TRAINLOG_SESSION_TRAINING; + } else if (route == TRAINLOG_ROUTE_SESSION_GENERATOR) { + if (session->generated_preview) + session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; + else { + size_t zone_count = trainlog_body_zone_catalog_count(); + session->phase = TRAINLOG_SESSION_GENERATOR_CONFIG; + if (session->generation_zone == NULL && zone_count > 0U) + session->generation_zone = trainlog_body_zone_catalog_at(0U); + if (session->generation_goal == NULL && + trainlog_session_generation_policy_v1.goal_count > 0U) + session->generation_goal = &trainlog_session_generation_policy_v1.goals[0]; + if (session->generation_duration_minutes <= 0 && + trainlog_session_generation_policy_v1.duration_preset_count > 0U) + session->generation_duration_minutes = + trainlog_session_generation_policy_v1.duration_presets_minutes[0]; + } + session_controller_sync_durability(session); + } + if (route == TRAINLOG_ROUTE_SESSION_GENERATOR && + !session->generated_preview && session->generation_zone == NULL) { + session->generation_zone_index = 0U; + session->generation_goal_index = 0U; + session->generation_duration_index = 0U; + session->generation_zone = trainlog_body_zone_catalog_at(0U); + if (trainlog_session_generation_policy_v1.goal_count > 0U) + session->generation_goal = &trainlog_session_generation_policy_v1.goals[0]; + if (trainlog_session_generation_policy_v1.duration_preset_count > 0U) + session->generation_duration_minutes = + trainlog_session_generation_policy_v1.duration_presets_minutes[0]; + session->phase = TRAINLOG_SESSION_GENERATOR_CONFIG; + } else if (route == TRAINLOG_ROUTE_SESSION_GENERATOR && session->generated_preview && + session->phase != TRAINLOG_SESSION_GENERATOR_WARNING) + session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; + if (route == TRAINLOG_ROUTE_BODY_METRIC) + app_shell_load_body_metric(app); + else if (route == TRAINLOG_ROUTE_BODY_TRENDS || + route == TRAINLOG_ROUTE_BODY_GLOBAL) + app_shell_load_body_global(app); + else if (route == TRAINLOG_ROUTE_BODY_ANALYTICS) + app_shell_load_body_analytics(app); + else if (route == TRAINLOG_ROUTE_SYNC) { + sync_controller_load_history(&app->sync_controller); + app->sync_controller.showing_detail = false; + } else if (route == TRAINLOG_ROUTE_SETTINGS) + app->body_has_profile = body_analytics_profile_load(&app->body_profile); + if (app_shell_is_list_route(route)) { + app->list = app->saved_lists[route]; + app->search = app->saved_searches[route]; + app_shell_refresh_list(app); + } +} + +static bool app_shell_back(TrainlogAppContext *app) +{ + TrainlogAppRoute previous = app->navigation.current.route; + if (app_shell_is_list_route(previous)) { + app->saved_lists[previous] = app->list; + app->saved_searches[previous] = app->search; + app->saved_searches[previous].focused = false; + } + if (!trainlog_navigation_back(&app->navigation)) return false; + if (app_shell_is_list_route(app->navigation.current.route)) { + app->list = app->saved_lists[app->navigation.current.route]; + app->search = app->saved_searches[app->navigation.current.route]; + app_shell_refresh_list(app); + } + return true; +} + +static bool app_shell_dispatch_session(TrainlogAppContext *app, int key); +static bool app_shell_dispatch_sync(TrainlogAppContext *app, int key); + +static void app_shell_load_exercise_detail_metadata(TrainlogAppContext *app) +{ + size_t index; + app->exercise_zone_count = 0U; + app->exercise_explicit_equipment_count = 0U; + app->exercise_historic_equipment_count = 0U; + app->exercise_equipment_selected = 0U; + app->exercise_detail_metadata_error = + trainlog_database_list_exercise_body_zones(app->database, + app->exercise_detail.exercise_id, app->exercise_zones, + MAX_BODY_ZONES, &app->exercise_zone_count) != TRAINLOG_STATUS_OK; + for (index = 0U; index < trainlog_equipment_catalog_relation_count() && + app->exercise_explicit_equipment_count < 64U; ++index) { + const TrainlogExerciseEquipmentRelation *relation = + trainlog_equipment_catalog_relation_at(index); + if (relation != NULL && strcmp(relation->exercise_id, + app->exercise_detail.exercise_id) == 0 && + trainlog_database_resolve_equipment(app->database, + relation->equipment_id, + &app->exercise_explicit_equipment[ + app->exercise_explicit_equipment_count]) == TRAINLOG_STATUS_OK) + ++app->exercise_explicit_equipment_count; + } + if (trainlog_database_list_exercise_equipment(app->database, + app->exercise_detail.exercise_id, + app->exercise_historic_equipment, 128U, + &app->exercise_historic_equipment_count) != TRAINLOG_STATUS_OK) { + app->exercise_historic_equipment_count = 0U; + app->exercise_detail_metadata_error = true; + } +} + +static void app_shell_primary(TrainlogAppContext *app) +{ + TrainlogAppRoute route = app->navigation.current.route; + if (route == TRAINLOG_ROUTE_SESSIONS) { + static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_SESSION_CURRENT, + TRAINLOG_ROUTE_SESSION_GENERATOR, TRAINLOG_ROUTE_SESSION_MANUAL, + TRAINLOG_ROUTE_SESSIONS_COMPLETED}; + app_shell_open_route(app, targets[app->content_selected % 4U]); + } else if (route == TRAINLOG_ROUTE_STATS) { + static const TrainlogAppRoute targets[] = {TRAINLOG_ROUTE_STATS_EXERCISE, + TRAINLOG_ROUTE_BODY, TRAINLOG_ROUTE_MAX}; + app_shell_open_route(app, targets[app->content_selected % 3U]); + } else if (route == TRAINLOG_ROUTE_SESSION_MANUAL || + route == TRAINLOG_ROUTE_SESSION_CURRENT || + route == TRAINLOG_ROUTE_SESSION_GENERATOR) { + (void)app_shell_dispatch_session(app, TRAINLOG_KEY_ENTER); + } else if (app_shell_is_list_route(route)) { + if (app->loaded_count == 0U) return; + /* INVARIANT: detail routes borrow list selection; they do not replace + * its stable-ID/query/viewport state. Back restores this exact snapshot + * after any intervening resize or catalogue refresh. */ + app->saved_lists[route] = app->list; + app->saved_searches[route] = app->search; + app->saved_searches[route].focused = false; + if (route == TRAINLOG_ROUTE_EQUIPMENT) { + app->equipment_detail = app->equipment[app->list.selected_index]; + (void)trainlog_navigation_open(&app->navigation, + TRAINLOG_ROUTE_EQUIPMENT_DETAIL, + app->equipment_detail.equipment_id); + } + else if (route == TRAINLOG_ROUTE_SESSIONS_COMPLETED) + app_shell_open_session_detail(app, + app->sessions[app->list.selected_index].session_id); + else if (route == TRAINLOG_ROUTE_BODY) { + app->body_detail = app->body_records[app->list.selected_index]; + (void)trainlog_navigation_open(&app->navigation, + TRAINLOG_ROUTE_BODY_DETAIL, + app->body_detail.observation_id); + } + else if (route == TRAINLOG_ROUTE_STATS_EXERCISE) { + app->exercise_detail = app->exercises[app->list.selected_index]; + app_shell_open_exercise_analytics(app, + TRAINLOG_ROUTE_EXERCISE_PERFORMANCE); + } else if (route == TRAINLOG_ROUTE_MAX) { + app->exercise_detail = app->exercises[app->list.selected_index]; + app_shell_open_exercise_analytics(app, TRAINLOG_ROUTE_EXERCISE_MAX); + } + else { + app->exercise_detail = app->exercises[app->list.selected_index]; + app_shell_load_exercise_detail_metadata(app); + (void)trainlog_navigation_open(&app->navigation, + TRAINLOG_ROUTE_EXERCISE_DETAIL, + app->exercise_detail.exercise_id); + } + if (route != TRAINLOG_ROUTE_EQUIPMENT && route != TRAINLOG_ROUTE_EXERCISES && + route != TRAINLOG_ROUTE_STATS_EXERCISE && route != TRAINLOG_ROUTE_MAX && + route != TRAINLOG_ROUTE_SESSIONS_COMPLETED && route != TRAINLOG_ROUTE_BODY) { + trainlog_terminal_erase(app->terminal); + app_shell_refresh_list(app); + } + } else if (route == TRAINLOG_ROUTE_SYNC) { + sync_controller_open_detail(&app->sync_controller); + } else if (route == TRAINLOG_ROUTE_SETTINGS) { + profile_controller_start(app, TRAINLOG_ROUTE_SETTINGS); + } +} + +static void app_shell_dispatch_overlay(TrainlogAppContext *app, int key) +{ + TrainlogOverlay *overlay = &app->overlays.items[app->overlays.count - 1U]; + if (overlay->type == TRAINLOG_OVERLAY_EXERCISE_MERGE) { + TrainlogExerciseController *controller = &app->exercise_controller; + if (controller->phase == TRAINLOG_EXERCISE_MERGE_PICKER) { + size_t matches = exercise_merge_match_count(controller); + if (key == TRAINLOG_KEY_ESCAPE) { + if (controller->form.text[0] != '\0') { + trainlog_form_init(&controller->form, ""); + controller->merge_selected = 0U; + } else { + char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY]; + (void)trainlog_overlays_pop(&app->overlays, &app->focus, id); + (void)memset(controller, 0, sizeof(*controller)); + } + } else if (key == TRAINLOG_KEY_UP && controller->merge_selected > 0U) + --controller->merge_selected; + else if (key == TRAINLOG_KEY_DOWN && controller->merge_selected + 1U < matches) + ++controller->merge_selected; + else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && matches > 0U) { + TrainlogExercise *target = exercise_merge_match_at(controller, + controller->merge_selected); + if (target != NULL) { + controller->merge_target = *target; + if (trainlog_database_preview_exercise_merge(app->database, + controller->merge_source.exercise_id, + &controller->merge_preview) == TRAINLOG_STATUS_OK) + controller->phase = TRAINLOG_EXERCISE_MERGE_CONFIRM; + else { + controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT; + (void)snprintf(controller->message, + sizeof(controller->message), + "Prévisualisation impossible; aucune fusion effectuée."); + } + overlay->selected = 0U; + } + } else if (!(key == '/' && controller->form.text[0] == '\0')) { + /* '/' activates the standard search affordance; this overlay + * already owns the shared UTF-8 form editor. */ + TrainlogFormResult result = trainlog_form_handle(&controller->form, key); + if (result == TRAINLOG_FORM_EDITED) controller->merge_selected = 0U; + } + } else if (controller->phase == TRAINLOG_EXERCISE_MERGE_CONFIRM) { + if (key == TRAINLOG_KEY_ESCAPE) { + controller->phase = TRAINLOG_EXERCISE_MERGE_PICKER; + overlay->selected = controller->merge_selected; + } else if (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN) + overlay->selected = overlay->selected == 0U ? 1U : 0U; + else if (key == TRAINLOG_KEY_ENTER || key == '\n') { + if (overlay->selected == 0U) { + controller->phase = TRAINLOG_EXERCISE_MERGE_PICKER; + overlay->selected = controller->merge_selected; + } else { + TrainlogStatus status = trainlog_database_merge_exercises(app->database, + controller->merge_source.exercise_id, + controller->merge_target.exercise_id); + if (status == TRAINLOG_STATUS_OK) { + app->exercise_detail = controller->merge_target; + (void)snprintf(app->navigation.current.stable_id, + sizeof(app->navigation.current.stable_id), "%s", + controller->merge_target.exercise_id); + app_shell_load_exercise_detail_metadata(app); + (void)snprintf(controller->message, sizeof(controller->message), + "Fusion terminée. Cible sélectionnée : %.112s", + controller->merge_target.name); + } else (void)snprintf(controller->message, + sizeof(controller->message), "%s", + status == TRAINLOG_STATUS_CONFLICT + ? "Fusion refusée : profil ou zone principale incompatible. Aucun changement." + : "Fusion impossible. Aucun changement confirmé."); + controller->phase = TRAINLOG_EXERCISE_MERGE_RESULT; + } + } + } else if (key == TRAINLOG_KEY_ENTER || key == '\n' || + key == TRAINLOG_KEY_ESCAPE) { + char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY]; + (void)trainlog_overlays_pop(&app->overlays, &app->focus, id); + (void)memset(controller, 0, sizeof(*controller)); + } + return; + } + size_t count = overlay->type == TRAINLOG_OVERLAY_NAVIGATION + ? sizeof(shell_sections) / sizeof(shell_sections[0]) + : overlay->type == TRAINLOG_OVERLAY_CONFIRMATION ? 2U : app->actions.count; + if (key == TRAINLOG_KEY_ESCAPE || + (key == TRAINLOG_KEY_F6 && + overlay->type == TRAINLOG_OVERLAY_NAVIGATION)) { + char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY]; + (void)trainlog_overlays_pop(&app->overlays, &app->focus, id); + app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH; + } else if (key == TRAINLOG_KEY_UP && overlay->selected > 0U) --overlay->selected; + else if (key == TRAINLOG_KEY_DOWN && overlay->selected + 1U < count) ++overlay->selected; + else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && count > 0U) { + if (overlay->type == TRAINLOG_OVERLAY_CONFIRMATION && app->quit_confirmation) { + bool quit = overlay->selected == 1U; + char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY]; + (void)trainlog_overlays_pop(&app->overlays, &app->focus, id); + app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH; + app->quit_confirmation = false; + if (quit) app->running = false; + } else if (overlay->type == TRAINLOG_OVERLAY_NAVIGATION) { + TrainlogAppRoute target = shell_sections[overlay->selected]; + char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY]; + (void)trainlog_overlays_pop(&app->overlays, &app->focus, id); + app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH; + app_shell_open_route(app, target); + } else if (overlay->type == TRAINLOG_OVERLAY_ACTIONS) { + TrainlogAction action = app->actions.items[overlay->selected]; + char id[TRAINLOG_SHELL_STABLE_ID_CAPACITY]; + (void)trainlog_overlays_pop(&app->overlays, &app->focus, id); + if (app->exercise_controller.phase != TRAINLOG_EXERCISE_IDLE) { + (void)app_shell_dispatch_exercise_controller(app, action.key); + } else if (app->equipment_controller.phase != TRAINLOG_EQUIPMENT_IDLE) { + (void)app_shell_dispatch_equipment_controller(app, action.key); + } else if (app->session.form.active) { + (void)snprintf(app->session.message, sizeof(app->session.message), + "Terminez ou annulez le champ actif avant une autre action."); + } else if (strcmp(action.identifier, "generator.discard") == 0) { + session_controller_discard_generator(&app->session); + app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS); + } else if (strcmp(action.identifier, "exercise.merge") == 0) { + exercise_controller_start_merge(app); + } else if (app->navigation.current.route == TRAINLOG_ROUTE_SYNC) { + /* CONTRACT: F7 executes the exact registered key through the + * production sync controller; it is not a palette-only stub. */ + (void)app_shell_dispatch_sync(app, action.key); + } else if (app->navigation.current.route == TRAINLOG_ROUTE_SESSION_MANUAL || + app->navigation.current.route == TRAINLOG_ROUTE_SESSION_CURRENT || + app->navigation.current.route == TRAINLOG_ROUTE_SESSION_GENERATOR) + (void)app_shell_dispatch_session(app, action.key); + else if (action.intent == TRAINLOG_INTENT_PRIMARY) app_shell_primary(app); + else if (action.intent == TRAINLOG_INTENT_OPEN_SEARCH && + app_shell_is_list_route(app->navigation.current.route)) { + /* Keep F7 action execution aligned with the registered '/' + * action: it opens the filter and transfers editor focus. */ + app->search.open = true; + app->search.focused = true; + app->focus = TRAINLOG_FOCUS_SEARCH; + } else if (action.intent == TRAINLOG_INTENT_OPEN_ROUTE) + app_shell_open_route(app, action.route); + else if (action.intent == TRAINLOG_INTENT_BACK) + (void)app_shell_back(app); + } + } +} + +static bool session_form_int(const TrainlogFormField *form, int *output) +{ + char *end = NULL; + long value; + if (form == NULL || output == NULL || form->bytes == 0U) return false; + value = strtol(form->text, &end, 10); + if (end == form->text || *end != '\0' || value <= 0L || value > INT_MAX) + return false; + *output = (int)value; + return true; +} + +static bool session_form_nonnegative_int(const TrainlogFormField *form, int *output) +{ + char *end = NULL; + long value; + if (form == NULL || output == NULL || form->bytes == 0U) return false; + value = strtol(form->text, &end, 10); + if (end == form->text || *end != '\0' || value < 0L || value > INT_MAX) + return false; + *output = (int)value; + return true; +} + +static bool session_form_double(const TrainlogFormField *form, double *output) +{ + char *end = NULL; + double value; + if (form == NULL || output == NULL || form->bytes == 0U) return false; + value = strtod(form->text, &end); + if (end == form->text || *end != '\0' || !isfinite(value) || value < 0.0) + return false; + *output = value; + return true; +} + +static void session_controller_start_form(TrainlogSessionController *session, + TrainlogSessionFormPurpose purpose, + const char *initial) +{ + session->form_purpose = purpose; + trainlog_form_init(&session->form, initial); +} + +static bool app_shell_dispatch_session_form(TrainlogAppContext *app, int key) +{ + TrainlogSessionController *session = &app->session; + TrainlogSessionDraftExercise *draft = &session->drafts[session->selected]; + TrainlogFormResult result = trainlog_form_handle(&session->form, key); + int integer_value; + double double_value; + if (result == TRAINLOG_FORM_OPEN_NAVIGATION) { + (void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_NAVIGATION, + TRAINLOG_FOCUS_EDITOR, app->navigation.current.stable_id); return true; + } + if (result == TRAINLOG_FORM_OPEN_ACTIONS) { + (void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_ACTIONS, + TRAINLOG_FOCUS_EDITOR, app->navigation.current.stable_id); return true; + } + if (result == TRAINLOG_FORM_CANCEL) { + session->form.active = false; + session->form_purpose = TRAINLOG_SESSION_FORM_NONE; + if (session->phase == TRAINLOG_SESSION_EQUIPMENT_CREATE) + session->phase = TRAINLOG_SESSION_EQUIPMENT_PICKER; + return true; + } + if (result != TRAINLOG_FORM_SUBMIT && result != TRAINLOG_FORM_NEXT) + return true; + if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_NAME) { + if (session->form.bytes == 0U || session->form.bytes >= + sizeof(session->pending_equipment.display_name)) { + (void)snprintf(session->message, sizeof(session->message), + "Le nom convivial est requis et doit tenir dans le champ."); + return true; + } + (void)snprintf(session->pending_equipment.display_name, + sizeof(session->pending_equipment.display_name), "%s", session->form.text); + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL, ""); + return true; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_LABEL) { + if (session->form.bytes >= sizeof(session->pending_equipment.label_name)) { + (void)snprintf(session->message, sizeof(session->message), + "Le nom d’étiquette est trop long."); + return true; + } + (void)snprintf(session->pending_equipment.label_name, + sizeof(session->pending_equipment.label_name), "%s", session->form.text); + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE, ""); + return true; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_TYPE) { + if (session->form.bytes == 0U || session->form.bytes >= + sizeof(session->pending_equipment.equipment_type)) { + (void)snprintf(session->message, sizeof(session->message), + "Le type d’équipement est requis."); + return true; + } + (void)snprintf(session->pending_equipment.equipment_type, + sizeof(session->pending_equipment.equipment_type), "%s", session->form.text); + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_EQUIPMENT_LOAD, "2"); + return true; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_EQUIPMENT_LOAD && + session_form_int(&session->form, &integer_value) && + integer_value <= 3) { + char generated[TRAINLOG_UUID_TEXT_LENGTH + 1U]; + size_t index; + (void)snprintf(session->pending_equipment.load_semantics, + sizeof(session->pending_equipment.load_semantics), "%s", + integer_value == 1 ? "none" : integer_value == 2 ? "external" : "assistance"); + generate_custom_equipment_uuid(generated); + (void)snprintf(session->pending_equipment.equipment_id, + sizeof(session->pending_equipment.equipment_id), "%s", generated); + if (trainlog_database_create_custom_equipment(app->database, + &session->pending_equipment) != TRAINLOG_STATUS_OK) { + (void)snprintf(session->message, sizeof(session->message), + "Équipement non créé : nom ou identifiant invalide/conflit."); + return true; + } + session->form.active = false; + session->form_purpose = TRAINLOG_SESSION_FORM_NONE; + session_controller_load_equipment(app); + for (index = 0U; index < session->equipment_count; ++index) + if (strcmp(app->equipment[index].equipment_id, generated) == 0) { + session->equipment_selected = index + 1U; + break; + } + session->message[0] = '\0'; + return true; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_GENERATED_SETS && + session_form_int(&session->form, &integer_value) && integer_value <= 64 && + session->selected < session->generated.exercise_count) { + char value[48]; + session->pending_generated_sets = integer_value; + (void)snprintf(value, sizeof(value), "%d", + session->generated.exercises[session->selected].target_repetitions); + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_GENERATED_REPS, value); + return true; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_GENERATED_REPS && + session_form_int(&session->form, &integer_value) && integer_value <= 10000 && + session->selected < session->generated.exercise_count) { + if (!session_controller_requalify_generated(app, session->selected, + session->pending_generated_sets, integer_value)) { + (void)snprintf(session->message, sizeof(session->message), + "Dose refusée; la proposition précédente est conservée."); + return true; + } + session->form.active = false; + session->form_purpose = TRAINLOG_SESSION_FORM_NONE; + session_controller_sync_durability(session); + return true; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_GENERATED_PERCENT_MAX && + session_form_int(&session->form, &integer_value) && integer_value <= 100 && + session->selected < session->generated.exercise_count) { + TrainlogGeneratedExercise *generated = + &session->generated.exercises[session->selected]; + TrainlogLatestExplicitMax latest = {0}; + double target = 0.0; + TrainlogStatus status = trainlog_database_latest_explicit_max_equipment_context( + app->database, generated->exercise_id, generated->equipment_id, &latest); + generated->has_target_weight = status == TRAINLOG_STATUS_OK && + trainlog_measured_max_target_load(&latest, generated->equipment_id, + strcmp(generated->equipment_load_semantics, "external") == 0 + ? TRAINLOG_LOAD_EXTERNAL : TRAINLOG_LOAD_NONE, + integer_value, &target) == TRAINLOG_STATUS_OK; + generated->target_weight_kg = generated->has_target_weight ? target : 0.0; + generated->planned_load_mode = generated->has_target_weight + ? TRAINLOG_LOAD_EXTERNAL : TRAINLOG_LOAD_NONE; + generated->rationale_count = 1U; + (void)snprintf(generated->rationale_codes[0], + sizeof(generated->rationale_codes[0]), "%s", + generated->has_target_weight ? "user_selected_max_percentage" + : "compatible_max_unavailable"); + generated->load_source_session_id[0] = '\0'; + generated->load_source_occurrence_id[0] = '\0'; + generated->load_source_started_at[0] = '\0'; + session->form.active = false; + session->form_purpose = TRAINLOG_SESSION_FORM_NONE; + session->dirty = true; + session_controller_sync_durability(session); + return true; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_SET_METRIC && + session_form_int(&session->form, &integer_value)) { + if (draft->tracking_mode == TRAINLOG_TRACKING_DURATION) + session->pending_set.duration_seconds = integer_value; + else session->pending_set.reps = integer_value; + if (draft->input.load_mode != TRAINLOG_LOAD_NONE) { + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_SET_WEIGHT, ""); + return true; + } + if (session->set_selected < draft->input.set_count) + draft->sets[session->set_selected] = session->pending_set; + else if (draft->input.set_count < MAX_SETS_PER_EXERCISE) + draft->sets[draft->input.set_count++] = session->pending_set; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_SET_WEIGHT && + (session->form.bytes == 0U || + session_form_double(&session->form, &double_value))) { + session->pending_set.has_weight = session->form.bytes > 0U; + session->pending_set.weight_kg = session->form.bytes > 0U ? double_value : 0.0; + if (session->set_selected < draft->input.set_count) + draft->sets[session->set_selected] = session->pending_set; + else if (draft->input.set_count < MAX_SETS_PER_EXERCISE) + draft->sets[draft->input.set_count++] = session->pending_set; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_CONTINUOUS_DURATION && + session_form_int(&session->form, &integer_value)) { + draft->input.continuous_duration_seconds = integer_value; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_CONTINUOUS_SPEED && + (session->form.bytes == 0U || session_form_double(&session->form, + &double_value))) { + draft->input.continuous_has_speed = session->form.bytes > 0U; + draft->input.continuous_speed_kmh = session->form.bytes > 0U ? double_value : 0.0; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_CONTINUOUS_DISTANCE && + (session->form.bytes == 0U || session_form_double(&session->form, + &double_value))) { + draft->input.continuous_has_distance = session->form.bytes > 0U; + draft->input.continuous_distance_km = session->form.bytes > 0U ? double_value : 0.0; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_SETS && + session_form_nonnegative_int(&session->form, &integer_value)) { + draft->input.target_sets = integer_value; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_REPS && + session_form_nonnegative_int(&session->form, &integer_value)) { + draft->input.target_reps = integer_value; + if (integer_value > 0) draft->input.target_duration_seconds = 0; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_DURATION && + session_form_nonnegative_int(&session->form, &integer_value)) { + draft->input.target_duration_seconds = integer_value; + if (integer_value > 0) draft->input.target_reps = 0; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_REST && + session_form_nonnegative_int(&session->form, &integer_value)) { + draft->input.rest_seconds = integer_value; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_WEIGHT && + (session->form.bytes == 0U || session_form_double(&session->form, + &double_value))) { + draft->input.target_has_weight = session->form.bytes > 0U; + draft->input.target_weight_kg = session->form.bytes > 0U ? double_value : 0.0; + draft->target_from_percent_max = false; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_TARGET_PERCENT_MAX && + session_form_int(&session->form, &integer_value) && + integer_value <= 100) { + TrainlogLatestExplicitMax latest = {0}; + double target = 0.0; + TrainlogStatus max_status = trainlog_database_latest_explicit_max_equipment_context( + app->database, draft->input.exercise_id, draft->input.equipment_id, &latest); + if (max_status != TRAINLOG_STATUS_OK || + trainlog_measured_max_target_load(&latest, + draft->input.equipment_id, draft->input.load_mode, + integer_value, &target) != + TRAINLOG_STATUS_OK) { + draft->input.target_has_weight = false; + draft->input.target_weight_kg = 0.0; + draft->target_from_percent_max = false; + (void)snprintf(session->message, sizeof(session->message), + "MAX compatible indisponible : exercice et équipement externe exacts requis."); + session->form.active = false; + session->form_purpose = TRAINLOG_SESSION_FORM_NONE; + draft_bind_input(draft); + session_controller_sync_durability(session); + return true; + } + draft->input.target_has_weight = true; + draft->input.target_weight_kg = target; + draft->target_from_percent_max = true; + } else if (session->form_purpose == TRAINLOG_SESSION_FORM_MAX_WEIGHT && + session_form_double(&session->form, &double_value)) { + draft->input.has_max_weight = true; + draft->input.max_weight_kg = double_value; + draft->input.set_count = 0U; + draft->input.continuous_duration_seconds = 0; + } else { + (void)snprintf(session->message, sizeof(session->message), + "Valeur invalide; saisissez un nombre positif."); + return true; + } + draft_bind_input(draft); + session->dirty = true; + session->message[0] = '\0'; + session->form.active = false; + session->form_purpose = TRAINLOG_SESSION_FORM_NONE; + session_controller_sync_durability(session); + return true; +} + +static bool app_shell_dispatch_session(TrainlogAppContext *app, int key) +{ + TrainlogSessionController *session = &app->session; + TrainlogAppRoute route = app->navigation.current.route; + if (route != TRAINLOG_ROUTE_SESSION_MANUAL && + route != TRAINLOG_ROUTE_SESSION_CURRENT && + route != TRAINLOG_ROUTE_SESSION_GENERATOR) return false; + if (session->form.active) return app_shell_dispatch_session_form(app, key); + if (key == TRAINLOG_KEY_F6 || key == TRAINLOG_KEY_F7 || key == '?') return false; + if (session->phase == TRAINLOG_SESSION_CHOOSE_TYPE) { + if (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN || + key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT) + session->session_type = session->session_type == TRAINLOG_SESSION_TRAINING + ? TRAINLOG_SESSION_MAX_TEST : TRAINLOG_SESSION_TRAINING; + else if (key == TRAINLOG_KEY_ENTER || key == '\n') { + if (trainlog_time_now_rfc3339(session->started_at, + sizeof(session->started_at)) == TRAINLOG_STATUS_OK) { + session->has_draft = true; + session_controller_load_picker(app); + } + } else if (key == TRAINLOG_KEY_ESCAPE || key == 27) + app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS); + return true; + } + if (session->phase == TRAINLOG_SESSION_EXERCISE_PICKER) { + if (key == TRAINLOG_KEY_UP && session->picker_selected > 0U) + --session->picker_selected; + else if (key == TRAINLOG_KEY_DOWN && + session->picker_selected + 1U < session->picker_count) + ++session->picker_selected; + else if (key == 'n' || key == 'N') + exercise_controller_start_create(app, true); + else if (key == TRAINLOG_KEY_ENTER || key == '\n') + (void)session_controller_choose_exercise(session); + else if (key == TRAINLOG_KEY_ESCAPE || key == 27) { + session->replacing_occurrence = false; + session->phase = TRAINLOG_SESSION_DRAFT; + } + return true; + } + if (session->phase == TRAINLOG_SESSION_EQUIPMENT_PICKER) { + if (key == TRAINLOG_KEY_UP && session->equipment_selected > 0U) + --session->equipment_selected; + else if (key == TRAINLOG_KEY_DOWN && + session->equipment_selected < session->equipment_count) + ++session->equipment_selected; + else if ((key == 'x' || key == 'X') && session->selected < session->draft_count) { + TrainlogSessionDraftExercise *draft = &session->drafts[session->selected]; + draft->input.equipment_id[0] = '\0'; + draft->input.load_mode = TRAINLOG_LOAD_NONE; + draft->input.target_has_weight = false; + draft->input.target_weight_kg = 0.0; + draft->target_from_percent_max = false; + for (size_t index = 0U; index < draft->input.set_count; ++index) { + draft->sets[index].has_weight = false; + draft->sets[index].weight_kg = 0.0; + } + draft_bind_input(draft); + session->dirty = true; + session->phase = TRAINLOG_SESSION_DRAFT; + } else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && + session->selected < session->draft_count) { + TrainlogSessionDraftExercise *draft = &session->drafts[session->selected]; + const char *id = session->equipment_selected == 0U ? "" + : app->equipment[session->equipment_selected - 1U].equipment_id; + bool context_changed = strcmp(draft->input.equipment_id, id) != 0; + (void)snprintf(session->drafts[session->selected].input.equipment_id, + sizeof(session->drafts[session->selected].input.equipment_id), "%s", id); + if (session->equipment_selected == 0U) + session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_NONE; + else if (strcmp(app->equipment[session->equipment_selected - 1U].load_semantics, + "assistance") == 0) + session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_ASSISTANCE; + else if (strcmp(app->equipment[session->equipment_selected - 1U].load_semantics, + "external") == 0) + session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_EXTERNAL; + else session->drafts[session->selected].input.load_mode = TRAINLOG_LOAD_NONE; + /* INVARIANT: a calculated target cannot survive an equipment + * context change. Direct manual kg remains user-owned. */ + if (context_changed && draft->target_from_percent_max) { + draft->input.target_has_weight = false; + draft->input.target_weight_kg = 0.0; + draft->target_from_percent_max = false; + } + if (session->drafts[session->selected].input.load_mode == TRAINLOG_LOAD_NONE) { + session->drafts[session->selected].input.target_has_weight = false; + session->drafts[session->selected].input.target_weight_kg = 0.0; + for (size_t index = 0U; index < draft->input.set_count; ++index) { + draft->sets[index].has_weight = false; + draft->sets[index].weight_kg = 0.0; + } + draft_bind_input(draft); + } + session->dirty = true; + session->phase = TRAINLOG_SESSION_DRAFT; + } else if (key == 'n' || key == 'N') { + (void)memset(&session->pending_equipment, 0, + sizeof(session->pending_equipment)); + session->message[0] = '\0'; + session->phase = TRAINLOG_SESSION_EQUIPMENT_CREATE; + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_EQUIPMENT_NAME, ""); + } else if (key == TRAINLOG_KEY_ESCAPE || key == 27) + session->phase = TRAINLOG_SESSION_DRAFT; + session_controller_sync_durability(session); + return true; + } + if (session->phase == TRAINLOG_SESSION_PLANNING && + session->selected < session->draft_count) { + TrainlogSessionDraftExercise *draft = &session->drafts[session->selected]; + if (key == TRAINLOG_KEY_UP && session->planning_field > 0U) + --session->planning_field; + else if (key == TRAINLOG_KEY_DOWN && session->planning_field < 4U) + ++session->planning_field; + else if (key == TRAINLOG_KEY_TAB) + session->planning_field = (session->planning_field + 1U) % 5U; + else if (key == TRAINLOG_KEY_SHIFT_TAB) + session->planning_field = session->planning_field > 0U + ? session->planning_field - 1U : 4U; + else if (key == '%' && session->planning_field == 4U) { + if (draft->input.load_mode != TRAINLOG_LOAD_EXTERNAL || + draft->input.equipment_id[0] == '\0') { + draft->input.target_has_weight = false; + draft->input.target_weight_kg = 0.0; + draft->target_from_percent_max = false; + (void)snprintf(session->message, sizeof(session->message), + "%%MAX indisponible : choisissez une résistance externe compatible."); + } else session_controller_start_form(session, + TRAINLOG_SESSION_FORM_TARGET_PERCENT_MAX, ""); + } else if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') { + char value[48] = ""; + TrainlogSessionFormPurpose purposes[] = { + TRAINLOG_SESSION_FORM_TARGET_SETS, + TRAINLOG_SESSION_FORM_TARGET_REPS, + TRAINLOG_SESSION_FORM_TARGET_DURATION, + TRAINLOG_SESSION_FORM_REST, + TRAINLOG_SESSION_FORM_TARGET_WEIGHT + }; + bool applicable = session->planning_field == 0U + ? draft->input.recording_mode == TRAINLOG_RECORDING_SETS + : session->planning_field == 1U + ? draft->input.recording_mode == TRAINLOG_RECORDING_SETS && + draft->tracking_mode == TRAINLOG_TRACKING_REPS + : session->planning_field == 2U + ? draft->tracking_mode == TRAINLOG_TRACKING_DURATION + : session->planning_field == 3U + ? draft->input.recording_mode == TRAINLOG_RECORDING_SETS + : draft->input.load_mode != TRAINLOG_LOAD_NONE; + if (!applicable) { + (void)snprintf(session->message, sizeof(session->message), + "Ce champ ne s’applique pas au profil de cet exercice."); + return true; + } + if (session->planning_field == 0U) + (void)snprintf(value, sizeof(value), "%d", draft->input.target_sets); + else if (session->planning_field == 1U) + (void)snprintf(value, sizeof(value), "%d", draft->input.target_reps); + else if (session->planning_field == 2U) + (void)snprintf(value, sizeof(value), "%d", draft->input.target_duration_seconds); + else if (session->planning_field == 3U) + (void)snprintf(value, sizeof(value), "%d", draft->input.rest_seconds); + else if (draft->input.target_has_weight) + (void)snprintf(value, sizeof(value), "%.2f", draft->input.target_weight_kg); + session_controller_start_form(session, purposes[session->planning_field], value); + } else if (key == TRAINLOG_KEY_ESCAPE || key == 27 || key == 'b' || key == 'B') + session->phase = TRAINLOG_SESSION_DRAFT; + return true; + } + if (session->phase == TRAINLOG_SESSION_CONFIRM_REMOVE) { + if (key == '1' && session->selected < session->draft_count) { + draft_delete_exercise(session->drafts, &session->draft_count, + session->selected); + if (session->selected >= session->draft_count && session->selected > 0U) + --session->selected; + session->dirty = true; + session->phase = TRAINLOG_SESSION_DRAFT; + } else if (key == '0' || key == TRAINLOG_KEY_ESCAPE || key == 27) + session->phase = TRAINLOG_SESSION_DRAFT; + return true; + } + if (session->phase == TRAINLOG_SESSION_CONFIRM_ABANDON) { + if (key == '1') { + session_controller_clear_draft(session); + app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS); + } else if (key == '0' || key == TRAINLOG_KEY_ESCAPE || key == 27) + session->phase = TRAINLOG_SESSION_DRAFT; + return true; + } + if (session->phase == TRAINLOG_SESSION_CONFIRM_LEAVE) { + if (key == TRAINLOG_KEY_ENTER || key == '\n') { + TrainlogAppRoute pending = session->pending_route; + app_shell_open_route(app, pending); + } else if (key == 'd' || key == 'D') { + TrainlogAppRoute pending = session->pending_route; + session_controller_discard_generator(session); + app_shell_open_route(app, pending); + } else if (key == TRAINLOG_KEY_ESCAPE || key == 27) + session->phase = session->return_phase; + return true; + } + if (session->phase == TRAINLOG_SESSION_MESSAGE) { + if (key == TRAINLOG_KEY_ENTER || key == '\n' || + key == TRAINLOG_KEY_ESCAPE || key == 27) { + session->message[0] = '\0'; + session->phase = session->return_phase != TRAINLOG_SESSION_IDLE + ? session->return_phase : TRAINLOG_SESSION_DRAFT; + } + return true; + } + if (session->phase == TRAINLOG_SESSION_GENERATOR_CONFIG) { + size_t zone_count = trainlog_body_zone_catalog_count(); + if ((key == 'z' || key == 'Z') && zone_count > 0U) { + session->generation_zone_index = + (session->generation_zone_index + 1U) % zone_count; + session->generation_zone = trainlog_body_zone_catalog_at( + session->generation_zone_index); + } else if ((key == 'o' || key == 'O') && + trainlog_session_generation_policy_v1.goal_count > 0U) { + session->generation_goal_index = (session->generation_goal_index + 1U) % + trainlog_session_generation_policy_v1.goal_count; + session->generation_goal = &trainlog_session_generation_policy_v1.goals[ + session->generation_goal_index]; + } else if ((key == 't' || key == 'T') && + trainlog_session_generation_policy_v1.duration_preset_count > 0U) { + session->generation_duration_index = + (session->generation_duration_index + 1U) % + trainlog_session_generation_policy_v1.duration_preset_count; + session->generation_duration_minutes = + trainlog_session_generation_policy_v1.duration_presets_minutes[ + session->generation_duration_index]; + } else if (key == 'g' || key == 'G' || key == TRAINLOG_KEY_ENTER || key == '\n') + (void)session_controller_generate(app); + else if (key == TRAINLOG_KEY_ESCAPE || key == 27) { + session->pending_route = TRAINLOG_ROUTE_SESSIONS; + session->return_phase = TRAINLOG_SESSION_GENERATOR_CONFIG; + session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE; + } + session_controller_sync_durability(session); + return true; + } + if (session->phase == TRAINLOG_SESSION_GENERATOR_WARNING) { + if (key == 'c' || key == 'C' || key == TRAINLOG_KEY_ENTER || key == '\n') { + session->generation_warning_acknowledged = true; + session->phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; + } else if (key == 'z' || key == 'Z') { + session->generated_preview = false; + session->phase = TRAINLOG_SESSION_GENERATOR_CONFIG; + } else if (key == TRAINLOG_KEY_ESCAPE || key == 27) { + session->pending_route = TRAINLOG_ROUTE_SESSIONS; + session->return_phase = TRAINLOG_SESSION_GENERATOR_WARNING; + session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE; + } + session_controller_sync_durability(session); + return true; + } + if (session->phase == TRAINLOG_SESSION_GENERATOR_PREVIEW) { + size_t count = session->generated.exercise_count; + if (key == TRAINLOG_KEY_UP && session->selected > 0U) --session->selected; + else if (key == TRAINLOG_KEY_DOWN && session->selected + 1U < count) + ++session->selected; + else if ((key == '<' || key == ',') && session->selected > 0U) { + TrainlogGeneratedExercise item = session->generated.exercises[session->selected]; + TrainlogGeneratorPreviewItem label = session->generated_items[session->selected]; + session->generated.exercises[session->selected] = + session->generated.exercises[session->selected - 1U]; + session->generated_items[session->selected] = + session->generated_items[session->selected - 1U]; + session->generated.exercises[--session->selected] = item; + session->generated_items[session->selected] = label; + } else if ((key == '>' || key == '.') && session->selected + 1U < count) { + TrainlogGeneratedExercise item = session->generated.exercises[session->selected]; + TrainlogGeneratorPreviewItem label = session->generated_items[session->selected]; + session->generated.exercises[session->selected] = + session->generated.exercises[session->selected + 1U]; + session->generated_items[session->selected] = + session->generated_items[session->selected + 1U]; + session->generated.exercises[++session->selected] = item; + session->generated_items[session->selected] = label; + } else if ((key == 'd' || key == TRAINLOG_KEY_DELETE) && count > 0U) { + for (size_t index = session->selected; index + 1U < count; ++index) { + session->generated.exercises[index] = session->generated.exercises[index + 1U]; + session->generated_items[index] = session->generated_items[index + 1U]; + } + --session->generated.exercise_count; + if (session->selected >= session->generated.exercise_count && session->selected > 0U) + --session->selected; + } else if ((key == 'e' || key == 'E') && count > 0U) { + char value[48]; + (void)snprintf(value, sizeof(value), "%d", + session->generated.exercises[session->selected].target_sets); + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_GENERATED_SETS, value); + } else if (key == '%' && count > 0U) { + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_GENERATED_PERCENT_MAX, ""); + } else if ((key == 'x' || key == 'X') && count > 0U) { + TrainlogGeneratedExercise *generated = + &session->generated.exercises[session->selected]; + generated->has_target_weight = false; + generated->target_weight_kg = 0.0; + generated->planned_load_mode = TRAINLOG_LOAD_NONE; + generated->rationale_count = 1U; + (void)snprintf(generated->rationale_codes[0], + sizeof(generated->rationale_codes[0]), "%s", "numeric_load_absent"); + generated->load_source_session_id[0] = '\0'; + generated->load_source_occurrence_id[0] = '\0'; + generated->load_source_started_at[0] = '\0'; + } else if ((key == 'u' || key == 'U') && count > 0U) { + TrainlogGeneratedExercise *generated = + &session->generated.exercises[session->selected]; + (void)session_controller_requalify_generated(app, session->selected, + generated->target_sets, generated->target_repetitions); + } else if (key == 'g' || key == 'G') { + (void)session_controller_generate(app); + } else if (key == 'a' || key == 'A' || key == TRAINLOG_KEY_ENTER || key == '\n') { + if (session_controller_accept_generator(app)) + app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_CURRENT); + } else if (key == 'q' || key == 'Q' || key == TRAINLOG_KEY_ESCAPE || key == 27) { + session->pending_route = TRAINLOG_ROUTE_SESSIONS; + session->return_phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; + session->phase = TRAINLOG_SESSION_CONFIRM_LEAVE; + } + session_controller_sync_durability(session); + return true; + } + if (session->phase == TRAINLOG_SESSION_ACTUALS && session->draft_count > 0U) { + TrainlogSessionDraftExercise *draft = &session->drafts[session->selected]; + if (key == 'f' || key == 'F' || key == 'b' || key == 'B' || + key == TRAINLOG_KEY_ESCAPE || key == 27) session->phase = TRAINLOG_SESSION_DRAFT; + else if (session->session_type == TRAINLOG_SESSION_MAX_TEST || + draft->input.has_max_weight) { + if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') { + char value[48] = ""; + if (draft->input.has_max_weight) + (void)snprintf(value, sizeof(value), "%.2f", draft->input.max_weight_kg); + session_controller_start_form(session, TRAINLOG_SESSION_FORM_MAX_WEIGHT, value); + } + } else if (draft->input.recording_mode == TRAINLOG_RECORDING_CONTINUOUS) { + if (key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') { + char value[48] = ""; + if (draft->input.continuous_duration_seconds > 0) + (void)snprintf(value, sizeof(value), "%d", + draft->input.continuous_duration_seconds); + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_CONTINUOUS_DURATION, value); + } else if ((key == 's' || key == 'S') && + (draft->input.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U) { + char value[48] = ""; + if (draft->input.continuous_has_speed) + (void)snprintf(value, sizeof(value), "%.2f", + draft->input.continuous_speed_kmh); + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_CONTINUOUS_SPEED, value); + } else if ((key == 'k' || key == 'K') && + (draft->input.data_fields & TRAINLOG_EXERCISE_DATA_DISTANCE_KM) != 0U) { + char value[48] = ""; + if (draft->input.continuous_has_distance) + (void)snprintf(value, sizeof(value), "%.2f", + draft->input.continuous_distance_km); + session_controller_start_form(session, + TRAINLOG_SESSION_FORM_CONTINUOUS_DISTANCE, value); + } + } else if (key == TRAINLOG_KEY_UP && session->set_selected > 0U) + --session->set_selected; + else if (key == TRAINLOG_KEY_DOWN && + session->set_selected + 1U < draft->input.set_count) + ++session->set_selected; + else if (key == TRAINLOG_KEY_TAB || key == TRAINLOG_KEY_SHIFT_TAB || + key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT) + session->set_field = session->set_field == 0U ? 1U : 0U; + else if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) && + draft->input.set_count > 0U) { + for (size_t index = session->set_selected; + index + 1U < draft->input.set_count; ++index) + draft->sets[index] = draft->sets[index + 1U]; + --draft->input.set_count; + if (session->set_selected >= draft->input.set_count && + session->set_selected > 0U) --session->set_selected; + draft_bind_input(draft); session->dirty = true; + } else if ((key == 'a' || key == 'A') && + draft->input.set_count < MAX_SETS_PER_EXERCISE) { + (void)memset(&session->pending_set, 0, sizeof(session->pending_set)); + session->set_selected = draft->input.set_count; + session_controller_start_form(session, TRAINLOG_SESSION_FORM_SET_METRIC, ""); + } else if ((key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') && + session->set_selected < draft->input.set_count) { + char value[48]; + session->pending_set = draft->sets[session->set_selected]; + if (session->set_field == 1U && draft->input.load_mode != TRAINLOG_LOAD_NONE) { + (void)snprintf(value, sizeof(value), "%.2f", session->pending_set.weight_kg); + session_controller_start_form(session, TRAINLOG_SESSION_FORM_SET_WEIGHT, value); + } else { + (void)snprintf(value, sizeof(value), "%d", + draft->tracking_mode == TRAINLOG_TRACKING_DURATION + ? session->pending_set.duration_seconds : session->pending_set.reps); + session_controller_start_form(session, TRAINLOG_SESSION_FORM_SET_METRIC, value); + } + } + session_controller_sync_durability(session); + return true; + } + if (session->phase == TRAINLOG_SESSION_DRAFT) { + session->message[0] = '\0'; + if (key == TRAINLOG_KEY_UP && session->selected > 0U) --session->selected; + else if (key == TRAINLOG_KEY_DOWN && session->selected + 1U < session->draft_count) + ++session->selected; + else if (key == 'a' || key == 'A') { + session->replacing_occurrence = false; + (void)session_controller_load_picker(app); + } else if ((key == 'r' || key == 'R') && session->draft_count > 0U) { + session->replacing_occurrence = true; + (void)session_controller_load_picker(app); + } else if ((key == 'i' || key == 'I') && session->draft_count > 0U) + session_controller_load_equipment(app); + else if ((key == 'p' || key == 'P') && session->draft_count > 0U) { + session->planning_field = 0U; + session->phase = TRAINLOG_SESSION_PLANNING; + } else if ((key == 'd' || key == 'D' || key == TRAINLOG_KEY_DELETE) && + session->draft_count > 0U) + session->phase = TRAINLOG_SESSION_CONFIRM_REMOVE; + else if ((key == TRAINLOG_KEY_ENTER || key == '\n' || key == 'e' || key == 'E') && + session->draft_count > 0U) { + session->set_selected = 0U; + session->set_field = 0U; + session->phase = TRAINLOG_SESSION_ACTUALS; + } else if (key == 'f' || key == 'F') { + TrainlogStatus status = session_controller_save(app); + if (status == TRAINLOG_STATUS_OK) { + app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS_COMPLETED); + return true; + } + if (status != TRAINLOG_STATUS_INVALID_ARGUMENT) + (void)snprintf(session->message, sizeof(session->message), + "Échec de l’enregistrement; le brouillon est conservé."); + } else if (key == 'q' || key == 'Q') + session->phase = TRAINLOG_SESSION_CONFIRM_ABANDON; + else if (key == TRAINLOG_KEY_ESCAPE || key == 27) + app_shell_open_route(app, TRAINLOG_ROUTE_SESSIONS); + session_controller_sync_durability(session); + return true; + } + return true; +} + +static bool app_shell_dispatch_sync(TrainlogAppContext *app, int key) +{ + TrainlogSyncController *sync = &app->sync_controller; + TrainlogSyncScreenAction action; + bool was_confirming; + if (app->navigation.current.route != TRAINLOG_ROUTE_SYNC) return false; + /* INVARIANT: synchronous execution may be re-entered by terminal or test + * callbacks, but no input can launch/probe/open history during that run. */ + if (sync->running) return true; + if (sync->showing_detail) { + size_t visible = app->layout.content.height > 5 + ? (size_t)(app->layout.content.height - 5) : 1U; + if (key == TRAINLOG_KEY_ESCAPE || key == 'b' || key == 'B' || + key == TRAINLOG_KEY_ENTER || key == '\n') { + sync->showing_detail = false; + return true; + } + if (key == TRAINLOG_KEY_UP && sync->detail_scroll > 0U) + --sync->detail_scroll; + else if (key == TRAINLOG_KEY_DOWN && + sync->detail_scroll + visible < sync->detail_line_count) + ++sync->detail_scroll; + else if (key == TRAINLOG_KEY_PAGE_UP) + sync->detail_scroll = sync->detail_scroll > visible + ? sync->detail_scroll - visible : 0U; + else if (key == TRAINLOG_KEY_PAGE_DOWN && + sync->detail_line_count > visible) { + size_t maximum = sync->detail_line_count - visible; + sync->detail_scroll = sync->detail_scroll + visible < maximum + ? sync->detail_scroll + visible : maximum; + } + return true; + } + was_confirming = sync->action.confirming; + action = trainlog_sync_screen_dispatch(&sync->action, + key == TRAINLOG_KEY_ESCAPE ? 27 : key); + if (action.effect == TRAINLOG_SYNC_SCREEN_REFRESH) { + (void)memset(&sync->device, 0, sizeof(sync->device)); + sync->probe_status = (sync->probe != NULL ? sync->probe + : trainlog_sync_probe)( + &sync->device); + sync->probe_known = true; + return true; + } + if (action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM || + action.effect == TRAINLOG_SYNC_SCREEN_CANCEL) return true; + if (action.effect == TRAINLOG_SYNC_SCREEN_RUN) { + TrainlogAppSyncRun run = sync->run != NULL ? sync->run : trainlog_sync_run; + TrainlogStatus run_status; + (void)memset(&sync->report, 0, sizeof(sync->report)); + sync->has_report = false; + sync->running = true; + /* Render before entering the synchronous shared engine. This provides + * immediate progress without a second scheduler or history system. */ + if (app->terminal != NULL) app_shell_render(app); + /* INVARIANT: this is the sole engine call site in the controller and + * every TUI launch has the complete bidirectional intent. */ + run_status = run(TRAINLOG_SYNC_TRIGGER_TUI, false, + TRAINLOG_SYNC_BIDIRECTIONAL, &sync->report); + sync->running = false; + /* WHY: retain the engine's precise diagnostic, but never leave a + * completed failed action at the unhelpful `error=unknown` boundary. */ + if ((!sync->report.success || run_status != TRAINLOG_STATUS_OK) && + sync->report.error[0] == '\0') + (void)snprintf(sync->report.error, sizeof(sync->report.error), "%s", + sync_controller_status_error(run_status)); + sync->has_report = true; + sync_controller_load_history(sync); + (void)memset(&sync->device, 0, sizeof(sync->device)); + sync->probe_status = (sync->probe != NULL ? sync->probe + : trainlog_sync_probe)( + &sync->device); + sync->probe_known = true; + return true; + } + if (was_confirming) return true; + if (key == TRAINLOG_KEY_UP && sync->history_count > 0U) + sync->selected = sync->selected > 0U ? sync->selected - 1U + : sync->history_count - 1U; + else if (key == TRAINLOG_KEY_DOWN && sync->history_count > 0U) + sync->selected = sync->selected + 1U < sync->history_count + ? sync->selected + 1U : 0U; + else if ((key == TRAINLOG_KEY_ENTER || key == '\n') && + sync->history_count > 0U) + sync_controller_open_detail(sync); + else return false; + return true; +} + +static void app_shell_dispatch(TrainlogAppContext *app, int key) +{ + const TrainlogAction *action; + if (!app->layout.usable) { if (key == 'q' || key == 'Q') app->running = false; return; } + if (trainlog_overlays_top(&app->overlays) != NULL) { + app_shell_dispatch_overlay(app, key); return; + } + /* Local forms own their editor keys before shell aliases or focus controls. */ + if (app->focus == TRAINLOG_FOCUS_EDITOR) { + if (app_shell_dispatch_exercise_controller(app, key)) return; + if (app_shell_dispatch_equipment_controller(app, key)) return; + if (app_shell_dispatch_body_controller(app, key)) return; + if (app_shell_dispatch_profile_controller(app, key)) return; + if (app_shell_dispatch_session(app, key)) return; + } + /* Local editor input precedes every route or shell alias. Home/End and + * printable digits therefore remain text editing while search is focused. */ + if (app->search.focused && app_shell_is_list_route(app->navigation.current.route)) { + bool changed = false; + if (key == TRAINLOG_KEY_TAB || key == TRAINLOG_KEY_SHIFT_TAB) { + app->search.focused = false; + app->focus = key == TRAINLOG_KEY_SHIFT_TAB + ? TRAINLOG_FOCUS_NAVIGATION : TRAINLOG_FOCUS_CONTENT; + return; + } + if (key == TRAINLOG_KEY_F6 || key == TRAINLOG_KEY_F7) { + /* Let a focused search invoke shell controls; the overlay restores + * TRAINLOG_FOCUS_SEARCH and re-enables this editor on close. */ + app->search.focused = false; + } else { + if (key == TRAINLOG_KEY_ENTER || key == '\n') { + app->search.focused = false; app->focus = TRAINLOG_FOCUS_CONTENT; return; + } + if (key == TRAINLOG_KEY_ESCAPE || key == 27) { + bool closed = trainlog_search_escape(&app->search); + if (closed) app->focus = TRAINLOG_FOCUS_CONTENT; + app_shell_refresh_list(app); return; + } + if (key == TRAINLOG_KEY_HOME) trainlog_search_home(&app->search); + else if (key == TRAINLOG_KEY_END) trainlog_search_end(&app->search); + else if (key == TRAINLOG_KEY_LEFT) trainlog_search_left(&app->search); + else if (key == TRAINLOG_KEY_RIGHT) trainlog_search_right(&app->search); + else if (key == TRAINLOG_KEY_BACKSPACE || key == TRAINLOG_KEY_DELETE) + changed = trainlog_search_backspace(&app->search); + 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(&app->search, + encoded, (size_t)bytes); + } + if (changed) app_shell_refresh_list(app); + return; + } + } + if (key == TRAINLOG_KEY_F6) { + if (app->layout.sidebar_visible) { + if (app->focus == TRAINLOG_FOCUS_NAVIGATION) + app->focus = app->navigation_restore_focus == TRAINLOG_FOCUS_NAVIGATION + ? TRAINLOG_FOCUS_CONTENT : app->navigation_restore_focus; + else { + app->navigation_restore_focus = app->focus; + app->focus = TRAINLOG_FOCUS_NAVIGATION; + } + } + else (void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_NAVIGATION, + app->focus, app->navigation.current.stable_id); + return; + } + if (key == TRAINLOG_KEY_F7) { + (void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_ACTIONS, + app->focus, app->navigation.current.stable_id); return; + } + if (key == '?') { + (void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_HELP, + app->focus, app->navigation.current.stable_id); return; + } + if (key == TRAINLOG_KEY_TAB || key == TRAINLOG_KEY_SHIFT_TAB) { + if (app->layout.sidebar_visible) { + if (key == TRAINLOG_KEY_SHIFT_TAB) + app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION + ? TRAINLOG_FOCUS_ACTIONS : app->focus == TRAINLOG_FOCUS_ACTIONS + ? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT + ? TRAINLOG_FOCUS_SEARCH : TRAINLOG_FOCUS_NAVIGATION; + else app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION + ? TRAINLOG_FOCUS_SEARCH : app->focus == TRAINLOG_FOCUS_SEARCH + ? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT + ? TRAINLOG_FOCUS_ACTIONS : TRAINLOG_FOCUS_NAVIGATION; + } else if (key == TRAINLOG_KEY_SHIFT_TAB) + app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION + ? TRAINLOG_FOCUS_ACTIONS : app->focus == TRAINLOG_FOCUS_ACTIONS + ? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT + ? TRAINLOG_FOCUS_SEARCH : TRAINLOG_FOCUS_NAVIGATION; + else app->focus = app->focus == TRAINLOG_FOCUS_NAVIGATION + ? TRAINLOG_FOCUS_SEARCH : app->focus == TRAINLOG_FOCUS_SEARCH + ? TRAINLOG_FOCUS_CONTENT : app->focus == TRAINLOG_FOCUS_CONTENT + ? TRAINLOG_FOCUS_ACTIONS : TRAINLOG_FOCUS_NAVIGATION; + app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH; + if (app->search.focused) app->search.open = true; + return; + } + if (app->focus == TRAINLOG_FOCUS_NAVIGATION) { + if (!app->layout.sidebar_visible) { + if (key == TRAINLOG_KEY_ENTER || key == '\n') + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_NAVIGATION, TRAINLOG_FOCUS_NAVIGATION, + app->navigation.current.stable_id); + else if (key == TRAINLOG_KEY_ESCAPE) + app->focus = app->navigation_restore_focus == TRAINLOG_FOCUS_NAVIGATION + ? TRAINLOG_FOCUS_CONTENT : app->navigation_restore_focus; + /* INVARIANT: compact Navigation owns its input and cannot fall + * through to the content route controller. */ + return; + } + size_t count = sizeof(shell_sections) / sizeof(shell_sections[0]); + if (key == TRAINLOG_KEY_UP && app->navigation_selected > 0U) --app->navigation_selected; + else if (key == TRAINLOG_KEY_DOWN && app->navigation_selected + 1U < count) + ++app->navigation_selected; + else if (key == TRAINLOG_KEY_ENTER || key == '\n') { + app_shell_open_route(app, shell_sections[app->navigation_selected]); + app->focus = TRAINLOG_FOCUS_CONTENT; + } else if (key == TRAINLOG_KEY_ESCAPE) + app->focus = app->navigation_restore_focus == TRAINLOG_FOCUS_NAVIGATION + ? TRAINLOG_FOCUS_CONTENT : app->navigation_restore_focus; + app->search.focused = app->focus == TRAINLOG_FOCUS_SEARCH; + /* CONTRACT: a key delivered to persistent navigation is consumed + * exactly once. Escape closes focus without also navigating Back. */ + return; + } + if (app->focus == TRAINLOG_FOCUS_ACTIONS) { + if (key == TRAINLOG_KEY_ENTER || key == '\n') + (void)trainlog_overlays_push(&app->overlays, TRAINLOG_OVERLAY_ACTIONS, + TRAINLOG_FOCUS_ACTIONS, app->navigation.current.stable_id); + else if (key == TRAINLOG_KEY_ESCAPE) + app->focus = TRAINLOG_FOCUS_CONTENT; + /* INVARIANT: the rendered Actions control owns its input. */ + return; + } + if (app_shell_dispatch_exercise_controller(app, key)) return; + if (app_shell_dispatch_equipment_controller(app, key)) return; + if (app_shell_dispatch_body_controller(app, key)) return; + if (app_shell_dispatch_profile_controller(app, key)) return; + if (app_shell_dispatch_session(app, key)) return; + if (app_shell_dispatch_sync(app, key)) return; + if ((app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS || + app->navigation.current.route == TRAINLOG_ROUTE_STATS) && + (key == TRAINLOG_KEY_UP || key == TRAINLOG_KEY_DOWN)) { + size_t count = app->navigation.current.route == TRAINLOG_ROUTE_SESSIONS ? 4U : 3U; + if (key == TRAINLOG_KEY_UP && app->content_selected > 0U) --app->content_selected; + if (key == TRAINLOG_KEY_DOWN && app->content_selected + 1U < count) ++app->content_selected; + return; + } + if (app_shell_is_list_route(app->navigation.current.route)) { + if ((key == 'a' || key == 'A') && + app->navigation.current.route == TRAINLOG_ROUTE_EXERCISES) { + exercise_controller_start_create(app, false); + return; + } + if ((key == 'n' || key == 'N') && + app->navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT) { + equipment_controller_start(app); + return; + } + if (key == TRAINLOG_KEY_UP) { + trainlog_list_move(&app->list, app->stable_ids, -1); return; + } + if (key == TRAINLOG_KEY_DOWN) { + trainlog_list_move(&app->list, app->stable_ids, 1); return; + } + if (key == TRAINLOG_KEY_PAGE_UP) { + trainlog_list_move(&app->list, app->stable_ids, + -(int)app->list.visible_rows); return; + } + if (key == TRAINLOG_KEY_PAGE_DOWN) { + trainlog_list_move(&app->list, app->stable_ids, + (int)app->list.visible_rows); return; + } + if ((key == 'z' || key == 'Z') && + app->navigation.current.route == TRAINLOG_ROUTE_EXERCISES) { + size_t zone_count = trainlog_body_zone_catalog_count(); + ++app->exercise_zone_filter; + if (app->exercise_zone_filter > (int)zone_count) + app->exercise_zone_filter = -1; + app_shell_refresh_list(app); return; + } + if ((key == 'x' || key == 'X') && + app->navigation.current.route == TRAINLOG_ROUTE_EXERCISES) { + trainlog_search_init(&app->search); app->exercise_zone_filter = -1; + app_shell_refresh_list(app); return; + } + } + if (app->navigation.current.route == TRAINLOG_ROUTE_BODY) { + if (key == 'a' || key == 'A') { + body_controller_start_add(app); return; + } + if ((key == 'e' || key == 'E') && app->loaded_count > 0U) { + (void)body_controller_start_edit(app, + app->body_records[app->list.selected_index].observation_id); + return; + } + if (key == 'v' || key == 'V') { + app_shell_open_route(app, TRAINLOG_ROUTE_BODY_ANALYTICS); return; + } + if (key == 'm' || key == 'M') { + app_shell_open_route(app, TRAINLOG_ROUTE_BODY_METRIC); return; + } + if (key == 'g' || key == 'G') { + app->body_metric_selected = 0U; + app_shell_open_route(app, TRAINLOG_ROUTE_BODY_TRENDS); return; + } + if (key == 'o' || key == 'O') { + app_shell_open_route(app, TRAINLOG_ROUTE_BODY_GLOBAL); return; + } + } + if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_DETAIL) { + if (key == 'e' || key == 'E') { + (void)body_controller_start_edit(app, + app->body_detail.observation_id); return; + } + if (key == TRAINLOG_KEY_UP && app->content_scroll > 0U) { + --app->content_scroll; return; + } + if (key == TRAINLOG_KEY_DOWN && app->content_scroll + 1U < 14U) { + ++app->content_scroll; return; + } + } + if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_METRIC) { + if (key == TRAINLOG_KEY_LEFT) { + app->body_metric_selected = app->body_metric_selected > 0U + ? app->body_metric_selected - 1U : 13U; + app_shell_load_body_metric(app); return; + } + if (key == TRAINLOG_KEY_RIGHT) { + app->body_metric_selected = (app->body_metric_selected + 1U) % 14U; + app_shell_load_body_metric(app); return; + } + } + if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_GLOBAL) { + if (key == TRAINLOG_KEY_UP) + app->body_global_selected = app->body_global_selected > 0U + ? app->body_global_selected - 1U : 13U; + else if (key == TRAINLOG_KEY_DOWN) + app->body_global_selected = (app->body_global_selected + 1U) % 14U; + else if (key == ' ') + app->body_global_enabled[app->body_global_selected] = + !app->body_global_enabled[app->body_global_selected]; + else goto body_global_unhandled; + return; +body_global_unhandled:; + } + if (app->navigation.current.route == TRAINLOG_ROUTE_BODY_ANALYTICS) { + if (key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_RIGHT) { + app->content_selected = app->content_selected == 0U ? 1U : 0U; + return; + } + if (key == 'p' || key == 'P') { + profile_controller_start(app, TRAINLOG_ROUTE_BODY_ANALYTICS); return; + } + } + if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_DETAIL) { + size_t equipment_count = app->exercise_explicit_equipment_count + + app->exercise_historic_equipment_count; + if (key == TRAINLOG_KEY_UP && app->exercise_equipment_selected > 0U) { + --app->exercise_equipment_selected; + return; + } + if (key == TRAINLOG_KEY_DOWN && + app->exercise_equipment_selected + 1U < equipment_count) { + ++app->exercise_equipment_selected; + return; + } + if ((key == TRAINLOG_KEY_ENTER || key == '\n') && equipment_count > 0U) { + size_t selected = app->exercise_equipment_selected; + app->equipment_detail = selected < app->exercise_explicit_equipment_count + ? app->exercise_explicit_equipment[selected] + : app->exercise_historic_equipment[ + selected - app->exercise_explicit_equipment_count]; + (void)trainlog_navigation_open(&app->navigation, + TRAINLOG_ROUTE_EQUIPMENT_DETAIL, + app->equipment_detail.equipment_id); + return; + } + if (key == 'k' || key == 'K') { + (void)trainlog_navigation_open(&app->navigation, + TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE, + app->exercise_detail.exercise_id); return; + } + if (key == 'p' || key == 'P') { + app_shell_open_exercise_analytics(app, + TRAINLOG_ROUTE_EXERCISE_PERFORMANCE); return; + } + if (key == 'm' || key == 'M') { + app_shell_open_exercise_analytics(app, TRAINLOG_ROUTE_EXERCISE_MAX); return; + } + if (key == 'e' || key == 'E') { + exercise_controller_start_edit(app); + return; + } + if (key == 'u' || key == 'U') { + exercise_controller_start_merge(app); + return; + } + } + if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE) { + size_t page = app->layout.content.height > 4 + ? (size_t)(app->layout.content.height - 4) : 1U; + if (key == TRAINLOG_KEY_UP && app->content_scroll > 0U) --app->content_scroll; + else if (key == TRAINLOG_KEY_DOWN) ++app->content_scroll; + else if (key == TRAINLOG_KEY_PAGE_UP) + app->content_scroll = app->content_scroll > page ? app->content_scroll - page : 0U; + else if (key == TRAINLOG_KEY_PAGE_DOWN) app->content_scroll += page; + else if (key == 'k' || key == 'K') (void)app_shell_back(app); + else goto shell_nonknowledge_input; + return; + } +shell_nonknowledge_input: + if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE || + app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_MAX) { + size_t page = app->layout.content.height > 10 + ? (size_t)(app->layout.content.height - 10) : 1U; + if (key == TRAINLOG_KEY_UP && app->content_scroll > 0U) --app->content_scroll; + else if (key == TRAINLOG_KEY_DOWN && + app->content_scroll + 1U < app->performance_count) ++app->content_scroll; + else if (key == TRAINLOG_KEY_PAGE_UP) + app->content_scroll = app->content_scroll > page ? app->content_scroll - page : 0U; + else if (key == TRAINLOG_KEY_PAGE_DOWN && app->performance_count > 0U) + app->content_scroll = app->content_scroll + page < app->performance_count + ? app->content_scroll + page : app->performance_count - 1U; + else if ((key == 'r' || key == 'R') && + app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_MAX) + app->max_rounding_index = (app->max_rounding_index + 1U) % 4U; + else goto shell_nonanalytics_input; + return; + } +shell_nonanalytics_input: + if (app->navigation.current.route == TRAINLOG_ROUTE_SESSION_DETAIL && + !app->session_detail_error) { + TrainlogPersistedExerciseDetail *entry = app->session_entry_count > 0U + ? &app->session_entries[app->session_entry_selected] : NULL; + if ((key == TRAINLOG_KEY_LEFT || key == TRAINLOG_KEY_UP) && + app->session_entry_count > 0U) { + app->session_entry_selected = app->session_entry_selected > 0U + ? app->session_entry_selected - 1U : app->session_entry_count - 1U; + app->session_set_scroll = 0U; return; + } + if ((key == TRAINLOG_KEY_RIGHT || key == TRAINLOG_KEY_DOWN) && + app->session_entry_count > 0U) { + app->session_entry_selected = (app->session_entry_selected + 1U) % + app->session_entry_count; + app->session_set_scroll = 0U; return; + } + if (key == TRAINLOG_KEY_PAGE_UP && app->session_set_scroll > 0U) { + --app->session_set_scroll; return; + } + if (key == TRAINLOG_KEY_PAGE_DOWN && entry != NULL && + app->session_set_scroll + 1U < entry->actual_set_count) { + ++app->session_set_scroll; return; + } + if ((key == 'i' || key == 'I') && entry != NULL && + entry->equipment_id[0] != '\0') { + if (trainlog_database_resolve_equipment(app->database, + entry->equipment_id, &app->equipment_detail) == TRAINLOG_STATUS_OK) + (void)trainlog_navigation_open(&app->navigation, + TRAINLOG_ROUTE_EQUIPMENT_DETAIL, + app->equipment_detail.equipment_id); + return; + } + if (key == 'e' || key == 'E') { + char session_id[TRAINLOG_ID_MAX + 1U]; + (void)snprintf(session_id, sizeof(session_id), "%s", + app->session_detail.session_id); + if (app->session.has_draft) { + app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_CURRENT); + (void)snprintf(app->session.message, sizeof(app->session.message), + "Une séance est déjà en cours; elle n’a pas été remplacée."); + } else { + TrainlogSessionSummary persisted; + if (load_persisted_draft(app->database, session_id, &persisted, + app->session.drafts, &app->session.draft_count)) { + app->session.has_draft = true; + app->session.dirty = false; + app->session.correcting = true; + app->session.session_type = persisted.session_type; + (void)snprintf(app->session.persisted_session_id, + sizeof(app->session.persisted_session_id), "%s", session_id); + app->session.phase = TRAINLOG_SESSION_DRAFT; + session_controller_sync_durability(&app->session); + app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_CURRENT); + } + } + return; + } + } + if (key == TRAINLOG_KEY_ENTER || key == '\n') { app_shell_primary(app); return; } + if (key == TRAINLOG_KEY_HOME) key = '0'; + else if (key == TRAINLOG_KEY_F1) key = '1'; + else if (key == TRAINLOG_KEY_F2) key = '2'; + else if (key == TRAINLOG_KEY_F3) key = '3'; + else if (key == TRAINLOG_KEY_F4) key = '4'; + else if (key == TRAINLOG_KEY_F5) key = '5'; + if ((key == 'g' || key == 'G') && app->navigation.current.route == TRAINLOG_ROUTE_HOME) { + app_shell_open_route(app, TRAINLOG_ROUTE_SESSION_GENERATOR); return; + } + action = trainlog_actions_find_key(&app->actions, key); + if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_SEARCH && + app_shell_is_list_route(app->navigation.current.route)) { + /* CONTRACT: the physical '/' follows input adapter -> top-level action + * registry -> focus/search -> list filter. F7 invokes the same stable + * action and Escape retains the established clear-then-close behavior. */ + app->search.open = true; + app->search.focused = true; + app->focus = TRAINLOG_FOCUS_SEARCH; + return; + } + if (action != NULL && action->intent == TRAINLOG_INTENT_OPEN_ROUTE) { + app_shell_open_route(app, action->route); return; + } + else if (key == TRAINLOG_KEY_ESCAPE || key == 'b' || key == 'B') { + if (!app_shell_back(app)) app_shell_open_route(app, TRAINLOG_ROUTE_HOME); + } else if ((key == 'q' || key == 'Q') && app->navigation.current.route == TRAINLOG_ROUTE_HOME) { + session_controller_sync_durability(&app->session); + if (app->session.has_draft || app->session.generated_preview || + app->session.generation_zone != NULL || + app->session.durability.transient_form_dirty) { + app->quit_confirmation = true; + (void)trainlog_overlays_push(&app->overlays, + TRAINLOG_OVERLAY_CONFIRMATION, app->focus, + app->navigation.current.stable_id); + } else app->running = false; } } int trainlog_tui_run(TrainlogDatabase *database) { + TrainlogAppContext app; if (database == NULL) { return 1; } (void)setlocale(LC_ALL, ""); - tui_terminal = trainlog_terminal_create(); - if (tui_terminal == NULL) { + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.terminal = trainlog_terminal_create(); + if (app.terminal == NULL) { return 1; } - trainlog_terminal_cursor_visible(tui_terminal, false); - - for (;;) { - DashboardAction action; - - if (trainlog_terminal_rows(tui_terminal) < 20 || trainlog_terminal_columns(tui_terminal) < 72) { - int key; - - trainlog_terminal_erase(tui_terminal); - trainlog_terminal_printf(tui_terminal, - 1, - 2, - "Terminal trop petit — minimum 72x20." - ); - trainlog_terminal_printf(tui_terminal, 3, 2, "q pour quitter"); - trainlog_terminal_render(tui_terminal); - - key = trainlog_terminal_get_key(tui_terminal); - if (key == 'q' || key == 'Q') { + trainlog_terminal_cursor_visible(app.terminal, false); + trainlog_navigation_init(&app.navigation); + trainlog_overlays_init(&app.overlays); + trainlog_list_init(&app.list); + trainlog_search_init(&app.search); + trainlog_sync_screen_state_init(&app.sync_controller.action); + app.sync_controller.probe_status = TRAINLOG_STATUS_NOT_FOUND; + app.sync_controller.probe = trainlog_sync_probe; + app.sync_controller.run = trainlog_sync_run; + app.exercise_zone_filter = -1; + app.focus = TRAINLOG_FOCUS_CONTENT; + app.running = true; + /* CONTRACT: this is the single shell event pump. Controllers return to it + * after an explicit action; navigation and resize never write domain data. */ + while (app.running) { + int key; + app_shell_actions(&app); + app_shell_render(&app); + if (!app.running) break; + key = trainlog_terminal_get_key(app.terminal); + if (key == TRAINLOG_KEY_NONE) continue; + if (key == TRAINLOG_KEY_RESIZE) { + if (!trainlog_terminal_refresh_geometry(app.terminal)) { + app.running = false; break; } continue; } - - action = screen_dashboard(database); - - switch (action) { - case DASHBOARD_NEW_SESSION: - screen_new_session(database); - break; - case DASHBOARD_GENERATE_SESSION: - screen_session_generator(database); - break; - case DASHBOARD_HISTORY: - screen_history(database); - break; - case DASHBOARD_EXERCISES: - screen_exercises(database); - break; - case DASHBOARD_EQUIPMENT: - screen_equipment(database); - break; - case DASHBOARD_BODY: - screen_body(database); - break; - case DASHBOARD_SYNC: - screen_sync(database); - break; - case DASHBOARD_QUIT: - trainlog_terminal_destroy(tui_terminal); - tui_terminal = NULL; - return 0; - default: - break; - } + app_shell_dispatch(&app, key); } - - trainlog_terminal_destroy(tui_terminal); - tui_terminal = NULL; + app_shell_destroy_surfaces(&app); + app_shell_release_session_detail(&app); + trainlog_terminal_destroy(app.terminal); return 0; } diff --git a/tui/tests/test_app_shell.c b/tui/tests/test_app_shell.c new file mode 100644 index 0000000..63002f3 --- /dev/null +++ b/tui/tests/test_app_shell.c @@ -0,0 +1,222 @@ +/** + * @file test_app_shell.c + * @brief APP_SHELL_V1 state and geometry contract regressions. + */ + +#include +#include + +#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; +} diff --git a/tui/tests/test_body_zones.c b/tui/tests/test_body_zones.c index 598ad57..63ab7e5 100644 --- a/tui/tests/test_body_zones.c +++ b/tui/tests/test_body_zones.c @@ -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); diff --git a/tui/tests/test_custom_equipment.c b/tui/tests/test_custom_equipment.c index b55bebc..b77fbeb 100644 --- a/tui/tests/test_custom_equipment.c +++ b/tui/tests/test_custom_equipment.c @@ -4,6 +4,8 @@ */ #include +#include +#include #include #include #include @@ -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; } diff --git a/tui/tests/test_database.c b/tui/tests/test_database.c index 17af653..ea87b1c 100644 --- a/tui/tests/test_database.c +++ b/tui/tests/test_database.c @@ -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; diff --git a/tui/tests/test_measured_max.c b/tui/tests/test_measured_max.c index f697401..d50c2e3 100644 --- a/tui/tests/test_measured_max.c +++ b/tui/tests/test_measured_max.c @@ -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, diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 180998f..a0758bf 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -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; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 34a3823..5cff594 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -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); diff --git a/tui/tests/test_sync_body_zone_wiring.c b/tui/tests/test_sync_body_zone_wiring.c new file mode 100644 index 0000000..dd51902 --- /dev/null +++ b/tui/tests/test_sync_body_zone_wiring.c @@ -0,0 +1,614 @@ +#include +#include +#include +#include +#include +#include + +#include + +#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; +} diff --git a/tui/tests/test_sync_screen_action.c b/tui/tests/test_sync_screen_action.c index 4c7765f..e921f45 100644 --- a/tui/tests/test_sync_screen_action.c +++ b/tui/tests/test_sync_screen_action.c @@ -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; diff --git a/tui/tests/test_terminal_input.c b/tui/tests/test_terminal_input.c index 599900c..6bf4d0d 100644 --- a/tui/tests/test_terminal_input.c +++ b/tui/tests/test_terminal_input.c @@ -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); diff --git a/tui/tests/test_training_context.c b/tui/tests/test_training_context.c index 710e906..dcf8c01 100644 --- a/tui/tests/test_training_context.c +++ b/tui/tests/test_training_context.c @@ -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); diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c index 9328161..7a59fa1 100644 --- a/tui/tests/test_tui_workflows.c +++ b/tui/tests/test_tui_workflows.c @@ -3,6 +3,7 @@ * @brief Scripted regressions through the production TUI workflow functions. */ +#include #include /* The workflows remain file-private in production. Including their translation @@ -20,23 +21,16 @@ struct TrainlogTerminal { int columns; bool coordinate_overflow; bool text_overflow; + size_t surface_draw_count; }; struct TrainlogPanel { int unused; }; +struct TrainlogSurface { TrainlogTerminal *terminal; }; #define CHECK(condition) do { if (!(condition)) { \ (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \ __FILE__, __LINE__, #condition); return false; } } while (0) -static void script(TrainlogTerminal *terminal, const int *events, size_t count) -{ - (void)memset(terminal, 0, sizeof(*terminal)); - (void)memcpy(terminal->events, events, count * sizeof(events[0])); - terminal->event_count = count; - terminal->rows = 24; - terminal->columns = 80; -} - TrainlogTerminal *trainlog_terminal_create(void) { return NULL; } void trainlog_terminal_destroy(TrainlogTerminal *terminal) { (void)terminal; } int trainlog_terminal_rows(const TrainlogTerminal *terminal) { return terminal->rows; } @@ -104,6 +98,8 @@ bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint, return true; } bool trainlog_terminal_push_key(TrainlogTerminal *terminal, int key) { (void)terminal; (void)key; return false; } +bool trainlog_terminal_refresh_geometry(TrainlogTerminal *terminal) +{ (void)terminal; return true; } TrainlogPanel *tui_panel_create(TrainlogTerminal *terminal, int height, int width, int top, int left) { (void)terminal; (void)height; (void)width; (void)top; (void)left; return NULL; } void tui_panel_destroy(TrainlogPanel *panel) { (void)panel; } void tui_panel_box(TrainlogPanel *panel) { (void)panel; } @@ -111,205 +107,83 @@ void tui_panel_style_on(TrainlogPanel *panel, TrainlogTextStyle style) { (void)p void tui_panel_style_off(TrainlogPanel *panel, TrainlogTextStyle style) { (void)panel; (void)style; } void tui_panel_print(TrainlogPanel *panel, int row, int column, const char *format, ...) { (void)panel; (void)row; (void)column; (void)format; } void tui_panel_commit(TrainlogPanel *panel) { (void)panel; } - -static bool test_assistance_creation_labels(void) +TrainlogSurface *trainlog_surface_create(TrainlogTerminal *terminal, + const char *name, int top, int left, int height, int width) { - TrainlogDatabase *database = NULL; - TrainlogExercise created; - TrainlogSessionExerciseInput input; - TrainlogSessionDraftExercise draft; - TrainlogSetInput sets[4]; - TrainlogTerminal terminal; - const int planning_events[] = { - TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER, - '3', '\n', '2', '0', '\n', '\n', '\n', '\n' - }; - const int table_events[] = { - 'a', '5', '\n', TRAINLOG_KEY_RIGHT, TRAINLOG_KEY_ENTER, - '1', '5', '\n', 'f' - }; - - CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(trainlog_catalog_create_exercise_profiled(database, "Tractions assistées", - TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U, &created) == - TRAINLOG_STATUS_OK); - script(&terminal, planning_events, - sizeof(planning_events) / sizeof(planning_events[0])); - tui_terminal = &terminal; - CHECK(build_session_exercise(database, TRAINLOG_SESSION_TRAINING, &input, - sets, sizeof(sets) / sizeof(sets[0]))); - CHECK(input.load_mode == TRAINLOG_LOAD_ASSISTANCE); - CHECK(input.target_weight_kg == 20.0 && input.set_count == 0U); - CHECK(strstr(terminal.output, "Assistance cible kg") != NULL); - CHECK(strstr(terminal.output, "Séries réalisées") == NULL); - CHECK(strstr(terminal.output, "Séries réellement faites") == NULL); - CHECK(strstr(terminal.output, "Charge cible kg") == NULL); - - (void)memset(&draft, 0, sizeof(draft)); - draft.input = input; - draft.input.sets = draft.sets; - draft.tracking_mode = TRAINLOG_TRACKING_REPS; - (void)snprintf(draft.name, sizeof(draft.name), "%s", created.name); - script(&terminal, table_events, - sizeof(table_events) / sizeof(table_events[0])); - draft_edit_sets(&draft); - CHECK(draft.input.set_count == 1U); - CHECK(draft.sets[0].reps == 5); - CHECK(draft.sets[0].reps != draft.input.target_reps); - CHECK(draft.sets[0].has_weight && draft.sets[0].weight_kg == 15.0); - CHECK(strstr(terminal.output, "Assistance") != NULL); - CHECK(strstr(terminal.output, "Charge kg (vide") == NULL); - tui_terminal = NULL; - trainlog_database_close(database); - return true; + static TrainlogSurface surface; + (void)name; (void)top; (void)left; (void)height; (void)width; + surface.terminal = terminal; return &surface; } - -static bool test_duration_creation_starts_empty(void) +bool trainlog_surface_set_rect(TrainlogSurface *surface, int top, int left, + int height, int width) { - TrainlogDatabase *database = NULL; - TrainlogExercise created; - TrainlogSessionExerciseInput input; - TrainlogSetInput sets[4]; - TrainlogTerminal terminal; - const int planning_events[] = { - TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER, - TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER, - TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER - }; - - CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(trainlog_catalog_create_exercise_profiled(database, "Gainage", - TRAINLOG_TRACKING_DURATION, TRAINLOG_RECORDING_SETS, 0U, &created) == - TRAINLOG_STATUS_OK); - script(&terminal, planning_events, - sizeof(planning_events) / sizeof(planning_events[0])); - tui_terminal = &terminal; - CHECK(build_session_exercise(database, TRAINLOG_SESSION_TRAINING, &input, - sets, sizeof(sets) / sizeof(sets[0]))); - CHECK(input.target_duration_seconds == 45); - CHECK(input.set_count == 0U); - CHECK(strstr(terminal.output, "Séries réellement faites") == NULL); - CHECK(strstr(terminal.output, "Durée réalisée") == NULL); - tui_terminal = NULL; - trainlog_database_close(database); - return true; + (void)surface; (void)top; (void)left; (void)height; (void)width; return true; } - -static bool test_append_requires_actual_and_rolls_back(void) +void trainlog_surface_destroy(TrainlogSurface *surface) { (void)surface; } +void trainlog_surface_erase(TrainlogSurface *surface) { (void)surface; } +void trainlog_surface_set_role(TrainlogSurface *surface, + TrainlogColorRole foreground, unsigned background_rgb, TrainlogTextStyle style) { - TrainlogSessionDraftExercise draft; - TrainlogSessionDraftExercise duration_draft; - TrainlogTerminal terminal; - const int accepted[] = {'7', '\n'}; - const int duration_accepted[] = {'1', ':', '3', '0', '\n'}; - const int cancelled[] = {27}; - - (void)memset(&draft, 0, sizeof(draft)); - draft.input.recording_mode = TRAINLOG_RECORDING_SETS; - draft.tracking_mode = TRAINLOG_TRACKING_REPS; - draft.input.target_reps = 12; - draft.input.set_count = 1U; - draft.sets[0].reps = 5; - - script(&terminal, accepted, sizeof(accepted) / sizeof(accepted[0])); - tui_terminal = &terminal; - CHECK(draft_append_incomplete_set(&draft)); - CHECK(draft.input.set_count == 2U); - CHECK(draft.sets[0].reps == 5); - CHECK(draft.sets[1].reps == 7); - CHECK(draft.sets[1].reps != draft.input.target_reps); - - script(&terminal, cancelled, sizeof(cancelled) / sizeof(cancelled[0])); - CHECK(!draft_append_incomplete_set(&draft)); - CHECK(draft.input.set_count == 2U); - CHECK(draft.sets[0].reps == 5 && draft.sets[1].reps == 7); - - (void)memset(&duration_draft, 0, sizeof(duration_draft)); - duration_draft.input.recording_mode = TRAINLOG_RECORDING_SETS; - duration_draft.tracking_mode = TRAINLOG_TRACKING_DURATION; - duration_draft.input.target_duration_seconds = 45; - script(&terminal, duration_accepted, - sizeof(duration_accepted) / sizeof(duration_accepted[0])); - CHECK(draft_append_incomplete_set(&duration_draft)); - CHECK(duration_draft.input.set_count == 1U); - CHECK(duration_draft.sets[0].duration_seconds == 90); - CHECK(duration_draft.sets[0].duration_seconds != - duration_draft.input.target_duration_seconds); - - script(&terminal, cancelled, sizeof(cancelled) / sizeof(cancelled[0])); - CHECK(!draft_append_incomplete_set(&duration_draft)); - CHECK(duration_draft.input.set_count == 1U); - CHECK(duration_draft.sets[0].duration_seconds == 90); - tui_terminal = NULL; - return true; + (void)surface; (void)foreground; (void)background_rgb; (void)style; } - -static bool test_empty_sets_cannot_finish(void) +void trainlog_surface_printf(TrainlogSurface *surface, int row, int column, + const char *format, ...) { - TrainlogSessionDraftExercise draft; - TrainlogTerminal terminal; - TrainlogDatabase *database = NULL; - size_t count = 1U; - const int events[] = {'f', 'x', 'q'}; - - CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - (void)memset(&draft, 0, sizeof(draft)); - draft.input.recording_mode = TRAINLOG_RECORDING_SETS; - draft.tracking_mode = TRAINLOG_TRACKING_REPS; - (void)snprintf(draft.name, sizeof(draft.name), "Squat"); - script(&terminal, events, sizeof(events) / sizeof(events[0])); - tui_terminal = &terminal; - CHECK(!edit_session_draft(database, &draft, &count, - TRAINLOG_SESSION_TRAINING)); - CHECK(count == 1U && draft.input.set_count == 0U); - CHECK(strstr(terminal.output, - "Ajoutez au moins une série réalisée pour Squat.") != NULL); - tui_terminal = NULL; - trainlog_database_close(database); - return true; + va_list arguments; + if (surface == NULL || surface->terminal == NULL) return; + va_start(arguments, format); + if (surface->terminal->output_used < sizeof(surface->terminal->output)) { + int written = vsnprintf(surface->terminal->output + surface->terminal->output_used, + sizeof(surface->terminal->output) - surface->terminal->output_used, + format, arguments); + if (written > 0 && (size_t)written < sizeof(surface->terminal->output) - + surface->terminal->output_used) surface->terminal->output_used += (size_t)written; + } + va_end(arguments); (void)row; (void)column; } +void trainlog_surface_draw(TrainlogSurface *surface, int row, int column, + uint32_t codepoint) +{ + if (surface != NULL && surface->terminal != NULL) + ++surface->terminal->surface_draw_count; + (void)row; (void)column; (void)codepoint; +} +void trainlog_surface_move_top(TrainlogSurface *surface) { (void)surface; } static bool test_knowledge_scrolls_long_lists_at_minimum_terminal(void) { - TrainlogExercise exercise; + TrainlogAppContext app; TrainlogTerminal terminal; - const int events[] = { - TRAINLOG_KEY_PAGE_DOWN, TRAINLOG_KEY_PAGE_DOWN, TRAINLOG_KEY_PAGE_DOWN, - TRAINLOG_KEY_PAGE_DOWN, 'b' - }; - - (void)memset(&exercise, 0, sizeof(exercise)); - (void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", + TrainlogSurface surface; + size_t page; + (void)memset(&app, 0, sizeof(app)); + (void)snprintf(app.exercise_detail.exercise_id, + sizeof(app.exercise_detail.exercise_id), "%s", "ex_a72fa713-4b0e-431d-95e2-42d95beb77b1"); - (void)snprintf(exercise.name, sizeof(exercise.name), "%s", "Lat pull"); - script(&terminal, events, sizeof(events) / sizeof(events[0])); + (void)snprintf(app.exercise_detail.name, sizeof(app.exercise_detail.name), + "%s", "Lat pull"); + (void)memset(&terminal, 0, sizeof(terminal)); terminal.rows = 20; terminal.columns = 72; - tui_terminal = &terminal; - screen_exercise_knowledge(&exercise); + surface.terminal = &terminal; + app.terminal = &terminal; + app.content = &surface; + app.layout.usable = true; + app.layout.content.height = 16; + app.layout.content.width = 72; + app.focus = TRAINLOG_FOCUS_CONTENT; + app.navigation.current.route = TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE; + for (page = 0U; page < 5U; ++page) { + app_shell_render_content(&app); + app_shell_dispatch(&app, TRAINLOG_KEY_PAGE_DOWN); + } CHECK(!terminal.coordinate_overflow); CHECK(!terminal.text_overflow); CHECK(strstr(terminal.output, "Supra-épineux") != NULL); CHECK(strstr(terminal.output, "Sources :") != NULL); - tui_terminal = NULL; return true; } -static size_t generator_zone_events(const char *zone_id, int *events) -{ - size_t index; - for (index = 0U; index < trainlog_body_zone_catalog_count(); ++index) { - const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index); - if (zone != NULL && strcmp(zone->zone_id, zone_id) == 0) { - size_t event; - for (event = 0U; event < index; ++event) events[event] = TRAINLOG_KEY_DOWN; - events[index] = TRAINLOG_KEY_ENTER; - return index + 1U; - } - } - return 0U; -} - static bool seed_generator_leg_press(TrainlogDatabase *database) { return trainlog_database_insert_exercise_profiled(database, @@ -317,192 +191,937 @@ static bool seed_generator_leg_press(TrainlogDatabase *database) TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) == TRAINLOG_STATUS_OK; } -static bool test_generator_dashboard_entry_and_policy_choices(void) +static bool test_shell_session_navigation_preserves_run_draft(void) { TrainlogDatabase *database = NULL; - TrainlogTerminal terminal; - const TrainlogSessionGenerationGoalPolicy *goal = NULL; - int minutes = 0; - const int dashboard_events[] = {'g'}; - const int goal_events[] = {TRAINLOG_KEY_DOWN, TRAINLOG_KEY_ENTER}; - const int duration_events[] = {TRAINLOG_KEY_DOWN, TRAINLOG_KEY_ENTER}; + TrainlogAppContext app; + size_t sessions = 99U; CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - script(&terminal, dashboard_events, sizeof(dashboard_events) / sizeof(dashboard_events[0])); - terminal.rows = 20; terminal.columns = 72; tui_terminal = &terminal; - CHECK(screen_dashboard(database) == DASHBOARD_GENERATE_SESSION); - CHECK(strstr(terminal.output, "g générer une séance") != NULL); - CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); - script(&terminal, goal_events, sizeof(goal_events) / sizeof(goal_events[0])); - CHECK(generator_choose_goal(&goal)); - CHECK(goal == &trainlog_session_generation_policy_v1.goals[1]); - script(&terminal, duration_events, sizeof(duration_events) / sizeof(duration_events[0])); - CHECK(generator_choose_duration(&minutes)); - CHECK(minutes == trainlog_session_generation_policy_v1.duration_presets_minutes[1]); - tui_terminal = NULL; trainlog_database_close(database); return true; + (void)memset(&app, 0, sizeof(app)); + app.database = database; + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT; + app.session.has_draft = true; + app.session.dirty = true; + app.session.draft_count = 1U; + (void)snprintf(app.session.drafts[0].input.exercise_id, + sizeof(app.session.drafts[0].input.exercise_id), "%s", + "ex_11111111-1111-4111-8111-111111111111"); + app_shell_open_route(&app, TRAINLOG_ROUTE_EXERCISES); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EXERCISES); + CHECK(app.session.has_draft && app.session.draft_count == 1U); + CHECK(strcmp(app.session.drafts[0].input.exercise_id, + "ex_11111111-1111-4111-8111-111111111111") == 0); + CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK); + CHECK(sessions == 0U); + trainlog_database_close(database); + return true; } -static bool test_generator_plan_drafts_have_no_actual_sets(void) +static bool test_shell_generator_keep_and_accept_has_no_actuals(void) { TrainlogDatabase *database = NULL; - TrainlogGeneratedSession generated; - TrainlogSessionDraftExercise drafts[1]; - char summary[160]; + TrainlogAppContext app; CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); CHECK(seed_generator_leg_press(database)); - (void)memset(&generated, 0, sizeof(generated)); - generated.exercise_count = 1U; - (void)snprintf(generated.exercises[0].exercise_id, - sizeof(generated.exercises[0].exercise_id), "%s", + (void)memset(&app, 0, sizeof(app)); + app.database = database; + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_SESSION_GENERATOR; + app.session.phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; + app.session.generated_preview = true; + app.session.generated.exercise_count = 1U; + (void)snprintf(app.session.generated.exercises[0].exercise_id, + sizeof(app.session.generated.exercises[0].exercise_id), "%s", "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"); - (void)snprintf(generated.exercises[0].equipment_id, - sizeof(generated.exercises[0].equipment_id), "%s", "leg_press"); - generated.exercises[0].target_sets = 3; - generated.exercises[0].target_repetitions = 10; - generated.exercises[0].rest_seconds = 120; - generated.exercises[0].planned_load_mode = TRAINLOG_LOAD_NONE; - CHECK(generator_build_drafts(database, &generated, drafts)); - CHECK(drafts[0].input.target_sets == 3 && drafts[0].input.target_reps == 10); - CHECK(drafts[0].input.rest_seconds == 120); - CHECK(drafts[0].input.set_count == 0U && drafts[0].input.sets == drafts[0].sets); - draft_set_summary(&drafts[0], summary, sizeof(summary)); - CHECK(strstr(summary, "Plan 3×10") != NULL); - CHECK(strstr(summary, "0 réalisée") != NULL); + (void)snprintf(app.session.generated.exercises[0].equipment_id, + sizeof(app.session.generated.exercises[0].equipment_id), "%s", "leg_press"); + app.session.generated.exercises[0].target_sets = 3; + app.session.generated.exercises[0].target_repetitions = 10; + app.session.generated.exercises[0].rest_seconds = 120; + app_shell_open_route(&app, TRAINLOG_ROUTE_EXERCISES); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_SESSION_GENERATOR); + CHECK(app.session.phase == TRAINLOG_SESSION_CONFIRM_LEAVE); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EXERCISES); + CHECK(app.session.generated_preview); + app.navigation.current.route = TRAINLOG_ROUTE_SESSION_GENERATOR; + app.session.phase = TRAINLOG_SESSION_GENERATOR_PREVIEW; + CHECK(session_controller_accept_generator(&app)); + CHECK(app.session.has_draft && app.session.draft_count == 1U); + CHECK(app.session.drafts[0].input.target_sets == 3); + CHECK(app.session.drafts[0].input.set_count == 0U); + trainlog_database_close(database); + return true; +} + +static bool test_shell_session_forms_keep_plan_and_actuals_separate(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogSessionDraftExercise *draft; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); app.database = database; + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT; + app.session.has_draft = true; app.session.draft_count = 1U; + app.session.phase = TRAINLOG_SESSION_DRAFT; + draft = &app.session.drafts[0]; + draft->tracking_mode = TRAINLOG_TRACKING_REPS; + draft->input.recording_mode = TRAINLOG_RECORDING_SETS; + draft->input.target_sets = 3; draft->input.target_reps = 10; + draft_bind_input(draft); + CHECK(app_shell_dispatch_session(&app, 'e')); + CHECK(app_shell_dispatch_session(&app, 'a')); + CHECK(app_shell_dispatch_session(&app, '8')); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(draft->input.set_count == 1U && draft->sets[0].reps == 8); + CHECK(draft->input.target_sets == 3 && draft->input.target_reps == 10); trainlog_database_close(database); return true; } -static bool test_generator_preview_scrolls_at_minimum_terminal(void) +static bool test_shell_continuous_supplemental_fields_have_no_fake_set(void) { - TrainlogGeneratedSession generated; - TrainlogGeneratorPreviewItem items[TRAINLOG_GENERATOR_MAX_SELECTED]; - TrainlogTerminal terminal; + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogSessionDraftExercise *draft; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); app.database = database; + app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT; + app.session.has_draft = true; app.session.draft_count = 1U; + app.session.phase = TRAINLOG_SESSION_ACTUALS; + draft = &app.session.drafts[0]; + draft->tracking_mode = TRAINLOG_TRACKING_DURATION; + draft->input.recording_mode = TRAINLOG_RECORDING_CONTINUOUS; + draft->input.data_fields = TRAINLOG_EXERCISE_DATA_SPEED_KMH | + TRAINLOG_EXERCISE_DATA_DISTANCE_KM; + draft_bind_input(draft); + CHECK(app_shell_dispatch_session(&app, 'e')); + CHECK(app_shell_dispatch_session(&app, '9')); + CHECK(app_shell_dispatch_session(&app, '0')); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(app_shell_dispatch_session(&app, 's')); + CHECK(app_shell_dispatch_session(&app, '1')); + CHECK(app_shell_dispatch_session(&app, '2')); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(app_shell_dispatch_session(&app, 'k')); + CHECK(app_shell_dispatch_session(&app, '2')); + CHECK(app_shell_dispatch_session(&app, '.')); + CHECK(app_shell_dispatch_session(&app, '5')); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(draft->input.continuous_duration_seconds == 90); + CHECK(draft->input.continuous_has_speed && draft->input.continuous_speed_kmh == 12.0); + CHECK(draft->input.continuous_has_distance && draft->input.continuous_distance_km == 2.5); + CHECK(draft->input.set_count == 0U && !draft->input.has_max_weight); + trainlog_database_close(database); return true; +} + +static bool test_shell_generator_dose_edit_drops_stale_load_provenance(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogGeneratedExercise *generated; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); app.database = database; + app.navigation.current.route = TRAINLOG_ROUTE_SESSION_GENERATOR; + app.session.generation_zone = trainlog_body_zone_catalog_lookup("thighs"); + app.session.generation_goal = &trainlog_session_generation_policy_v1.goals[0]; + app.session.generation_duration_minutes = 30; + CHECK(seed_generator_leg_press(database)); + CHECK(session_controller_generate(&app)); + CHECK(app.session.generated.exercise_count == 1U); + generated = &app.session.generated.exercises[0]; + generated->target_sets = 3; generated->target_repetitions = 10; + generated->has_target_weight = true; generated->target_weight_kg = 80.0; + (void)snprintf(generated->load_source_session_id, + sizeof(generated->load_source_session_id), "se_source"); + CHECK(app_shell_dispatch_session(&app, 'e')); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_BACKSPACE)); + CHECK(app_shell_dispatch_session(&app, '4')); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_BACKSPACE)); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_BACKSPACE)); + CHECK(app_shell_dispatch_session(&app, '1')); + CHECK(app_shell_dispatch_session(&app, '2')); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(generated->target_sets == 4 && generated->target_repetitions == 12); + CHECK(!generated->has_target_weight && generated->load_source_session_id[0] == '\0'); + trainlog_database_close(database); return true; +} + +static bool test_shell_inline_equipment_creation_resumes_occurrence(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + const char *name = "Cable maison"; + const char *type = "cable"; size_t index; - const int events[] = {TRAINLOG_KEY_RESIZE, TRAINLOG_KEY_DOWN, TRAINLOG_KEY_DOWN, - TRAINLOG_KEY_DOWN, TRAINLOG_KEY_DOWN, TRAINLOG_KEY_DOWN, 'q'}; - (void)memset(&generated, 0, sizeof(generated)); - (void)memset(items, 0, sizeof(items)); - generated.exercise_count = TRAINLOG_GENERATOR_MAX_SELECTED; - generated.estimated_duration_seconds = 1800; - for (index = 0U; index < generated.exercise_count; ++index) { - generated.exercises[index].target_sets = 3; - generated.exercises[index].target_repetitions = 10; - generated.exercises[index].rest_seconds = 120; - (void)snprintf(items[index].exercise_name, sizeof(items[index].exercise_name), "Exercice %zu", index + 1U); - (void)snprintf(items[index].equipment_name, sizeof(items[index].equipment_name), "Machine %zu", index + 1U); - (void)snprintf(items[index].zone_name, sizeof(items[index].zone_name), "Cuisses"); - (void)snprintf(items[index].movement_name, sizeof(items[index].movement_name), "Extension du genou"); + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); app.database = database; + app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT; + app.session.has_draft = true; app.session.draft_count = 1U; + app.session.phase = TRAINLOG_SESSION_EQUIPMENT_PICKER; + CHECK(app_shell_dispatch_session(&app, 'n')); + for (index = 0U; name[index] != '\0'; ++index) + CHECK(app_shell_dispatch_session(&app, (unsigned char)name[index])); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + for (index = 0U; type[index] != '\0'; ++index) + CHECK(app_shell_dispatch_session(&app, (unsigned char)type[index])); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.session.phase == TRAINLOG_SESSION_EQUIPMENT_PICKER); + CHECK(app.session.equipment_selected > 0U); + CHECK(app_shell_dispatch_session(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.session.phase == TRAINLOG_SESSION_DRAFT); + CHECK(app.session.drafts[0].input.equipment_id[0] != '\0'); + CHECK(app.session.drafts[0].input.load_mode == TRAINLOG_LOAD_EXTERNAL); + trainlog_database_close(database); return true; +} + +static bool test_shell_equipment_catalog_create_preserves_invalid_form(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogCustomEquipment items[2]; + size_t count = 0U; + const char *name = "Poulie personnelle"; + const char *type = "cable"; + size_t index; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.navigation.current.route = TRAINLOG_ROUTE_EQUIPMENT; + app.layout.content.height = 20; + trainlog_list_init(&app.list); + equipment_controller_start(&app); + CHECK(app_shell_dispatch_equipment_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.equipment_controller.phase == TRAINLOG_EQUIPMENT_NAME); + CHECK(app.equipment_controller.form.active); + CHECK(strstr(app.equipment_controller.message, "obligatoire") != NULL); + for (index = 0U; name[index] != '\0'; ++index) + CHECK(app_shell_dispatch_equipment_controller(&app, + (unsigned char)name[index])); + app_shell_open_route(&app, TRAINLOG_ROUTE_HOME); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT); + CHECK(app.equipment_controller.phase == TRAINLOG_EQUIPMENT_CONFIRM_DISCARD); + CHECK(strcmp(app.equipment_controller.form.text, name) == 0); + CHECK(app_shell_dispatch_equipment_controller(&app, '0')); + CHECK(app.equipment_controller.phase == TRAINLOG_EQUIPMENT_NAME); + CHECK(app_shell_dispatch_equipment_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app_shell_dispatch_equipment_controller(&app, TRAINLOG_KEY_ENTER)); + for (index = 0U; type[index] != '\0'; ++index) + CHECK(app_shell_dispatch_equipment_controller(&app, + (unsigned char)type[index])); + CHECK(app_shell_dispatch_equipment_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.equipment_controller.phase == TRAINLOG_EQUIPMENT_LOAD); + CHECK(app_shell_dispatch_equipment_controller(&app, '3')); + CHECK(app_shell_dispatch_equipment_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.equipment_controller.phase == TRAINLOG_EQUIPMENT_MESSAGE); + CHECK(trainlog_database_list_custom_equipment(database, items, 2U, + &count) == TRAINLOG_STATUS_OK); + CHECK(count == 1U); + CHECK(strcmp(items[0].display_name, name) == 0); + CHECK(strcmp(items[0].equipment_type, type) == 0); + CHECK(strcmp(items[0].load_semantics, "assistance") == 0); + trainlog_database_close(database); + return true; +} + +static bool test_shell_inline_exercise_creation_and_stable_edit(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogExercise created; + TrainlogExercise listed[4]; + size_t count = 0U; + const char *name = "Course inclinée"; + size_t index; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.navigation.current.route = TRAINLOG_ROUTE_SESSION_CURRENT; + app.session.phase = TRAINLOG_SESSION_EXERCISE_PICKER; + exercise_controller_start_create(&app, true); + for (index = 0U; name[index] != '\0'; ++index) + CHECK(app_shell_dispatch_exercise_controller(&app, + (unsigned char)name[index])); + CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app_shell_dispatch_exercise_controller(&app, '2')); + CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app_shell_dispatch_exercise_controller(&app, '2')); + CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app_shell_dispatch_exercise_controller(&app, '1')); + CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app_shell_dispatch_exercise_controller(&app, '0')); + CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_ZONES); + CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_IDLE); + CHECK(app.session.phase == TRAINLOG_SESSION_EXERCISE_PICKER); + CHECK(app.session.picker_count == 1U); + created = app.session.picker[app.session.picker_selected]; + CHECK(created.recording_mode == TRAINLOG_RECORDING_CONTINUOUS); + CHECK(created.tracking_mode == TRAINLOG_TRACKING_DURATION); + CHECK((created.data_fields & TRAINLOG_EXERCISE_DATA_SPEED_KMH) != 0U); + app.exercise_detail = created; + exercise_controller_start_edit(&app); + trainlog_form_init(&app.exercise_controller.form, "Course renommée"); + app.exercise_controller.dirty = true; + CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_ZONES); + CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(strcmp(app.exercise_detail.exercise_id, created.exercise_id) == 0); + CHECK(strcmp(app.exercise_detail.name, "Course renommée") == 0); + CHECK(trainlog_database_list_exercises(database, listed, 4U, + &count) == TRAINLOG_STATUS_OK); + CHECK(count == 1U && strcmp(listed[0].exercise_id, created.exercise_id) == 0); + trainlog_database_close(database); + return true; +} + +static bool test_shell_navigation_focus_consumes_input(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.layout.usable = true; + app.layout.sidebar_visible = true; + app.layout.content.height = 20; + app.focus = TRAINLOG_FOCUS_CONTENT; + trainlog_navigation_init(&app.navigation); + app_shell_dispatch(&app, TRAINLOG_KEY_F6); + CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION); + app_shell_dispatch(&app, TRAINLOG_KEY_ESCAPE); + CHECK(app.focus == TRAINLOG_FOCUS_CONTENT); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_HOME); + app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB); + CHECK(app.focus == TRAINLOG_FOCUS_SEARCH); + app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB); + CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION); + app.navigation_selected = 3U; + app_shell_dispatch(&app, TRAINLOG_KEY_ENTER); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT); + CHECK(app.focus == TRAINLOG_FOCUS_CONTENT); + trainlog_database_close(database); + return true; +} + +static bool test_shell_actions_overlay_executes_registered_search_action(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + const TrainlogAction *search_action; + TrainlogCustomEquipment equipment; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&equipment, 0, sizeof(equipment)); + generate_custom_equipment_uuid(equipment.equipment_id); + (void)snprintf(equipment.display_name, sizeof(equipment.display_name), + "overlay search needle"); + (void)snprintf(equipment.equipment_type, sizeof(equipment.equipment_type), + "machine"); + (void)snprintf(equipment.load_semantics, sizeof(equipment.load_semantics), + "external"); + CHECK(trainlog_database_create_custom_equipment(database, &equipment) == + TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.layout.usable = true; + app.layout.content.height = 20; + app.focus = TRAINLOG_FOCUS_CONTENT; + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_EQUIPMENT; + trainlog_search_init(&app.search); + app_shell_refresh_list(&app); + app_shell_actions(&app); + search_action = trainlog_actions_find_key(&app.actions, '/'); + CHECK(search_action != NULL); + CHECK(strcmp(search_action->identifier, "list.search") == 0); + CHECK(search_action->intent == TRAINLOG_INTENT_OPEN_SEARCH); + app_shell_dispatch(&app, TRAINLOG_KEY_F7); + CHECK(trainlog_overlays_top(&app.overlays)->type == TRAINLOG_OVERLAY_ACTIONS); + app_shell_dispatch(&app, TRAINLOG_KEY_DOWN); + app_shell_dispatch(&app, TRAINLOG_KEY_ENTER); + CHECK(app.focus == TRAINLOG_FOCUS_SEARCH); + CHECK(app.search.open && app.search.focused); + app_shell_dispatch(&app, 'n'); + app_shell_dispatch(&app, 'e'); + app_shell_dispatch(&app, 'e'); + app_shell_dispatch(&app, 'd'); + app_shell_dispatch(&app, 'l'); + app_shell_dispatch(&app, 'e'); + CHECK(strcmp(app.search.text, "needle") == 0); + CHECK(app.loaded_count == 1U); + CHECK(strcmp(app.equipment[0].display_name, "overlay search needle") == 0); + app_shell_dispatch(&app, TRAINLOG_KEY_ESCAPE); + CHECK(app.search.focused && app.search.bytes == 0U); + CHECK(app.loaded_count > 1U); + app_shell_dispatch(&app, TRAINLOG_KEY_ESCAPE); + CHECK(!app.search.focused && app.focus == TRAINLOG_FOCUS_CONTENT); + trainlog_database_close(database); + return true; +} + +static bool test_shell_compact_navigation_focus_contract(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogTerminal terminal = {0}; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); + terminal.columns = 120; terminal.rows = 35; + app.database = database; app.terminal = &terminal; + app.focus = TRAINLOG_FOCUS_CONTENT; + trainlog_navigation_init(&app.navigation); + CHECK(app_shell_layout(&app)); + app_shell_dispatch(&app, TRAINLOG_KEY_F6); + CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION); + app.navigation_selected = 3U; + terminal.columns = 100; terminal.rows = 25; + CHECK(app_shell_layout(&app)); + CHECK(!app.layout.sidebar_visible && app.focus == TRAINLOG_FOCUS_NAVIGATION); + app_shell_dispatch(&app, TRAINLOG_KEY_ENTER); + CHECK(trainlog_overlays_top(&app.overlays)->type == TRAINLOG_OVERLAY_NAVIGATION); + app_shell_dispatch(&app, TRAINLOG_KEY_ESCAPE); + CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION && app.navigation_selected == 3U); + app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB); + CHECK(app.focus == TRAINLOG_FOCUS_ACTIONS); + app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB); + CHECK(app.focus == TRAINLOG_FOCUS_CONTENT); + app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB); + CHECK(app.focus == TRAINLOG_FOCUS_SEARCH && app.search.focused); + app_shell_dispatch(&app, TRAINLOG_KEY_SHIFT_TAB); + CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION && !app.search.focused); + app_shell_dispatch(&app, TRAINLOG_KEY_TAB); + CHECK(app.focus == TRAINLOG_FOCUS_SEARCH && app.search.focused); + app.navigation.current.route = TRAINLOG_ROUTE_EQUIPMENT; + app.search.open = true; + app_shell_dispatch(&app, TRAINLOG_KEY_F6); + CHECK(trainlog_overlays_top(&app.overlays)->type == TRAINLOG_OVERLAY_NAVIGATION); + app_shell_dispatch(&app, TRAINLOG_KEY_ESCAPE); + CHECK(app.focus == TRAINLOG_FOCUS_SEARCH && app.search.focused); + app.focus = TRAINLOG_FOCUS_ACTIONS; app.search.focused = false; + app_shell_dispatch(&app, TRAINLOG_KEY_ENTER); + CHECK(trainlog_overlays_top(&app.overlays)->type == TRAINLOG_OVERLAY_ACTIONS); + trainlog_database_close(database); + return true; +} + +static bool test_shell_detail_back_restores_stable_list_selection(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + char selected_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY]; + size_t selected_index; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.layout.content.height = 16; + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_EQUIPMENT; + trainlog_list_init(&app.list); + trainlog_search_init(&app.search); + app_shell_refresh_list(&app); + CHECK(app.loaded_count > 1U); + trainlog_list_move(&app.list, app.stable_ids, 1); + (void)snprintf(selected_id, sizeof(selected_id), "%s", + app.list.selected_id); + selected_index = app.list.selected_index; + app_shell_primary(&app); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL); + CHECK(app_shell_back(&app)); + CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT); + CHECK(strcmp(app.list.selected_id, selected_id) == 0); + CHECK(app.list.selected_index == selected_index); + trainlog_database_close(database); + return true; +} + +static int sync_probe_calls; +static int sync_run_calls; +static TrainlogSyncDirection sync_last_direction; +static TrainlogAppContext *sync_running_app; +static char sync_journal_root[PATH_MAX + 1U]; + +static TrainlogStatus mock_sync_probe(TrainlogSyncDeviceInfo *output) +{ + ++sync_probe_calls; + (void)memset(output, 0, sizeof(*output)); + output->connected = true; + output->storage_ready = true; + (void)snprintf(output->device.vendor, sizeof(output->device.vendor), "Test"); + (void)snprintf(output->device.model, sizeof(output->device.model), "Phone"); + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger, + bool require_request, TrainlogSyncDirection direction, + TrainlogSyncReport *output) +{ + char directory[PATH_MAX + 1U]; + char history_path[PATH_MAX + 1U]; + FILE *history; + int run_number; + CHECK(trigger == TRAINLOG_SYNC_TRIGGER_TUI); + CHECK(!require_request); + ++sync_run_calls; + run_number = sync_run_calls; + sync_last_direction = direction; + CHECK(sync_running_app != NULL && sync_running_app->sync_controller.running); + CHECK(strstr(sync_running_app->terminal->output, + "Synchronisation en cours") != NULL); + /* A re-entrant launch attempt is consumed while the sole run is active. */ + CHECK(app_shell_dispatch_sync(sync_running_app, 's')); + CHECK(app_shell_dispatch_sync(sync_running_app, TRAINLOG_KEY_ENTER)); + CHECK(sync_run_calls == run_number); + (void)memset(output, 0, sizeof(*output)); + output->success = run_number != 2; + output->direction = direction; + (void)snprintf(output->sync_id, sizeof(output->sync_id), + "sy_00000000-0000-4000-8000-%012d", run_number); + (void)snprintf(output->summary, sizeof(output->summary), "%s", + output->success ? "sync test réussie" : "sync test échouée"); + if (!output->success) + (void)snprintf(output->error, sizeof(output->error), "%s", + "Appareil déconnecté pendant la synchronisation."); + CHECK(snprintf(directory, sizeof(directory), "%s/trainlog", + sync_journal_root) > 0); + CHECK(mkdir(directory, 0700) == 0 || errno == EEXIST); + CHECK(snprintf(history_path, sizeof(history_path), "%s/sync_history.log", + directory) > 0); + history = fopen(history_path, "ab"); + CHECK(history != NULL); + CHECK(fprintf(history, "%s\t11/09/2026 12:%02d\t%d\tb\t%s\n", + output->sync_id, run_number, output->success ? 1 : 0, + output->summary) > 0); + CHECK(fclose(history) == 0); + return output->success ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR; +} + +static bool test_shell_body_controller_preserves_identity_and_failed_edit(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogBodyObservationRecord records[4]; + size_t count = 0U; + size_t field; + char id[TRAINLOG_ID_MAX + 1U]; + char observed_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); + app.database = database; + app.layout.usable = true; + app.layout.content.height = 16; + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_BODY; + body_controller_start_add(&app); + CHECK(app.body_controller.phase == TRAINLOG_BODY_FORM); + CHECK(app_shell_dispatch_body_controller(&app, '8')); + CHECK(app_shell_dispatch_body_controller(&app, '0')); + for (field = 0U; field < 14U; ++field) + CHECK(app_shell_dispatch_body_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.body_controller.phase == TRAINLOG_BODY_MESSAGE); + CHECK(trainlog_database_list_body_observations(database, records, 4U, + &count) == TRAINLOG_STATUS_OK); + CHECK(count == 1U && records[0].has_body_weight && + records[0].body_weight_kg == 80.0); + (void)snprintf(id, sizeof(id), "%s", records[0].observation_id); + (void)snprintf(observed_at, sizeof(observed_at), "%s", records[0].observed_at); + (void)memset(&app.body_controller, 0, sizeof(app.body_controller)); + CHECK(body_controller_start_edit(&app, id)); + trainlog_form_init(&app.body_controller.form, "-"); + CHECK(app_shell_dispatch_body_controller(&app, TRAINLOG_KEY_ENTER)); + for (field = 1U; field < 14U; ++field) + CHECK(app_shell_dispatch_body_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.body_controller.phase == TRAINLOG_BODY_FORM); + CHECK(strstr(app.body_controller.message, "au moins une mesure") != NULL); + CHECK(trainlog_database_get_body_observation(database, id, &records[0]) == + TRAINLOG_STATUS_OK); + CHECK(records[0].has_body_weight && records[0].body_weight_kg == 80.0); + trainlog_form_init(&app.body_controller.form, "81"); + app.body_controller.field = 0U; + CHECK(app_shell_dispatch_body_controller(&app, TRAINLOG_KEY_ENTER)); + for (field = 1U; field < 14U; ++field) + CHECK(app_shell_dispatch_body_controller(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.body_controller.phase == TRAINLOG_BODY_MESSAGE); + CHECK(trainlog_database_get_body_observation(database, id, &records[0]) == + TRAINLOG_STATUS_OK); + CHECK(strcmp(records[0].observation_id, id) == 0); + CHECK(strcmp(records[0].observed_at, observed_at) == 0); + CHECK(records[0].body_weight_kg == 81.0); + (void)memset(&app.body_controller, 0, sizeof(app.body_controller)); + app.navigation.current.route = TRAINLOG_ROUTE_BODY; + app_shell_open_route(&app, TRAINLOG_ROUTE_BODY_METRIC); + CHECK(trainlog_database_list_body_observations(database, records, 4U, + &count) == TRAINLOG_STATUS_OK); + CHECK(count == 1U && strcmp(records[0].observation_id, id) == 0 && + records[0].body_weight_kg == 81.0); + trainlog_database_close(database); + return true; +} + +static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void) +{ + TrainlogDatabase *database = NULL; + TrainlogAppContext app; + TrainlogTerminal terminal = {0}; + char temporary[] = "/tmp/trainlog-tui-sync-XXXXXX"; + const char *old_data_home = getenv("XDG_DATA_HOME"); + char *saved_data_home = old_data_home != NULL ? strdup(old_data_home) : NULL; + size_t index; + size_t sync_action_index = SIZE_MAX; + size_t sync_action_count = 0U; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(mkdtemp(temporary) != NULL); + CHECK(setenv("XDG_DATA_HOME", temporary, 1) == 0); + (void)snprintf(sync_journal_root, sizeof(sync_journal_root), "%s", temporary); + (void)memset(&app, 0, sizeof(app)); + app.database = database; + terminal.columns = 120; + terminal.rows = 35; + app.terminal = &terminal; + trainlog_navigation_init(&app.navigation); + trainlog_overlays_init(&app.overlays); + trainlog_sync_screen_state_init(&app.sync_controller.action); + app.sync_controller.probe = mock_sync_probe; + app.sync_controller.run = mock_sync_run; + sync_probe_calls = 0; + sync_run_calls = 0; + sync_running_app = &app; + app_shell_open_route(&app, TRAINLOG_ROUTE_SYNC); + CHECK(app_shell_layout(&app)); + CHECK(sync_probe_calls == 0 && sync_run_calls == 0); + app_shell_actions(&app); + for (index = 0U; index < app.actions.count; ++index) { + const TrainlogAction *action = &app.actions.items[index]; + CHECK(strstr(action->label, "Android→PC") == NULL); + CHECK(strstr(action->label, "PC→Android") == NULL); + if (strcmp(action->identifier, "sync.now") == 0) { + ++sync_action_count; + sync_action_index = index; + CHECK(strcmp(action->label, + "s Synchroniser maintenant · PC↔Android") == 0); + } } - script(&terminal, events, sizeof(events) / sizeof(events[0])); - terminal.rows = 30; terminal.columns = 100; tui_terminal = &terminal; - CHECK(!generator_preview(&generated, items)); - CHECK(strstr(terminal.output, "Exercice 6") != NULL); - CHECK(strstr(terminal.output, "aucune prescription numérique") != NULL); - CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); - tui_terminal = NULL; return true; + CHECK(sync_action_count == 1U && sync_action_index != SIZE_MAX); + + /* Actual user path: Synchronisation screen -> F7 -> registered action. */ + app_shell_dispatch(&app, TRAINLOG_KEY_F7); + CHECK(trainlog_overlays_top(&app.overlays) != NULL); + app.overlays.items[app.overlays.count - 1U].selected = sync_action_index; + app_shell_dispatch(&app, TRAINLOG_KEY_ENTER); + CHECK(app.sync_controller.action.confirming && sync_run_calls == 0); + CHECK(app_shell_dispatch_sync(&app, TRAINLOG_KEY_ESCAPE)); + CHECK(!app.sync_controller.action.confirming && sync_run_calls == 0); + + app_shell_dispatch(&app, TRAINLOG_KEY_F7); + app.overlays.items[app.overlays.count - 1U].selected = sync_action_index; + app_shell_dispatch(&app, TRAINLOG_KEY_ENTER); + CHECK(sync_run_calls == 0); + CHECK(app_shell_dispatch_sync(&app, TRAINLOG_KEY_ENTER)); + CHECK(sync_run_calls == 1); + CHECK(sync_last_direction == TRAINLOG_SYNC_BIDIRECTIONAL); + CHECK(sync_probe_calls == 1); + CHECK(!app.sync_controller.running && app.sync_controller.has_report); + CHECK(app.sync_controller.report.success); + CHECK(app.sync_controller.history_count == 1U); + terminal.output_used = 0U; terminal.output[0] = '\0'; + app_shell_render_sync(&app); + CHECK(strstr(terminal.output, "sync test réussie") != NULL); + + /* Repeating the action creates the next legitimate journal event. */ + app_shell_dispatch(&app, 's'); + CHECK(app.sync_controller.action.confirming); + app_shell_dispatch(&app, TRAINLOG_KEY_ENTER); + CHECK(sync_run_calls == 2); + CHECK(sync_last_direction == TRAINLOG_SYNC_BIDIRECTIONAL); + CHECK(!app.sync_controller.report.success); + CHECK(app.sync_controller.history_count == 2U); + terminal.output_used = 0U; terminal.output[0] = '\0'; + app_shell_render_sync(&app); + CHECK(strstr(terminal.output, + "Appareil déconnecté pendant la synchronisation.") != NULL); + CHECK(app_shell_dispatch_sync(&app, 'r')); + CHECK(sync_probe_calls == 3 && sync_run_calls == 2); + if (saved_data_home != NULL) { + CHECK(setenv("XDG_DATA_HOME", saved_data_home, 1) == 0); + free(saved_data_home); + } else CHECK(unsetenv("XDG_DATA_HOME") == 0); + { + char history_path[PATH_MAX + 1U]; + char directory[PATH_MAX + 1U]; + CHECK(snprintf(history_path, sizeof(history_path), + "%s/trainlog/sync_history.log", temporary) > 0); + CHECK(snprintf(directory, sizeof(directory), "%s/trainlog", + temporary) > 0); + CHECK(unlink(history_path) == 0); + CHECK(rmdir(directory) == 0); + CHECK(rmdir(temporary) == 0); + } + sync_running_app = NULL; + trainlog_database_close(database); + return true; } -static bool test_generator_recency_continue_and_cancel(void) +static bool test_shell_sync_history_neutralizes_legacy_directions(void) { - TrainlogBodyZoneRecentExposure exposure; - TrainlogTerminal terminal; - const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup("back"); - const int continue_events[] = {'c'}; - const int cancel_events[] = {'q'}; - (void)memset(&exposure, 0, sizeof(exposure)); - exposure.warning_level = TRAINLOG_GENERATION_WARNING_WARNING; - exposure.within_24h.primary_set_count = 2U; - exposure.within_72h.secondary_set_count = 4U; - exposure.has_latest = true; - (void)snprintf(exposure.latest_started_at, sizeof(exposure.latest_started_at), - "%s", "2026-09-10T08:00:00.250+02:00"); - CHECK(zone != NULL); - script(&terminal, continue_events, sizeof(continue_events) / sizeof(continue_events[0])); - terminal.rows = 20; terminal.columns = 72; tui_terminal = &terminal; - CHECK(generator_confirm_exposure(zone, &exposure) == 1); - CHECK(strstr(terminal.output, "Dos travaillé récemment") != NULL); - CHECK(strstr(terminal.output, "2 séries principales") != NULL); - CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); - script(&terminal, cancel_events, sizeof(cancel_events) / sizeof(cancel_events[0])); - CHECK(generator_confirm_exposure(zone, &exposure) == -1); - tui_terminal = NULL; return true; + TrainlogAppContext app; + TrainlogTerminal terminal = {0}; + TrainlogSurface surface = {0}; + char temporary[] = "/tmp/trainlog-tui-sync-legacy-XXXXXX"; + char directory[PATH_MAX + 1U]; + char history_path[PATH_MAX + 1U]; + char runs_directory[PATH_MAX + 1U]; + char detail_path[PATH_MAX + 1U]; + const char *old_data_home = getenv("XDG_DATA_HOME"); + char *saved_data_home = old_data_home != NULL ? strdup(old_data_home) : NULL; + FILE *history; + CHECK(mkdtemp(temporary) != NULL); + CHECK(snprintf(directory, sizeof(directory), "%s/trainlog", temporary) > 0); + CHECK(mkdir(directory, 0700) == 0); + CHECK(snprintf(history_path, sizeof(history_path), "%s/sync_history.log", + directory) > 0); + CHECK(snprintf(runs_directory, sizeof(runs_directory), "%s/sync_runs", + directory) > 0); + CHECK(mkdir(runs_directory, 0700) == 0); + history = fopen(history_path, "wb"); + CHECK(history != NULL); + CHECK(fprintf(history, + "sy_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\t09/09/2026 10:11\t1\ta\tAndroid→PC import ancien\n" + "sy_pppppppp-pppp-4ppp-8ppp-pppppppppppp\t10/09/2026 11:12\t0\tp\tPC→Android publication ancienne\n") > 0); + CHECK(fclose(history) == 0); + CHECK(snprintf(detail_path, sizeof(detail_path), "%s/%s.txt", runs_directory, + "sy_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") > 0); + history = fopen(detail_path, "wb"); + CHECK(history != NULL); + CHECK(fputs("Direction: Android→PC\nAndroid→PC import details\n", history) >= 0); + CHECK(fclose(history) == 0); + CHECK(snprintf(detail_path, sizeof(detail_path), "%s/%s.txt", runs_directory, + "sy_pppppppp-pppp-4ppp-8ppp-pppppppppppp") > 0); + history = fopen(detail_path, "wb"); + CHECK(history != NULL); + CHECK(fputs("Direction: PC→Android\nPC→Android publication details\n", history) >= 0); + CHECK(fclose(history) == 0); + CHECK(setenv("XDG_DATA_HOME", temporary, 1) == 0); + + (void)memset(&app, 0, sizeof(app)); + terminal.columns = 120; + terminal.rows = 35; + surface.terminal = &terminal; + app.terminal = &terminal; + app.content = &surface; + app.layout.content.width = 120; + app.layout.content.height = 30; + trainlog_navigation_init(&app.navigation); + app.navigation.current.route = TRAINLOG_ROUTE_SYNC; + trainlog_sync_screen_state_init(&app.sync_controller.action); + sync_history_load(app.sync_controller.history, SYNC_HISTORY_CAPACITY, + &app.sync_controller.history_count); + CHECK(app.sync_controller.history_count == 2U); + app_shell_render_sync(&app); + CHECK(strstr(terminal.output, "09/09/2026 10:11") != NULL); + CHECK(strstr(terminal.output, "10/09/2026 11:12") != NULL); + CHECK(strstr(terminal.output, "entrée historique") != NULL); + CHECK(strstr(terminal.output, "PC↔Android") != NULL); + CHECK(strstr(terminal.output, "Android→PC") == NULL); + CHECK(strstr(terminal.output, "PC→Android") == NULL); + + /* Actual keyboard path: select and open each retained legacy artifact. */ + for (app.sync_controller.selected = 0U; + app.sync_controller.selected < app.sync_controller.history_count; + ++app.sync_controller.selected) { + terminal.output_used = 0U; + terminal.output[0] = '\0'; + CHECK(app_shell_dispatch_sync(&app, TRAINLOG_KEY_ENTER)); + CHECK(app.sync_controller.showing_detail); + app_shell_render_sync(&app); + CHECK(strstr(terminal.output, + "Synchronisation historique PC↔Android") != NULL); + CHECK(strstr(terminal.output, "Horodatage") != NULL); + CHECK(strstr(terminal.output, "État") != NULL); + CHECK(strstr(terminal.output, "Android→PC") == NULL); + CHECK(strstr(terminal.output, "PC→Android") == NULL); + app.sync_controller.showing_detail = false; + } + + if (saved_data_home != NULL) { + CHECK(setenv("XDG_DATA_HOME", saved_data_home, 1) == 0); + free(saved_data_home); + } else CHECK(unsetenv("XDG_DATA_HOME") == 0); + CHECK(unlink(history_path) == 0); + CHECK(snprintf(detail_path, sizeof(detail_path), "%s/%s.txt", runs_directory, + "sy_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") > 0); + CHECK(unlink(detail_path) == 0); + CHECK(snprintf(detail_path, sizeof(detail_path), "%s/%s.txt", runs_directory, + "sy_pppppppp-pppp-4ppp-8ppp-pppppppppppp") > 0); + CHECK(unlink(detail_path) == 0); + CHECK(rmdir(runs_directory) == 0); + CHECK(rmdir(directory) == 0); + CHECK(rmdir(temporary) == 0); + return true; } -static bool test_generator_cancel_preview_writes_nothing(void) +static bool test_equipment_collector_finds_match_after_first_pages(void) { TrainlogDatabase *database = NULL; - TrainlogTerminal terminal; - int events[32]; - size_t count = generator_zone_events("thighs", events); - size_t sessions = 99U; - CHECK(count > 0U); - events[count++] = TRAINLOG_KEY_ENTER; - events[count++] = TRAINLOG_KEY_ENTER; - events[count++] = 'q'; + TrainlogCustomEquipment equipment; + TrainlogResolvedEquipment result[2]; + bool truncated = false; + bool failed = false; + size_t index; + size_t count; CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(seed_generator_leg_press(database)); - script(&terminal, events, count); tui_terminal = &terminal; - screen_session_generator(database); - CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK); - CHECK(sessions == 0U); - CHECK(strstr(terminal.output, "Aperçu de la séance générée") != NULL); - tui_terminal = NULL; trainlog_database_close(database); return true; + for (index = 0U; index < 140U; ++index) { + (void)memset(&equipment, 0, sizeof(equipment)); + generate_custom_equipment_uuid(equipment.equipment_id); + (void)snprintf(equipment.display_name, sizeof(equipment.display_name), + index == 139U ? "zz needle-late" : "custom %03zu", index); + (void)snprintf(equipment.equipment_type, + sizeof(equipment.equipment_type), "machine"); + (void)snprintf(equipment.load_semantics, + sizeof(equipment.load_semantics), "external"); + CHECK(trainlog_database_create_custom_equipment(database, &equipment) == + TRAINLOG_STATUS_OK); + } + count = equipment_collect(database, "needle-late", result, 2U, + &truncated, &failed); + CHECK(!failed && !truncated && count == 1U); + CHECK(strcmp(result[0].display_name, "zz needle-late") == 0); + trainlog_database_close(database); + return true; } -static bool test_generator_accept_requires_and_persists_actual(void) +static bool test_body_graphs_draw_inside_minimum_content_surface(void) { - TrainlogDatabase *database = NULL; + TrainlogAppContext app; TrainlogTerminal terminal; - int events[48]; - size_t count = generator_zone_events("thighs", events); - size_t sessions = 0U; - CHECK(count > 0U); - events[count++] = TRAINLOG_KEY_ENTER; - events[count++] = TRAINLOG_KEY_ENTER; - events[count++] = 'a'; - events[count++] = TRAINLOG_KEY_ENTER; - events[count++] = 'a'; events[count++] = '8'; events[count++] = '\n'; - events[count++] = 'f'; events[count++] = 'f'; events[count++] = 'x'; - CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(seed_generator_leg_press(database)); - script(&terminal, events, count); tui_terminal = &terminal; - screen_session_generator(database); - CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK); - CHECK(sessions == 1U); - CHECK(strstr(terminal.output, "Valeurs réellement effectuées") != NULL); - CHECK(strstr(terminal.output, "Séance générée et réalisée enregistrée") != NULL); - tui_terminal = NULL; trainlog_database_close(database); return true; + TrainlogSurface surface; + TrainlogDashboardMonth previous; + TrainlogDashboardMonth current; + long current_key; + (void)memset(&app, 0, sizeof(app)); + (void)memset(&terminal, 0, sizeof(terminal)); + terminal.rows = 20; + terminal.columns = 72; + surface.terminal = &terminal; + app.terminal = &terminal; + app.content = &surface; + app.layout.content.height = 16; + app.layout.content.width = 72; + app.body_global_initialized = true; + app.body_global_enabled[0] = true; + app.body_global_series[0].symbol = GLOBAL_BODY_SYMBOLS[0]; + app.body_global_series[0].role = GLOBAL_BODY_ROLES[0]; + app.body_global_series[0].baseline = 80.0; + app.body_global_series[0].count = 2U; + CHECK(dashboard_current_month_key(¤t_key)); + dashboard_month_from_key(current_key - 1L, &previous); + dashboard_month_from_key(current_key, ¤t); + (void)snprintf(app.body_global_series[0].points[0].observed_at, + sizeof(app.body_global_series[0].points[0].observed_at), + "%04d-%02d-01T08:00:00Z", previous.year, previous.month); + app.body_global_series[0].points[0].value = 80.0; + (void)snprintf(app.body_global_series[0].points[1].observed_at, + sizeof(app.body_global_series[0].points[1].observed_at), + "%04d-%02d-01T08:00:00Z", current.year, current.month); + app.body_global_series[0].points[1].value = 81.0; + (void)snprintf(app.body_global_dates[0], sizeof(app.body_global_dates[0]), + "%s", app.body_global_series[0].points[0].observed_at); + (void)snprintf(app.body_global_dates[1], sizeof(app.body_global_dates[1]), + "%s", app.body_global_series[0].points[1].observed_at); + app.body_global_date_count = 2U; + app_shell_render_body_global(&app); + CHECK(terminal.surface_draw_count > 0U); + terminal.surface_draw_count = 0U; + app_shell_render_body_trends(&app); + CHECK(terminal.surface_draw_count > 0U); + return true; } -static bool test_generator_empty_knowledge_cannot_save(void) +static bool test_exercise_merge_action_search_confirm_selects_target(void) { TrainlogDatabase *database = NULL; - TrainlogTerminal terminal; - const int events[] = {TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER, - TRAINLOG_KEY_ENTER, 'x'}; - size_t sessions = 99U; + TrainlogAppContext app; + char resolved[TRAINLOG_ID_MAX + 1U]; + size_t action_index; CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - script(&terminal, events, sizeof(events) / sizeof(events[0])); - tui_terminal = &terminal; screen_session_generator(database); - CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK); - CHECK(sessions == 0U); - CHECK(strstr(terminal.output, "Pas assez d’exercices résolus") != NULL); - CHECK(strstr(terminal.output, "rien ne peut être enregistré") != NULL); - tui_terminal = NULL; trainlog_database_close(database); return true; + CHECK(trainlog_database_insert_exercise(database, + "ex_11111111-1111-4111-8111-111111111111", "Source curl", + "source curl", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_insert_exercise(database, + "ex_22222222-2222-4222-8222-222222222222", "Target curl", + "target curl", TRAINLOG_TRACKING_REPS) == TRAINLOG_STATUS_OK); + (void)memset(&app, 0, sizeof(app)); + app.database = database; + trainlog_navigation_init(&app.navigation); + trainlog_overlays_init(&app.overlays); + app.navigation.current.route = TRAINLOG_ROUTE_EXERCISE_DETAIL; + (void)snprintf(app.exercise_detail.exercise_id, + sizeof(app.exercise_detail.exercise_id), "%s", + "ex_11111111-1111-4111-8111-111111111111"); + (void)snprintf(app.exercise_detail.name, sizeof(app.exercise_detail.name), + "%s", "Source curl"); + app.exercise_detail.tracking_mode = TRAINLOG_TRACKING_REPS; + app.exercise_detail.recording_mode = TRAINLOG_RECORDING_SETS; + app_shell_actions(&app); + for (action_index = 0U; action_index < app.actions.count; ++action_index) + if (strcmp(app.actions.items[action_index].identifier, + "exercise.merge") == 0) break; + CHECK(action_index < app.actions.count); + (void)trainlog_overlays_push(&app.overlays, TRAINLOG_OVERLAY_ACTIONS, + TRAINLOG_FOCUS_CONTENT, app.exercise_detail.exercise_id); + app.overlays.items[0].selected = action_index; + app_shell_dispatch_overlay(&app, TRAINLOG_KEY_ENTER); + CHECK(trainlog_overlays_top(&app.overlays)->type == + TRAINLOG_OVERLAY_EXERCISE_MERGE); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_MERGE_PICKER); + app_shell_dispatch_overlay(&app, 't'); + app_shell_dispatch_overlay(&app, 'a'); + app_shell_dispatch_overlay(&app, 'r'); + CHECK(exercise_merge_match_count(&app.exercise_controller) == 1U); + app_shell_dispatch_overlay(&app, TRAINLOG_KEY_ENTER); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_MERGE_CONFIRM); + app_shell_dispatch_overlay(&app, TRAINLOG_KEY_DOWN); + app_shell_dispatch_overlay(&app, TRAINLOG_KEY_ENTER); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_MERGE_RESULT); + CHECK(strcmp(app.exercise_detail.exercise_id, + "ex_22222222-2222-4222-8222-222222222222") == 0); + CHECK(strcmp(app.navigation.current.stable_id, + "ex_22222222-2222-4222-8222-222222222222") == 0); + CHECK(trainlog_database_resolve_exercise_id(database, + "ex_11111111-1111-4111-8111-111111111111", resolved, + sizeof(resolved)) == TRAINLOG_STATUS_OK); + CHECK(strcmp(resolved, "ex_22222222-2222-4222-8222-222222222222") == 0); + app_shell_dispatch_overlay(&app, TRAINLOG_KEY_ENTER); + CHECK(app.overlays.count == 0U); + CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_IDLE); + trainlog_database_close(database); + return true; } int main(void) { - if (!test_assistance_creation_labels() || - !test_duration_creation_starts_empty() || - !test_append_requires_actual_and_rolls_back() || - !test_empty_sets_cannot_finish() || - !test_knowledge_scrolls_long_lists_at_minimum_terminal() || - !test_generator_dashboard_entry_and_policy_choices() || - !test_generator_plan_drafts_have_no_actual_sets() || - !test_generator_preview_scrolls_at_minimum_terminal() || - !test_generator_recency_continue_and_cancel() || - !test_generator_cancel_preview_writes_nothing() || - !test_generator_accept_requires_and_persists_actual() || - !test_generator_empty_knowledge_cannot_save()) return 1; + if (!test_knowledge_scrolls_long_lists_at_minimum_terminal() || + !test_shell_session_navigation_preserves_run_draft() || + !test_shell_generator_keep_and_accept_has_no_actuals() || + !test_shell_session_forms_keep_plan_and_actuals_separate() || + !test_shell_continuous_supplemental_fields_have_no_fake_set() || + !test_shell_generator_dose_edit_drops_stale_load_provenance() || + !test_shell_inline_equipment_creation_resumes_occurrence() || + !test_shell_equipment_catalog_create_preserves_invalid_form() || + !test_shell_inline_exercise_creation_and_stable_edit() || + !test_shell_navigation_focus_consumes_input() || + !test_shell_actions_overlay_executes_registered_search_action() || + !test_shell_compact_navigation_focus_contract() || + !test_shell_detail_back_restores_stable_list_selection() || + !test_shell_body_controller_preserves_identity_and_failed_edit() || + !test_shell_sync_f7_runs_bidirectional_and_refreshes_history() || + !test_shell_sync_history_neutralizes_legacy_directions() || + !test_equipment_collector_finds_match_after_first_pages() || + !test_exercise_merge_action_search_confirm_selects_target() || + !test_body_graphs_draw_inside_minimum_content_surface()) return 1; (void)printf("PASS tui_workflows\n"); return 0; }