feat(trainlog): complete app shell and exercise merge
This commit is contained in:
parent
7cb49964e3
commit
1f027743f2
101 changed files with 14754 additions and 12170 deletions
14
AGENTS.md
14
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.
|
||||
|
|
|
|||
31
CHANGELOG.md
31
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;
|
||||
|
|
|
|||
26
README.md
26
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_<uuid-v4>` identity is
|
||||
unchanged; completed history, an active draft, and synchronization therefore
|
||||
|
|
@ -222,14 +234,14 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
|
|||
|
||||
## Android-triggered synchronization
|
||||
|
||||
Launch the desktop TUI from a built checkout with:
|
||||
Launch the desktop TUI from this built checkout with:
|
||||
|
||||
```bash
|
||||
trainlog
|
||||
cd /home/fy59/Documents/trainlog
|
||||
./build/tui/trainlog
|
||||
```
|
||||
|
||||
The usual user command resolves to `build/tui/trainlog` in this checkout. The
|
||||
desktop database is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
||||
The desktop database is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
||||
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
|
||||
|
||||
Build the desktop first, then install the user service:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,14 +3,17 @@ package com.labfytools.trainlog
|
|||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.SyncExporter
|
||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.ui.TrainlogApp
|
||||
import com.labfytools.trainlog.ui.TrainlogAppState
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val appState by viewModels<TrainlogAppState>()
|
||||
override fun onCreate(
|
||||
savedInstanceState: Bundle?
|
||||
) {
|
||||
|
|
@ -46,6 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||
inbox = inbox,
|
||||
requestOutbox =
|
||||
requestOutbox,
|
||||
appState = appState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -111,6 +111,13 @@ sealed interface ExerciseBodyZoneImportResult {
|
|||
data object DatabaseError : ExerciseBodyZoneImportResult
|
||||
}
|
||||
|
||||
sealed interface ExerciseAliasImportResult {
|
||||
data class Applied(val added: Int, val skipped: Int) : ExerciseAliasImportResult
|
||||
data class Invalid(val message: String) : ExerciseAliasImportResult
|
||||
data class Conflict(val sourceExerciseId: String) : ExerciseAliasImportResult
|
||||
data object DatabaseError : ExerciseAliasImportResult
|
||||
}
|
||||
|
||||
sealed interface MobileSessionImportResult {
|
||||
/** CONTRACT: counters describe persistent mutations, not artifact size. */
|
||||
data class Applied(
|
||||
|
|
@ -252,6 +259,15 @@ data class TrainingExerciseContext(
|
|||
val recentPerformance: ExerciseOccurrencePage,
|
||||
)
|
||||
|
||||
sealed interface ManualPercentMaxResult {
|
||||
data class Available(
|
||||
val maxWeightKg: Double,
|
||||
val maxStartedAt: String,
|
||||
val targetWeightKg: Double,
|
||||
) : ManualPercentMaxResult
|
||||
data class Unavailable(val message: String) : ManualPercentMaxResult
|
||||
}
|
||||
|
||||
class TrainlogRepository(
|
||||
context: Context,
|
||||
databaseName: String =
|
||||
|
|
@ -737,11 +753,12 @@ class TrainlogRepository(
|
|||
return try {
|
||||
db.beginTransaction()
|
||||
incoming.forEach { item ->
|
||||
val rowId = lookupExerciseRowIdOrNull(db, item.exerciseId)
|
||||
val canonicalExerciseId = resolveExerciseId(db, item.exerciseId)
|
||||
val rowId = lookupExerciseRowIdOrNull(db, canonicalExerciseId)
|
||||
?: return ExerciseBodyZoneImportResult.Invalid(
|
||||
"Exercice de relation inconnu : ${item.exerciseId}",
|
||||
)
|
||||
val local = readExerciseBodyZones(db, item.exerciseId)
|
||||
val local = readExerciseBodyZones(db, canonicalExerciseId)
|
||||
val localState = bodyZoneSyncState(local.first, local.second)
|
||||
val incomingState = bodyZoneSyncState(item.primary, item.secondary)
|
||||
val baseline = db.rawQuery(
|
||||
|
|
@ -754,13 +771,13 @@ class TrainlogRepository(
|
|||
skipped += 1
|
||||
}
|
||||
baseline != null && localState == baseline -> {
|
||||
replaceExerciseBodyZones(db, item.exerciseId, item.primary, item.secondary)
|
||||
replaceExerciseBodyZones(db, canonicalExerciseId, item.primary, item.secondary)
|
||||
writeBodyZoneSyncBaseline(db, rowId, incomingState)
|
||||
updated += 1
|
||||
}
|
||||
baseline != null && incomingState == baseline -> keptLocal += 1
|
||||
baseline == null && localState == "|" -> {
|
||||
replaceExerciseBodyZones(db, item.exerciseId, item.primary, item.secondary)
|
||||
replaceExerciseBodyZones(db, canonicalExerciseId, item.primary, item.secondary)
|
||||
writeBodyZoneSyncBaseline(db, rowId, incomingState)
|
||||
updated += 1
|
||||
}
|
||||
|
|
@ -776,6 +793,126 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTRACT: this companion is the only publication of retired exercise
|
||||
* identities. Mappings are sorted, flattened, one hop, and never alter a
|
||||
* mobile session exchange version.
|
||||
*/
|
||||
fun buildExerciseAliasesJson(): String {
|
||||
val aliases = JSONArray()
|
||||
database.readableDatabase.rawQuery(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " +
|
||||
"ORDER BY source_exercise_id COLLATE BINARY;",
|
||||
null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
aliases.put(JSONObject()
|
||||
.put("source_exercise_id", cursor.getString(0))
|
||||
.put("canonical_exercise_id", cursor.getString(1)))
|
||||
}
|
||||
}
|
||||
check(aliases.length() <= MAX_EXERCISE_ALIASES) { "Trop d'alias exercice." }
|
||||
return JSONObject()
|
||||
.put("format", "trainlog-exercise-aliases")
|
||||
.put("version", 1)
|
||||
.put("aliases", aliases)
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun applyExerciseAliasesJson(json: String): ExerciseAliasImportResult {
|
||||
if (json.toByteArray(Charsets.UTF_8).size > MAX_EXERCISE_ALIAS_BYTES ||
|
||||
!jsonHasUniqueObjectKeys(json)) {
|
||||
return ExerciseAliasImportResult.Invalid("Artifact alias JSON invalide ou trop volumineux.")
|
||||
}
|
||||
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||
return ExerciseAliasImportResult.Invalid("Artifact alias JSON invalide.")
|
||||
}
|
||||
if (!root.hasExactKeys(setOf("format", "version", "aliases")) ||
|
||||
root.value("format") != "trainlog-exercise-aliases" ||
|
||||
!root.value("version").isJsonInt(1, 1) || root.value("aliases") !is JSONArray) {
|
||||
return ExerciseAliasImportResult.Invalid("Artifact alias v1 non supporté.")
|
||||
}
|
||||
val incoming = mutableListOf<Pair<String, String>>()
|
||||
val sources = mutableSetOf<String>()
|
||||
val array = root.getJSONArray("aliases")
|
||||
if (array.length() > MAX_EXERCISE_ALIASES)
|
||||
return ExerciseAliasImportResult.Invalid("Trop d'alias exercice.")
|
||||
for (index in 0 until array.length()) {
|
||||
val item = array.opt(index) as? JSONObject
|
||||
?: return ExerciseAliasImportResult.Invalid("Alias[$index] invalide.")
|
||||
if (!item.hasExactKeys(setOf("source_exercise_id", "canonical_exercise_id")))
|
||||
return ExerciseAliasImportResult.Invalid("Forme d'alias[$index] invalide.")
|
||||
val source = item.optString("source_exercise_id")
|
||||
val canonical = item.optString("canonical_exercise_id")
|
||||
if (!source.matches(EXERCISE_ID_V4_PATTERN) ||
|
||||
!canonical.matches(EXERCISE_ID_V4_PATTERN) || source == canonical ||
|
||||
!sources.add(source)) {
|
||||
return ExerciseAliasImportResult.Invalid("Identité d'alias[$index] invalide.")
|
||||
}
|
||||
incoming += source to canonical
|
||||
}
|
||||
if (incoming != incoming.sortedWith(compareBy<Pair<String, String>> { it.first }.thenBy { it.second }) ||
|
||||
incoming.any { it.second in sources }) {
|
||||
return ExerciseAliasImportResult.Invalid("Les alias doivent être triés et aplatis.")
|
||||
}
|
||||
|
||||
val db = database.writableDatabase
|
||||
var added = 0
|
||||
var skipped = 0
|
||||
return try {
|
||||
db.beginTransaction()
|
||||
incoming.forEach { (sourceId, canonicalId) ->
|
||||
val canonical = findExerciseRow(db, "exercise_id=?", arrayOf(canonicalId))
|
||||
?: return ExerciseAliasImportResult.Invalid(
|
||||
"Cible canonique absente : $canonicalId",
|
||||
)
|
||||
val existing = db.rawQuery(
|
||||
"SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?;",
|
||||
arrayOf(sourceId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
if (existing != null) {
|
||||
if (existing != canonicalId) return ExerciseAliasImportResult.Conflict(sourceId)
|
||||
skipped += 1
|
||||
return@forEach
|
||||
}
|
||||
val retired = findExerciseRow(db, "exercise_id=?", arrayOf(sourceId))
|
||||
if (retired != null) {
|
||||
if (retired.recordingMode != canonical.recordingMode ||
|
||||
retired.trackingMode != canonical.trackingMode ||
|
||||
retired.dataFields != canonical.dataFields ||
|
||||
!aliasBodyZonesAreCompatible(db, canonical, retired) ||
|
||||
!catalogEquipmentProfilesAreCompatible(db, sourceId, canonicalId)) {
|
||||
return ExerciseAliasImportResult.Conflict(sourceId)
|
||||
}
|
||||
/* INVARIANT: flatten incoming edges before deleting the
|
||||
* intermediate catalogue row protected by the alias FK. */
|
||||
db.execSQL(
|
||||
"UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?;",
|
||||
arrayOf(canonicalId, sourceId),
|
||||
)
|
||||
mergeAliasedExerciseRows(db, canonical, retired)
|
||||
}
|
||||
/* INVARIANT: aliases which previously targeted the retired ID
|
||||
* remain one hop from a live catalogue row. */
|
||||
db.execSQL(
|
||||
"UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?;",
|
||||
arrayOf(canonicalId, sourceId),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) VALUES(?,?);",
|
||||
arrayOf(sourceId, canonicalId),
|
||||
)
|
||||
added += 1
|
||||
}
|
||||
db.setTransactionSuccessful()
|
||||
ExerciseAliasImportResult.Applied(added, skipped)
|
||||
} catch (_: Exception) {
|
||||
ExerciseAliasImportResult.DatabaseError
|
||||
} finally {
|
||||
if (db.inTransaction()) db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bodyZoneSyncState(primary: String?, secondary: List<String>): String =
|
||||
(primary ?: "") + "|" + secondary.sorted().joinToString(",")
|
||||
|
||||
|
|
@ -1390,10 +1527,12 @@ class TrainlogRepository(
|
|||
index
|
||||
)
|
||||
|
||||
val exerciseId =
|
||||
item.getString(
|
||||
"exercise_id"
|
||||
)
|
||||
val suppliedExerciseId = item.getString("exercise_id")
|
||||
/* CONTRACT: a peer which has not yet consumed the companion
|
||||
* may resend a retired catalogue ID; resolve it before lookup
|
||||
* so the old row can never be resurrected. */
|
||||
val exerciseId = resolveExerciseId(db, suppliedExerciseId)
|
||||
val suppliedRetiredAlias = suppliedExerciseId != exerciseId
|
||||
|
||||
val name =
|
||||
item.getString(
|
||||
|
|
@ -1501,6 +1640,34 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
/* Retired identity is routing information only. Its stale
|
||||
* name must not overwrite canonical presentation metadata
|
||||
* or trigger a normalized-name merge. */
|
||||
if (suppliedRetiredAlias) {
|
||||
val richerFields = byId.dataFields or dataFields
|
||||
if (richerFields == byId.dataFields) {
|
||||
skipped += 1
|
||||
traceDecision(
|
||||
"exercise_alias:$suppliedExerciseId;exercise_id:${byId.exerciseId}",
|
||||
"existing-identical",
|
||||
)
|
||||
} else {
|
||||
if (db.update(
|
||||
"exercises",
|
||||
ContentValues().apply { put("data_fields", richerFields) },
|
||||
"id = ?",
|
||||
arrayOf(byId.rowId.toString()),
|
||||
) != 1
|
||||
) return PcCatalogImportResult.DatabaseError
|
||||
reconciled += 1
|
||||
traceDecision(
|
||||
"exercise_alias:$suppliedExerciseId;exercise_id:${byId.exerciseId}",
|
||||
"existing-reconciled",
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
/* CONTRACT: a catalog name is mutable metadata. Identity
|
||||
* reconciliation always prefers exercise_id, so an update
|
||||
* retains the row used by completed sessions and drafts. */
|
||||
|
|
@ -1817,12 +1984,69 @@ class TrainlogRepository(
|
|||
retired.exerciseId,
|
||||
canonical.exerciseId,
|
||||
)
|
||||
if (sqliteTableExists(db, "exercise_aliases")) {
|
||||
db.execSQL(
|
||||
"UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?;",
|
||||
arrayOf(canonical.exerciseId, retired.exerciseId),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) VALUES(?,?);",
|
||||
arrayOf(retired.exerciseId, canonical.exerciseId),
|
||||
)
|
||||
}
|
||||
db.execSQL(
|
||||
"DELETE FROM exercises WHERE id=?;",
|
||||
arrayOf(retired.rowId),
|
||||
)
|
||||
}
|
||||
|
||||
private fun aliasBodyZonesAreCompatible(
|
||||
db: SQLiteDatabase,
|
||||
canonical: ExerciseRow,
|
||||
retired: ExerciseRow,
|
||||
): Boolean {
|
||||
val canonicalPrimary = readExerciseBodyZones(db, canonical.exerciseId).first
|
||||
val retiredPrimary = readExerciseBodyZones(db, retired.exerciseId).first
|
||||
return canonicalPrimary == null || retiredPrimary == null ||
|
||||
canonicalPrimary == retiredPrimary
|
||||
}
|
||||
|
||||
private fun mergeAliasedExerciseRows(
|
||||
db: SQLiteDatabase,
|
||||
canonical: ExerciseRow,
|
||||
retired: ExerciseRow,
|
||||
) {
|
||||
val canonicalZones = readExerciseBodyZones(db, canonical.exerciseId)
|
||||
val retiredZones = readExerciseBodyZones(db, retired.exerciseId)
|
||||
val primary = canonicalZones.first ?: retiredZones.first
|
||||
val secondaries = (canonicalZones.second + retiredZones.second)
|
||||
.filter { it != primary }.distinct()
|
||||
replaceExerciseBodyZones(db, canonical.exerciseId, primary, secondaries)
|
||||
db.execSQL("DELETE FROM exercise_body_zones WHERE exercise_row_id=?;", arrayOf(retired.rowId))
|
||||
if (sqliteTableExists(db, "exercise_body_zone_sync")) {
|
||||
db.execSQL(
|
||||
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);",
|
||||
arrayOf(canonical.rowId, retired.rowId),
|
||||
)
|
||||
}
|
||||
/* CONTRACT: row-owned occurrence and draft children keep their stable
|
||||
* IDs and actual/planning values; only the catalogue FK is repointed. */
|
||||
db.execSQL("UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;",
|
||||
arrayOf(canonical.rowId, retired.rowId))
|
||||
db.execSQL("UPDATE draft_session_exercises SET exercise_row_id=? WHERE exercise_row_id=?;",
|
||||
arrayOf(canonical.rowId, retired.rowId))
|
||||
db.execSQL("UPDATE active_session_draft SET selected_exercise_row_id=? WHERE selected_exercise_row_id=?;",
|
||||
arrayOf(canonical.rowId, retired.rowId))
|
||||
db.execSQL(
|
||||
"INSERT OR IGNORE INTO exercise_equipment(exercise_row_id,equipment_row_id) " +
|
||||
"SELECT ?,equipment_row_id FROM exercise_equipment WHERE exercise_row_id=?;",
|
||||
arrayOf(canonical.rowId, retired.rowId),
|
||||
)
|
||||
db.execSQL("DELETE FROM exercise_equipment WHERE exercise_row_id=?;", arrayOf(retired.rowId))
|
||||
mergeCatalogEquipmentIdentity(db, retired.exerciseId, canonical.exerciseId)
|
||||
db.execSQL("DELETE FROM exercises WHERE id=?;", arrayOf(retired.rowId))
|
||||
}
|
||||
|
||||
private fun sqliteTableExists(db: SQLiteDatabase, table: String): Boolean =
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?;",
|
||||
|
|
@ -1839,7 +2063,7 @@ class TrainlogRepository(
|
|||
val retired = readExerciseBodyZones(db, retiredExerciseId)
|
||||
val canonicalEmpty = canonical.first == null && canonical.second.isEmpty()
|
||||
val retiredEmpty = retired.first == null && retired.second.isEmpty()
|
||||
return canonicalEmpty || retiredEmpty || canonical == retired
|
||||
return canonical.first == null || retired.first == null || canonical.first == retired.first
|
||||
}
|
||||
|
||||
private fun mergeExerciseBodyZoneRows(
|
||||
|
|
@ -1852,7 +2076,8 @@ class TrainlogRepository(
|
|||
val retiredState = readExerciseBodyZones(db, retired.exerciseId)
|
||||
val canonicalEmpty = canonicalState.first == null && canonicalState.second.isEmpty()
|
||||
val retiredEmpty = retiredState.first == null && retiredState.second.isEmpty()
|
||||
check(canonicalEmpty || retiredEmpty || canonicalState == retiredState) {
|
||||
check(canonicalState.first == null || retiredState.first == null ||
|
||||
canonicalState.first == retiredState.first) {
|
||||
"Relations de zones incompatibles pendant la réconciliation d'identité."
|
||||
}
|
||||
|
||||
|
|
@ -1862,17 +2087,21 @@ class TrainlogRepository(
|
|||
* INVARIANT: baselines are cleared because an identity merge is not a
|
||||
* synchronization acknowledgement; the next companion must establish
|
||||
* a fresh common ancestor before accepting a one-sided change. */
|
||||
if (canonicalEmpty && !retiredEmpty) {
|
||||
db.execSQL(
|
||||
"UPDATE exercise_body_zones SET exercise_row_id=? WHERE exercise_row_id=?;",
|
||||
arrayOf(canonical.rowId, retired.rowId),
|
||||
)
|
||||
} else {
|
||||
db.execSQL(
|
||||
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?;",
|
||||
arrayOf(retired.rowId),
|
||||
)
|
||||
val primary = canonicalState.first ?: retiredState.first
|
||||
if (primary != null) {
|
||||
db.execSQL("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?;",
|
||||
arrayOf<Any>(canonical.rowId, primary))
|
||||
db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,'primary');",
|
||||
arrayOf<Any>(canonical.rowId, primary))
|
||||
}
|
||||
db.execSQL(
|
||||
"INSERT OR IGNORE INTO exercise_body_zones(exercise_row_id,zone_id,role) " +
|
||||
"SELECT ?,zone_id,'secondary' FROM exercise_body_zones " +
|
||||
"WHERE exercise_row_id=? AND role='secondary' AND zone_id<>COALESCE(?, '');",
|
||||
arrayOf<Any?>(canonical.rowId, retired.rowId, primary),
|
||||
)
|
||||
db.execSQL("DELETE FROM exercise_body_zones WHERE exercise_row_id=?;",
|
||||
arrayOf(retired.rowId))
|
||||
if (sqliteTableExists(db, "exercise_body_zone_sync")) {
|
||||
db.execSQL(
|
||||
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);",
|
||||
|
|
@ -1943,8 +2172,8 @@ class TrainlogRepository(
|
|||
private fun lookupExerciseRowIdOrNull(
|
||||
db: SQLiteDatabase,
|
||||
exerciseId: String,
|
||||
): Long? =
|
||||
db.query(
|
||||
): Long? {
|
||||
val direct = db.query(
|
||||
"exercises",
|
||||
arrayOf("id"),
|
||||
"exercise_id = ?",
|
||||
|
|
@ -1955,6 +2184,19 @@ class TrainlogRepository(
|
|||
).use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getLong(0) else null
|
||||
}
|
||||
if (direct != null) return direct
|
||||
return db.rawQuery(
|
||||
"SELECT e.id FROM exercise_aliases a JOIN exercises e " +
|
||||
"ON e.exercise_id=a.canonical_exercise_id WHERE a.source_exercise_id=?;",
|
||||
arrayOf(exerciseId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null }
|
||||
}
|
||||
|
||||
private fun resolveExerciseId(db: SQLiteDatabase, exerciseId: String): String =
|
||||
db.rawQuery(
|
||||
"SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?;",
|
||||
arrayOf(exerciseId),
|
||||
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else exerciseId }
|
||||
|
||||
private fun lookupEquipmentRowIdOrNull(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -2245,7 +2487,11 @@ class TrainlogRepository(
|
|||
for (index in 0 until entries.length()) {
|
||||
val entry = entries.getJSONObject(index)
|
||||
val exerciseId = entry.optString("exercise_id")
|
||||
val exerciseRow = findExerciseRow(db, "exercise_id = ?", arrayOf(exerciseId))
|
||||
val exerciseRow = findExerciseRow(
|
||||
db,
|
||||
"exercise_id = ?",
|
||||
arrayOf(resolveExerciseId(db, exerciseId)),
|
||||
)
|
||||
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
|
||||
if (
|
||||
entry.optString("recording_mode") != exerciseRow.recordingMode.wireValue ||
|
||||
|
|
@ -2289,7 +2535,11 @@ class TrainlogRepository(
|
|||
if (entryId.isBlank() || !seen.add(entryId) || exerciseId.isBlank() || position < 0) {
|
||||
return MobileSessionImportResult.Invalid("Identité d'entrée V2 invalide.")
|
||||
}
|
||||
val exerciseRow = findExerciseRow(db, "exercise_id = ?", arrayOf(exerciseId))
|
||||
val exerciseRow = findExerciseRow(
|
||||
db,
|
||||
"exercise_id = ?",
|
||||
arrayOf(resolveExerciseId(db, exerciseId)),
|
||||
)
|
||||
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
|
||||
val recording = entry.optString("recording_mode")
|
||||
val tracking = entry.optString("tracking_mode")
|
||||
|
|
@ -2658,7 +2908,8 @@ class TrainlogRepository(
|
|||
if (rows.size != incoming.length()) return false
|
||||
for (index in rows.indices) {
|
||||
val item = incoming.getJSONObject(index)
|
||||
val expected = mutableListOf<Any?>(item.optString("entry_id"), item.optInt("position", -1), item.optString("exercise_id"),
|
||||
val expected = mutableListOf<Any?>(item.optString("entry_id"), item.optInt("position", -1),
|
||||
resolveExerciseId(db, item.optString("exercise_id")),
|
||||
item.optString("recording_mode"), item.optString("tracking_mode"), item.optInt("data_fields", -1),
|
||||
if (item.isNull("equipment_id")) null else item.optString("equipment_id"))
|
||||
if (version == 3) {
|
||||
|
|
@ -2730,7 +2981,7 @@ class TrainlogRepository(
|
|||
current[index] == Triple(
|
||||
item.optString("entry_id"),
|
||||
item.optInt("position", -1),
|
||||
item.optString("exercise_id"),
|
||||
resolveExerciseId(db, item.optString("exercise_id")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -2932,6 +3183,7 @@ class TrainlogRepository(
|
|||
val item = items.getJSONObject(index)
|
||||
val sessionId = item.optString("session_id")
|
||||
val exerciseId = item.optString("exercise_id")
|
||||
val canonicalExerciseId = resolveExerciseId(db, exerciseId)
|
||||
val entryId = if (version == 2) item.optString("entry_id") else null
|
||||
val state = item.optString("state")
|
||||
val identity = "$sessionId\u0000${entryId ?: exerciseId}"
|
||||
|
|
@ -2948,19 +3200,19 @@ class TrainlogRepository(
|
|||
} else {
|
||||
"SELECT se.id,eq.equipment_id FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id WHERE s.session_id=? AND e.exercise_id=?;"
|
||||
},
|
||||
if (version == 2) arrayOf(sessionId, entryId) else arrayOf(sessionId, exerciseId),
|
||||
if (version == 2) arrayOf(sessionId, entryId) else arrayOf(sessionId, canonicalExerciseId),
|
||||
).use { cursor ->
|
||||
val first = if (cursor.moveToFirst()) {
|
||||
if (version == 2) {
|
||||
Triple(cursor.getLong(0), cursor.getString(1), if (cursor.isNull(2)) null else cursor.getString(2))
|
||||
} else {
|
||||
Triple(cursor.getLong(0), exerciseId, if (cursor.isNull(1)) null else cursor.getString(1))
|
||||
Triple(cursor.getLong(0), canonicalExerciseId, if (cursor.isNull(1)) null else cursor.getString(1))
|
||||
}
|
||||
} else null
|
||||
if (version == 1 && first != null && cursor.moveToNext()) null else first
|
||||
}
|
||||
?: return EquipmentAssociationImportResult.Invalid("Entrée de séance inconnue : $sessionId/$exerciseId")
|
||||
if (row.second != exerciseId) {
|
||||
if (row.second != canonicalExerciseId) {
|
||||
return EquipmentAssociationImportResult.Invalid("Conflit exercice association : $sessionId/${entryId ?: exerciseId}")
|
||||
}
|
||||
if (row.third != equipmentId) {
|
||||
|
|
@ -3587,12 +3839,16 @@ class TrainlogRepository(
|
|||
targetRepetitions: Int,
|
||||
restSeconds: Int,
|
||||
manualWeightKg: Double?,
|
||||
loadChoice: GeneratorLoadChoice = GeneratorLoadChoice.AUTOMATIC,
|
||||
maxPercent: Int? = null,
|
||||
): SessionGenerationResult {
|
||||
val current = preview.exercises.getOrNull(index)
|
||||
?: return SessionGenerationResult.Invalid("Exercice de proposition introuvable.")
|
||||
if (targetSets !in 1..MAX_PLAN_SETS || targetRepetitions !in 1..MAX_PLAN_REPS ||
|
||||
restSeconds !in 0..MAX_PLAN_REST_SECONDS ||
|
||||
(manualWeightKg != null && (!manualWeightKg.isFinite() || manualWeightKg <= 0.0)))
|
||||
(manualWeightKg != null && (!manualWeightKg.isFinite() || manualWeightKg <= 0.0)) ||
|
||||
(loadChoice == GeneratorLoadChoice.PERCENT_MAX &&
|
||||
(maxPercent == null || maxPercent !in 1..100)))
|
||||
return SessionGenerationResult.Invalid("Dose cible invalide.")
|
||||
val db = database.readableDatabase
|
||||
val ownsTransaction = !db.inTransaction()
|
||||
|
|
@ -3604,7 +3860,20 @@ class TrainlogRepository(
|
|||
candidate, preview.request.referenceTime, targetSets, targetRepetitions, restSeconds,
|
||||
) { visitor -> streamGenerationHistory(db, visitor) }
|
||||
val semantics = candidate.equipmentLoadSemantics
|
||||
val plan = if (manualWeightKg == null) {
|
||||
val percentageTarget = if (loadChoice == GeneratorLoadChoice.PERCENT_MAX) {
|
||||
readLatestExplicitMax(db, current.exerciseId, current.equipmentId)?.let { maximum ->
|
||||
PercentMaxCalculator.calculate(maximum, current.equipmentId, checkNotNull(maxPercent))
|
||||
}
|
||||
} else null
|
||||
val plan = if (loadChoice == GeneratorLoadChoice.NONE) {
|
||||
SessionExercisePlan(targetSets, reps = targetRepetitions,
|
||||
weightKg = null, loadMode = SessionLoadMode.NONE, restSeconds = restSeconds)
|
||||
} else if (loadChoice == GeneratorLoadChoice.PERCENT_MAX) {
|
||||
SessionExercisePlan(targetSets, reps = targetRepetitions,
|
||||
weightKg = percentageTarget,
|
||||
loadMode = if (percentageTarget == null) SessionLoadMode.NONE else SessionLoadMode.EXTERNAL,
|
||||
restSeconds = restSeconds)
|
||||
} else if (manualWeightKg == null) {
|
||||
SessionExercisePlan(qualified.targetSets, reps = qualified.targetRepetitions,
|
||||
weightKg = qualified.targetWeightKg, loadMode = qualified.plannedLoadMode,
|
||||
restSeconds = qualified.restSeconds)
|
||||
|
|
@ -3621,11 +3890,16 @@ class TrainlogRepository(
|
|||
plan = plan,
|
||||
estimatedSeconds = sessionGenerationEngine.estimateExerciseSeconds(
|
||||
targetSets, targetRepetitions, restSeconds),
|
||||
rationaleCodes = if (manualWeightKg == null) listOf(qualified.rationaleCode)
|
||||
else listOf("manual_target_load"),
|
||||
loadSourceSessionId = if (manualWeightKg == null) qualified.sourceSessionId else null,
|
||||
loadSourceOccurrenceId = if (manualWeightKg == null) qualified.sourceOccurrenceId else null,
|
||||
loadSourceStartedAt = if (manualWeightKg == null) qualified.sourceStartedAt else null,
|
||||
rationaleCodes = when (loadChoice) {
|
||||
GeneratorLoadChoice.PERCENT_MAX -> listOf(if (percentageTarget != null)
|
||||
"user_selected_max_percentage" else "compatible_max_unavailable")
|
||||
GeneratorLoadChoice.NONE -> listOf("numeric_load_absent")
|
||||
GeneratorLoadChoice.AUTOMATIC -> if (manualWeightKg == null)
|
||||
listOf(qualified.rationaleCode) else listOf("manual_target_load")
|
||||
},
|
||||
loadSourceSessionId = if (loadChoice == GeneratorLoadChoice.AUTOMATIC && manualWeightKg == null) qualified.sourceSessionId else null,
|
||||
loadSourceOccurrenceId = if (loadChoice == GeneratorLoadChoice.AUTOMATIC && manualWeightKg == null) qualified.sourceOccurrenceId else null,
|
||||
loadSourceStartedAt = if (loadChoice == GeneratorLoadChoice.AUTOMATIC && manualWeightKg == null) qualified.sourceStartedAt else null,
|
||||
)
|
||||
val exercises = preview.exercises.toMutableList().also { it[index] = changed }
|
||||
val total = Math.addExact(sessionGenerationPolicy.preparationSeconds,
|
||||
|
|
@ -3771,6 +4045,52 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only manual target calculation; it never creates or updates a draft.
|
||||
* WHY: assistance and similar names cannot establish external resistance.
|
||||
* CONTRACT: exact stable exercise/equipment IDs and the latest chronological
|
||||
* explicit MAX in that context are required.
|
||||
*/
|
||||
fun calculateManualPercentMaxTarget(
|
||||
exerciseId: String,
|
||||
equipmentId: String?,
|
||||
percent: Int,
|
||||
): ManualPercentMaxResult {
|
||||
if (percent !in 1..100)
|
||||
return ManualPercentMaxResult.Unavailable("Le pourcentage doit être compris entre 1 et 100.")
|
||||
if (equipmentId.isNullOrBlank())
|
||||
return ManualPercentMaxResult.Unavailable(
|
||||
"MAX compatible indisponible : choisissez un équipement à résistance externe.")
|
||||
val db = database.readableDatabase
|
||||
val ownsTransaction = !db.inTransaction()
|
||||
return try {
|
||||
if (ownsTransaction) db.beginTransactionNonExclusive()
|
||||
if (readExerciseProfileExact(db, exerciseId) == null)
|
||||
return ManualPercentMaxResult.Unavailable("Exercice introuvable.")
|
||||
val runtimeEquipment = readRuntimeEquipment(db, equipmentId)
|
||||
if (runtimeEquipment?.second == EquipmentLoadSemantics.ASSISTANCE)
|
||||
return ManualPercentMaxResult.Unavailable(
|
||||
"Le %MAX est indisponible pour une assistance ; choisissez une résistance externe.")
|
||||
if (runtimeEquipment?.second != EquipmentLoadSemantics.EXTERNAL)
|
||||
return ManualPercentMaxResult.Unavailable(
|
||||
"Le %MAX est indisponible sans équipement connu à résistance externe.")
|
||||
val maximum = readLatestExplicitMax(db, exerciseId, equipmentId)
|
||||
?: return ManualPercentMaxResult.Unavailable(
|
||||
"MAX compatible indisponible pour cet exercice et cet équipement exacts.")
|
||||
val target = PercentMaxCalculator.calculate(maximum, equipmentId, percent)
|
||||
?: return ManualPercentMaxResult.Unavailable(
|
||||
"MAX compatible indisponible pour cet exercice et cet équipement exacts.")
|
||||
if (ownsTransaction) db.setTransactionSuccessful()
|
||||
/* INVARIANT: the repository returns arithmetic context only. The UI
|
||||
* persists targetWeightKg solely when the user confirms its form. */
|
||||
ManualPercentMaxResult.Available(maximum.maxWeightKg, maximum.startedAt, target)
|
||||
} catch (error: Exception) {
|
||||
ManualPercentMaxResult.Unavailable(error.message ?: "Lecture du MAX impossible.")
|
||||
} finally {
|
||||
if (ownsTransaction && db.inTransaction()) db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic keyset page over current data; pages do not hold a cross-call snapshot. */
|
||||
fun listExerciseOccurrences(
|
||||
exerciseId: String,
|
||||
|
|
@ -3902,6 +4222,41 @@ class TrainlogRepository(
|
|||
}
|
||||
}
|
||||
|
||||
/** Latest chronological explicit MAX for one exact external-load context. */
|
||||
private fun readLatestExplicitMax(
|
||||
db: SQLiteDatabase,
|
||||
exerciseId: String,
|
||||
equipmentId: String,
|
||||
): ExplicitMaxContext? {
|
||||
val selected = mutableListOf<TemporalCandidate>()
|
||||
db.rawQuery(
|
||||
"""SELECT se.id,s.session_id,se.entry_id,s.started_at FROM max_results mr
|
||||
JOIN session_exercises se ON se.id=mr.session_exercise_row_id
|
||||
JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id
|
||||
JOIN equipment eq ON eq.id=se.equipment_row_id
|
||||
WHERE e.exercise_id=? AND eq.equipment_id=? AND eq.load_semantics='external';""",
|
||||
arrayOf(exerciseId, equipmentId),
|
||||
).use { cursor -> while (cursor.moveToNext()) retainCandidate(selected, candidate(cursor), 1) }
|
||||
val winner = selected.singleOrNull() ?: return null
|
||||
return db.rawQuery(
|
||||
"""SELECT s.session_id,se.entry_id,s.started_at,mr.max_weight_kg,
|
||||
eq.equipment_id,eq.display_name,eq.load_semantics
|
||||
FROM max_results mr JOIN session_exercises se ON se.id=mr.session_exercise_row_id
|
||||
JOIN sessions s ON s.id=se.session_row_id JOIN equipment eq ON eq.id=se.equipment_row_id
|
||||
WHERE se.id=?;""",
|
||||
arrayOf(winner.rowId.toString()),
|
||||
).use { cursor ->
|
||||
check(cursor.moveToFirst()) { "résultat MAX compatible sélectionné absent" }
|
||||
ExplicitMaxContext(
|
||||
cursor.requiredText(0, "session_id"), cursor.requiredText(1, "entry_id"),
|
||||
cursor.requiredText(2, "started_at"),
|
||||
cursor.finiteNonNegativeDouble(3, "max_weight_kg", strictlyPositive = true),
|
||||
cursor.requiredText(4, "equipment_id"), cursor.requiredText(5, "display_name"),
|
||||
parseLoadSemantics(cursor.requiredText(6, "load_semantics")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readExerciseOccurrencePage(
|
||||
db: SQLiteDatabase, exerciseId: String, limit: Int, after: ExerciseOccurrenceCursor?, setPreviewLimit: Int,
|
||||
): ExerciseOccurrencePage {
|
||||
|
|
@ -4016,44 +4371,45 @@ class TrainlogRepository(
|
|||
* presentation context only and never participates in max identity.
|
||||
*/
|
||||
fun listLatestExerciseMaxima(): List<LatestExerciseMax> {
|
||||
val output = mutableListOf<LatestExerciseMax>()
|
||||
data class AggregateCandidate(
|
||||
val temporal: TemporalCandidate,
|
||||
val result: LatestExerciseMax,
|
||||
)
|
||||
|
||||
val selected = linkedMapOf<String, AggregateCandidate>()
|
||||
database.readableDatabase.rawQuery(
|
||||
"""
|
||||
SELECT e.exercise_id, e.name, mr.max_weight_kg, s.started_at,
|
||||
eq.display_name
|
||||
SELECT se.id, s.session_id, se.entry_id, s.started_at,
|
||||
e.exercise_id, e.name, mr.max_weight_kg, eq.display_name
|
||||
FROM max_results AS mr
|
||||
JOIN session_exercises AS se ON se.id = mr.session_exercise_row_id
|
||||
JOIN sessions AS s ON s.id = se.session_row_id
|
||||
JOIN exercises AS e ON e.id = se.exercise_row_id
|
||||
LEFT JOIN equipment AS eq ON eq.id = se.equipment_row_id
|
||||
WHERE s.session_type = 'max_test'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM max_results AS newer_mr
|
||||
JOIN session_exercises AS newer_se
|
||||
ON newer_se.id = newer_mr.session_exercise_row_id
|
||||
JOIN sessions AS newer_s ON newer_s.id = newer_se.session_row_id
|
||||
WHERE newer_se.exercise_row_id = se.exercise_row_id
|
||||
AND newer_s.session_type = 'max_test'
|
||||
AND (newer_s.started_at > s.started_at OR
|
||||
(newer_s.started_at = s.started_at AND
|
||||
newer_se.position > se.position))
|
||||
)
|
||||
ORDER BY e.name COLLATE NOCASE, e.exercise_id;
|
||||
""".trimIndent(),
|
||||
null,
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
output += LatestExerciseMax(
|
||||
exerciseId = cursor.getString(0),
|
||||
exerciseName = cursor.getString(1),
|
||||
maxWeightKg = cursor.getDouble(2),
|
||||
startedAt = cursor.getString(3),
|
||||
equipmentDisplayName = if (cursor.isNull(4)) null else cursor.getString(4),
|
||||
val temporal = candidate(cursor)
|
||||
val exerciseId = cursor.requiredText(4, "exercise_id")
|
||||
val value = AggregateCandidate(
|
||||
temporal = temporal,
|
||||
result = LatestExerciseMax(
|
||||
exerciseId = exerciseId,
|
||||
exerciseName = cursor.requiredText(5, "exercise_name"),
|
||||
maxWeightKg = cursor.finiteNonNegativeDouble(6, "max_weight_kg", true),
|
||||
startedAt = temporal.startedAt,
|
||||
equipmentDisplayName = cursor.optionalText(7),
|
||||
),
|
||||
)
|
||||
val current = selected[exerciseId]
|
||||
if (current == null || compareTemporal(temporal, current.temporal) > 0)
|
||||
selected[exerciseId] = value
|
||||
}
|
||||
}
|
||||
return output
|
||||
return selected.values.map { it.result }
|
||||
}
|
||||
|
||||
fun setCompletedSessionEquipment(
|
||||
|
|
@ -5005,6 +5361,38 @@ private fun JSONObject.optDoubleOrNull(key: String): Double? =
|
|||
private fun JSONObject.optIntOrNull(key: String): Int? =
|
||||
if (has(key) && !isNull(key)) getInt(key) else null
|
||||
|
||||
private fun jsonHasUniqueObjectKeys(json: String): Boolean = try {
|
||||
JsonReader(StringReader(json)).use { reader ->
|
||||
fun consumeValue() {
|
||||
when (reader.peek()) {
|
||||
JsonToken.BEGIN_OBJECT -> {
|
||||
reader.beginObject()
|
||||
val names = mutableSetOf<String>()
|
||||
while (reader.hasNext()) {
|
||||
check(names.add(reader.nextName())) { "champ JSON dupliqué" }
|
||||
consumeValue()
|
||||
}
|
||||
reader.endObject()
|
||||
}
|
||||
JsonToken.BEGIN_ARRAY -> {
|
||||
reader.beginArray()
|
||||
while (reader.hasNext()) consumeValue()
|
||||
reader.endArray()
|
||||
}
|
||||
JsonToken.STRING, JsonToken.NUMBER -> reader.nextString()
|
||||
JsonToken.BOOLEAN -> reader.nextBoolean()
|
||||
JsonToken.NULL -> reader.nextNull()
|
||||
else -> error("JSON incomplet")
|
||||
}
|
||||
}
|
||||
consumeValue()
|
||||
check(reader.peek() == JsonToken.END_DOCUMENT) { "JSON supplémentaire" }
|
||||
}
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
private fun equipmentAliasNormalize(value: String): String =
|
||||
Normalizer.normalize(value, Normalizer.Form.NFD)
|
||||
.replace("\\p{M}+".toRegex(), "")
|
||||
|
|
@ -5019,6 +5407,8 @@ private const val MAX_PLAN_SETS = 64
|
|||
private const val MAX_PLAN_REPS = 10000
|
||||
private const val MAX_PLAN_DURATION_SECONDS = 86400
|
||||
private const val MAX_PLAN_REST_SECONDS = 86400
|
||||
private const val MAX_EXERCISE_ALIAS_BYTES = 1024 * 1024
|
||||
private const val MAX_EXERCISE_ALIASES = 4096
|
||||
private const val ANDROID_LEG_PRESS_LEGACY_ID =
|
||||
"ex_d68a1af1-7247-4fb3-a48b-da8516906a29"
|
||||
private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
|
||||
|
|
@ -5034,7 +5424,7 @@ private class TrainlogDatabaseHelper(
|
|||
appContext,
|
||||
databaseName,
|
||||
null,
|
||||
11,
|
||||
12,
|
||||
) {
|
||||
override fun onConfigure(
|
||||
db: SQLiteDatabase,
|
||||
|
|
@ -5062,6 +5452,7 @@ private class TrainlogDatabaseHelper(
|
|||
createActiveDraftTables(db)
|
||||
createEquipmentTables(db)
|
||||
createBodyZoneTables(db)
|
||||
createExerciseAliasTable(db)
|
||||
seedEquipment(db)
|
||||
}
|
||||
|
||||
|
|
@ -5156,6 +5547,11 @@ private class TrainlogDatabaseHelper(
|
|||
version = 11
|
||||
}
|
||||
|
||||
if (version < 12 && newVersion >= 12) {
|
||||
createExerciseAliasTable(db)
|
||||
version = 12
|
||||
}
|
||||
|
||||
if (version != newVersion) {
|
||||
error(
|
||||
"Unsupported Android DB upgrade " +
|
||||
|
|
@ -5185,6 +5581,16 @@ private class TrainlogDatabaseHelper(
|
|||
)
|
||||
}
|
||||
|
||||
private fun createExerciseAliasTable(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS exercise_aliases(" +
|
||||
"source_exercise_id TEXT PRIMARY KEY," +
|
||||
"canonical_exercise_id TEXT NOT NULL REFERENCES exercises(exercise_id) ON DELETE RESTRICT," +
|
||||
"CHECK(source_exercise_id<>canonical_exercise_id));",
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS exercise_aliases_canonical ON exercise_aliases(canonical_exercise_id);")
|
||||
}
|
||||
|
||||
private fun seedInitialBodyZones(db: SQLiteDatabase) {
|
||||
val catalog = BodyZoneCatalog.load(appContext)
|
||||
catalog.initialMappings.forEach { mapping ->
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
enum class AppSection(val label: String) {
|
||||
HOME("Accueil"), SESSIONS("Séances"), EXERCISES("Exercices"),
|
||||
EQUIPMENT("Équipements"), STATISTICS("Statistiques"), SYNC("Synchronisation"), SETTINGS("Paramètres"),
|
||||
}
|
||||
|
||||
sealed interface AppRoute {
|
||||
val section: AppSection
|
||||
data object Home : AppRoute { override val section = AppSection.HOME }
|
||||
data object Sessions : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object SessionEditor : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object Exercises : AppRoute { override val section = AppSection.EXERCISES }
|
||||
data class ExerciseDetail(val exerciseId: String) : AppRoute { override val section = AppSection.EXERCISES }
|
||||
data class ExerciseEdit(val exerciseId: String, val caller: AppRoute) : AppRoute { override val section = caller.section }
|
||||
data class ExerciseCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
|
||||
data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT }
|
||||
data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT }
|
||||
data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
|
||||
data object Statistics : AppRoute { override val section = AppSection.STATISTICS }
|
||||
data object BodyMeasurements : AppRoute { override val section = AppSection.STATISTICS }
|
||||
data object LatestMaxima : AppRoute { override val section = AppSection.STATISTICS }
|
||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||
}
|
||||
|
||||
fun AppSection.rootRoute(): AppRoute = when (this) {
|
||||
AppSection.HOME -> AppRoute.Home
|
||||
AppSection.SESSIONS -> AppRoute.Sessions
|
||||
AppSection.EXERCISES -> AppRoute.Exercises
|
||||
AppSection.EQUIPMENT -> AppRoute.Equipment
|
||||
AppSection.STATISTICS -> AppRoute.Statistics
|
||||
AppSection.SYNC -> AppRoute.Sync
|
||||
AppSection.SETTINGS -> AppRoute.Settings
|
||||
}
|
||||
|
||||
/** CONTRACT: one owner performs every route transaction; routes never mutate persistence. */
|
||||
class AppNavigationState(initial: AppRoute = AppRoute.Home, private val limit: Int = 16) {
|
||||
private val history = ArrayDeque<AppRoute>()
|
||||
var route: AppRoute by mutableStateOf(initial)
|
||||
private set
|
||||
|
||||
fun open(destination: AppRoute) {
|
||||
if (destination == route) return
|
||||
history.addLast(route)
|
||||
while (history.size > limit) history.removeFirst()
|
||||
route = destination
|
||||
}
|
||||
|
||||
fun openSection(section: AppSection) {
|
||||
history.clear()
|
||||
route = section.rootRoute()
|
||||
}
|
||||
|
||||
fun back(): Boolean {
|
||||
val caller = when (val current = route) {
|
||||
is AppRoute.ExerciseCreate -> current.caller
|
||||
is AppRoute.ExerciseEdit -> current.caller
|
||||
is AppRoute.EquipmentCreate -> current.caller
|
||||
else -> null
|
||||
}
|
||||
/* INVARIANT: inline creation returns to its declared caller exactly
|
||||
* once. When open() recorded that caller, consume the matching entry
|
||||
* so the following Back continues beyond it instead of becoming a
|
||||
* no-op on the same route. */
|
||||
if (caller != null && history.lastOrNull() == caller) history.removeLast()
|
||||
route = caller ?: history.removeLastOrNull() ?: when (route) {
|
||||
AppRoute.Home -> return false
|
||||
else -> route.section.rootRoute().takeUnless { it == route } ?: AppRoute.Home
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: root navigation guards are product behavior, so their owner must be
|
||||
* callable by both [TrainlogApp] and host-side regression tests.
|
||||
* CONTRACT: route requests never save, synchronize, finalize, or mutate the
|
||||
* repository; explicit discard clears only the transient state for the route
|
||||
* that raised the guard.
|
||||
*/
|
||||
class AppNavigationController(
|
||||
val navigation: AppNavigationState,
|
||||
private val generator: SessionGeneratorUiState,
|
||||
private val equipment: EquipmentScreenState,
|
||||
private val exercise: ExerciseScreenState,
|
||||
private val body: BodyScreenState,
|
||||
) {
|
||||
private var pendingAction: (() -> Unit)? by mutableStateOf(null)
|
||||
private var guardedRoute: AppRoute? = null
|
||||
private var replacementBlocked = false
|
||||
val hasPendingNavigation: Boolean get() = pendingAction != null
|
||||
val pendingRoute: AppRoute? get() = guardedRoute
|
||||
|
||||
private fun isDirty(route: AppRoute): Boolean =
|
||||
route == AppRoute.SessionGenerator && generator.hasUnacceptedWork ||
|
||||
route is AppRoute.EquipmentCreate && equipment.dirty ||
|
||||
(route is AppRoute.ExerciseCreate || route is AppRoute.ExerciseEdit) && exercise.dirty ||
|
||||
route == AppRoute.BodyMeasurements && body.dirty
|
||||
|
||||
private fun request(replacesEditor: Boolean = false, action: () -> Unit): Boolean {
|
||||
val source = navigation.route
|
||||
return if (isDirty(source)) {
|
||||
guardedRoute = source
|
||||
replacementBlocked = replacesEditor
|
||||
pendingAction = action
|
||||
false
|
||||
} else {
|
||||
action()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun open(route: AppRoute): Boolean {
|
||||
val source = navigation.route
|
||||
val replacement =
|
||||
(source is AppRoute.ExerciseCreate || source is AppRoute.ExerciseEdit) &&
|
||||
(route is AppRoute.ExerciseCreate || route is AppRoute.ExerciseEdit) && route != source
|
||||
return request(replacement) { navigation.open(route) }
|
||||
}
|
||||
fun openSection(section: AppSection): Boolean = request { navigation.openSection(section) }
|
||||
fun back(): Boolean = request { navigation.back() }
|
||||
fun cancelPending() { pendingAction = null; guardedRoute = null; replacementBlocked = false }
|
||||
fun keepAndNavigate() {
|
||||
/* INVARIANT: retaining a dirty form also retains its route identity;
|
||||
* a different target may only replace it after explicit discard. */
|
||||
if (replacementBlocked) cancelPending() else resolve(discard = false)
|
||||
}
|
||||
fun discardAndNavigate() = resolve(discard = true)
|
||||
|
||||
private fun resolve(discard: Boolean) {
|
||||
val action = pendingAction ?: return
|
||||
val source = guardedRoute
|
||||
if (discard) when (source) {
|
||||
AppRoute.SessionGenerator -> generator.abandon()
|
||||
is AppRoute.EquipmentCreate -> equipment.abandonEdits()
|
||||
AppRoute.BodyMeasurements -> body.abandonEdits()
|
||||
is AppRoute.ExerciseCreate, is AppRoute.ExerciseEdit -> exercise.abandonEdits()
|
||||
else -> Unit
|
||||
}
|
||||
cancelPending()
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun routeTitle(route: AppRoute): String = when (route) {
|
||||
AppRoute.Home -> "Accueil"
|
||||
AppRoute.Sessions -> "Séances"
|
||||
AppRoute.SessionEditor -> "Séance en cours"
|
||||
AppRoute.SessionGenerator -> "Programmer une séance"
|
||||
AppRoute.CompletedSessions -> "Séances effectuées"
|
||||
is AppRoute.SessionDetail -> "Détail de la séance"
|
||||
AppRoute.Exercises -> "Exercices"
|
||||
is AppRoute.ExerciseDetail -> "Fiche exercice"
|
||||
is AppRoute.ExerciseEdit -> "Modifier l'exercice"
|
||||
is AppRoute.ExerciseCreate -> "Créer un exercice"
|
||||
AppRoute.Equipment -> "Équipements"
|
||||
is AppRoute.EquipmentDetail -> "Fiche équipement"
|
||||
is AppRoute.EquipmentCreate -> "Créer un équipement"
|
||||
AppRoute.Statistics -> "Statistiques"
|
||||
AppRoute.BodyMeasurements -> "Mensurations"
|
||||
AppRoute.LatestMaxima -> "Derniers MAX"
|
||||
AppRoute.Sync -> "Synchronisation"
|
||||
AppRoute.Settings -> "Paramètres"
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: configuration recreation must retain small navigation and form state,
|
||||
* while process death deliberately restores only the repository-owned draft.
|
||||
* No session or proposal is serialized into a Bundle.
|
||||
*/
|
||||
class TrainlogAppState : ViewModel() {
|
||||
val navigation = AppNavigationState()
|
||||
val generator = SessionGeneratorUiState()
|
||||
val equipment = EquipmentScreenState()
|
||||
val exercise = ExerciseScreenState()
|
||||
val body = BodyScreenState()
|
||||
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,18 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||
|
|
@ -32,74 +36,90 @@ import com.labfytools.trainlog.model.TrackingMode
|
|||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
|
||||
class ExerciseScreenState {
|
||||
val name = mutableStateOf("")
|
||||
val recordingMode = mutableStateOf(RecordingMode.SETS)
|
||||
val trackingMode = mutableStateOf(TrackingMode.REPS)
|
||||
val speed = mutableStateOf(false)
|
||||
val distance = mutableStateOf(false)
|
||||
val primaryZoneId = mutableStateOf<String?>(null)
|
||||
val secondaryZoneIds = mutableStateOf(emptySet<String>())
|
||||
val searchQuery = mutableStateOf("")
|
||||
val filterZoneId = mutableStateOf<String?>(null)
|
||||
val unclassifiedFilter = mutableStateOf(false)
|
||||
val expandedKnowledgeIds = mutableStateOf(emptySet<String>())
|
||||
val message = mutableStateOf<String?>(null)
|
||||
val editedExerciseId = mutableStateOf<String?>(null)
|
||||
var selectedExerciseId by mutableStateOf<String?>(null)
|
||||
var catalogueScroll by mutableStateOf(0)
|
||||
var detailScroll by mutableStateOf(0)
|
||||
private var cleanSignature = signature()
|
||||
val dirty: Boolean get() = signature() != cleanSignature
|
||||
fun markClean() { cleanSignature = signature() }
|
||||
fun prepareCreate() {
|
||||
if (editedExerciseId.value == null && !dirty) abandonEdits()
|
||||
}
|
||||
fun prepareEdit(exercise: ExerciseProfile) {
|
||||
if (editedExerciseId.value == exercise.exerciseId) return
|
||||
require(!dirty) { "un éditeur sale ne peut pas être remplacé" }
|
||||
editedExerciseId.value = exercise.exerciseId
|
||||
name.value = exercise.name
|
||||
recordingMode.value = exercise.recordingMode
|
||||
trackingMode.value = exercise.trackingMode
|
||||
speed.value = exercise.dataFields and ExerciseDataFields.SPEED_KMH != 0
|
||||
distance.value = exercise.dataFields and ExerciseDataFields.DISTANCE_KM != 0
|
||||
primaryZoneId.value = exercise.primaryZoneId
|
||||
secondaryZoneIds.value = exercise.secondaryZoneIds.toSet()
|
||||
message.value = null
|
||||
markClean()
|
||||
}
|
||||
fun abandonEdits() {
|
||||
name.value = ""; recordingMode.value = RecordingMode.SETS; trackingMode.value = TrackingMode.REPS
|
||||
speed.value = false; distance.value = false; primaryZoneId.value = null
|
||||
secondaryZoneIds.value = emptySet(); editedExerciseId.value = null; message.value = null; markClean()
|
||||
}
|
||||
private fun signature(): String = listOf(name.value, recordingMode.value, trackingMode.value, speed.value,
|
||||
distance.value, primaryZoneId.value, secondaryZoneIds.value.sorted(), editedExerciseId.value).joinToString("|")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExerciseScreen(
|
||||
repository: TrainlogRepository,
|
||||
state: ExerciseScreenState,
|
||||
inline: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onSaved: () -> Unit,
|
||||
onOpenMaxima: () -> Unit,
|
||||
catalogueVisible: Boolean = true,
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
||||
var name by
|
||||
remember {
|
||||
mutableStateOf("")
|
||||
}
|
||||
|
||||
var recordingMode by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
RecordingMode.SETS
|
||||
)
|
||||
}
|
||||
|
||||
var trackingMode by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
TrackingMode.REPS
|
||||
)
|
||||
}
|
||||
|
||||
var speed by
|
||||
remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var distance by
|
||||
remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var primaryZoneId by remember { mutableStateOf<String?>(null) }
|
||||
var secondaryZoneIds by remember { mutableStateOf(emptySet<String>()) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var filterZoneId by remember { mutableStateOf<String?>(null) }
|
||||
var unclassifiedFilter by remember { mutableStateOf(false) }
|
||||
var expandedKnowledgeIds by remember { mutableStateOf(emptySet<String>()) }
|
||||
var name by state.name
|
||||
var recordingMode by state.recordingMode
|
||||
var trackingMode by state.trackingMode
|
||||
var speed by state.speed
|
||||
var distance by state.distance
|
||||
var primaryZoneId by state.primaryZoneId
|
||||
var secondaryZoneIds by state.secondaryZoneIds
|
||||
var searchQuery by state.searchQuery
|
||||
var filterZoneId by state.filterZoneId
|
||||
var unclassifiedFilter by state.unclassifiedFilter
|
||||
var expandedKnowledgeIds by state.expandedKnowledgeIds
|
||||
val zones = repository.listBodyZones()
|
||||
|
||||
var message by
|
||||
remember {
|
||||
mutableStateOf<String?>(
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
var editedExercise by
|
||||
remember {
|
||||
mutableStateOf<ExerciseProfile?>(null)
|
||||
}
|
||||
var message by state.message
|
||||
var editedExerciseId by state.editedExerciseId
|
||||
|
||||
val profileLocked =
|
||||
editedExercise?.let {
|
||||
!repository.canEditExerciseProfile(it.exerciseId)
|
||||
editedExerciseId?.let {
|
||||
!repository.canEditExerciseProfile(it)
|
||||
} ?: false
|
||||
|
||||
fun startEditing(exercise: ExerciseProfile) {
|
||||
/* WHY: edit state copies catalog metadata for presentation only. The
|
||||
* repository remains the sole owner of stable identity and SQLite. */
|
||||
editedExercise = exercise
|
||||
editedExerciseId = exercise.exerciseId
|
||||
name = exercise.name
|
||||
recordingMode = exercise.recordingMode
|
||||
trackingMode = exercise.trackingMode
|
||||
|
|
@ -108,34 +128,18 @@ fun ExerciseScreen(
|
|||
primaryZoneId = exercise.primaryZoneId
|
||||
secondaryZoneIds = exercise.secondaryZoneIds.toSet()
|
||||
message = null
|
||||
state.markClean()
|
||||
}
|
||||
|
||||
TrainlogScreen(
|
||||
subtitle = "E X E R C I C E"
|
||||
subtitle = if (editedExerciseId == null) "Créer un exercice" else "Modifier l'exercice"
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
if (inline) {
|
||||
"< Retour à la séance"
|
||||
} else {
|
||||
"< Retour"
|
||||
},
|
||||
description =
|
||||
if (inline) {
|
||||
"Retourner dans la séance en cours."
|
||||
} else {
|
||||
"Revenir à l'accueil."
|
||||
},
|
||||
onClick = onBack,
|
||||
accent = colors.muted,
|
||||
)
|
||||
|
||||
TrainlogFrame(
|
||||
title =
|
||||
if (editedExercise == null) {
|
||||
"NOUVEL EXERCICE"
|
||||
if (editedExerciseId == null) {
|
||||
"Nouvel exercice"
|
||||
} else {
|
||||
"MODIFIER L'EXERCICE"
|
||||
"Modifier l'exercice"
|
||||
}
|
||||
) {
|
||||
TrainlogField(
|
||||
|
|
@ -350,9 +354,9 @@ fun ExerciseScreen(
|
|||
)
|
||||
}
|
||||
|
||||
TrainlogAction(
|
||||
TrainlogPrimaryAction(
|
||||
label =
|
||||
if (editedExercise != null) {
|
||||
if (editedExerciseId != null) {
|
||||
"Enregistrer les modifications"
|
||||
} else if (inline) {
|
||||
"Créer et revenir à la séance"
|
||||
|
|
@ -360,20 +364,18 @@ fun ExerciseScreen(
|
|||
"Enregistrer l'exercice"
|
||||
},
|
||||
description =
|
||||
if (editedExercise == null) {
|
||||
if (editedExerciseId == null) {
|
||||
"Ajouter ce profil au catalogue local."
|
||||
} else {
|
||||
"Conserver l'identité et mettre à jour le catalogue."
|
||||
},
|
||||
accent =
|
||||
colors.success,
|
||||
onClick = save@{
|
||||
if (editedExercise == null &&
|
||||
if (editedExerciseId == null &&
|
||||
recordingMode == RecordingMode.SETS && primaryZoneId == null) {
|
||||
message = "Une zone principale est requise pour un nouvel exercice musculaire."
|
||||
return@save
|
||||
}
|
||||
val current = editedExercise
|
||||
val current = editedExerciseId
|
||||
val result =
|
||||
if (current == null) {
|
||||
repository.createExercise(
|
||||
|
|
@ -392,7 +394,7 @@ fun ExerciseScreen(
|
|||
} else {
|
||||
repository.editExercise(
|
||||
ExerciseEditInput(
|
||||
exerciseId = current.exerciseId,
|
||||
exerciseId = current,
|
||||
name = name,
|
||||
recordingMode = recordingMode,
|
||||
trackingMode = trackingMode,
|
||||
|
|
@ -405,6 +407,7 @@ fun ExerciseScreen(
|
|||
when (result) {
|
||||
is CreateExerciseResult.Created -> {
|
||||
message = null
|
||||
state.abandonEdits()
|
||||
onSaved()
|
||||
}
|
||||
|
||||
|
|
@ -424,7 +427,8 @@ fun ExerciseScreen(
|
|||
|
||||
is EditExerciseResult.Saved -> {
|
||||
message = null
|
||||
editedExercise = null
|
||||
editedExerciseId = null
|
||||
state.abandonEdits()
|
||||
onSaved()
|
||||
}
|
||||
|
||||
|
|
@ -448,13 +452,13 @@ fun ExerciseScreen(
|
|||
},
|
||||
)
|
||||
|
||||
if (editedExercise != null) {
|
||||
if (editedExerciseId != null) {
|
||||
TrainlogAction(
|
||||
label = "Annuler",
|
||||
description = "Revenir au catalogue sans modification.",
|
||||
accent = colors.muted,
|
||||
onClick = {
|
||||
editedExercise = null
|
||||
editedExerciseId = null
|
||||
name = ""
|
||||
recordingMode = RecordingMode.SETS
|
||||
trackingMode = TrackingMode.REPS
|
||||
|
|
@ -476,7 +480,7 @@ fun ExerciseScreen(
|
|||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(title = "EXERCICES EXISTANTS", active = false) {
|
||||
if (catalogueVisible) TrainlogFrame(title = "Exercices existants", active = false) {
|
||||
TrainlogInputField(
|
||||
label = "Recherche par préfixe",
|
||||
value = searchQuery,
|
||||
|
|
@ -533,12 +537,18 @@ fun ExerciseScreen(
|
|||
)
|
||||
if (expanded) KnowledgePanel(repository, knowledge)
|
||||
}
|
||||
TrainlogAction(
|
||||
label = "Voir les derniers MAX",
|
||||
description = "Ouvrir la liste agrégée existante dans Statistiques.",
|
||||
accent = colors.warning,
|
||||
onClick = onOpenMaxima,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "CONTRAT",
|
||||
title = "Contrat",
|
||||
active = false,
|
||||
) {
|
||||
TrainlogInfo(
|
||||
|
|
@ -552,6 +562,29 @@ fun ExerciseScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExerciseEditorRoute(
|
||||
repository: TrainlogRepository,
|
||||
state: ExerciseScreenState,
|
||||
exerciseId: String?,
|
||||
inline: Boolean,
|
||||
onSaved: () -> Unit,
|
||||
) {
|
||||
val profile = remember(exerciseId) {
|
||||
exerciseId?.let { id -> repository.listExercises().firstOrNull { it.exerciseId == id } }
|
||||
}
|
||||
LaunchedEffect(exerciseId) {
|
||||
if (profile == null) state.prepareCreate() else state.prepareEdit(profile)
|
||||
}
|
||||
if (exerciseId != null && profile == null) {
|
||||
TrainlogScreen("Modifier l'exercice") { TrainlogInfo("Exercice introuvable.") }
|
||||
} else if (state.editedExerciseId.value != exerciseId) {
|
||||
TrainlogScreen("Modifier l'exercice") { TrainlogInfo("Chargement du profil…") }
|
||||
} else {
|
||||
ExerciseScreen(repository, state, inline, {}, onSaved, {}, catalogueVisible = false)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKnowledge) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
|
|
@ -564,31 +597,115 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
|
|||
}
|
||||
if (interpretation == null) {
|
||||
TrainlogInfo("Classification scientifique non résolue.", colors.warning)
|
||||
return
|
||||
}
|
||||
if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) {
|
||||
TrainlogInfo("Confiance : ${confidenceLabel(knowledge.confidence)}", colors.muted)
|
||||
} else if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) {
|
||||
TrainlogInfo(
|
||||
"Interprétation conditionnelle · à confirmer : ${interpretation.requiredConfirmation.orEmpty()}",
|
||||
colors.warning,
|
||||
)
|
||||
}
|
||||
val patterns = interpretation.patternIds.mapNotNull(repository::getMovementPatternKnowledge)
|
||||
val primary = interpretation.primaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||
val secondary = interpretation.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||
val primaryZone = repository.bodyZone(interpretation.primaryZoneId)?.displayName
|
||||
?: interpretation.primaryZoneId
|
||||
val secondaryZones = interpretation.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
|
||||
interpretation?.let { resolved ->
|
||||
val patterns = resolved.patternIds.mapNotNull(repository::getMovementPatternKnowledge)
|
||||
val primary = resolved.primaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||
val secondary = resolved.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.displayName ?: resolved.primaryZoneId
|
||||
val secondaryZones = resolved.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
|
||||
val runtimeEquipment = repository.listEquipment().associateBy { it.equipmentId }
|
||||
val equipment = knowledge.equipmentIds.map { runtimeEquipment[it]?.displayName ?: it }
|
||||
TrainlogInfo("Mouvement : ${patterns.joinToString { it.displayNameFr }.ifEmpty { "Non classé" }}")
|
||||
TrainlogInfo("Muscles principaux : ${primary.joinToString { it.displayNameFr }.ifEmpty { "Non classés" }}")
|
||||
TrainlogInfo("Muscles secondaires : ${secondary.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
|
||||
TrainlogInfo(
|
||||
"Zones scientifiques : $primaryZone" +
|
||||
if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}",
|
||||
)
|
||||
TrainlogInfo("Zones scientifiques : $primaryZone" + if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}")
|
||||
TrainlogInfo("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}")
|
||||
TrainlogInfo("Confiance : ${confidenceLabel(interpretation.confidence)}", colors.muted)
|
||||
TrainlogInfo("Confiance : ${confidenceLabel(resolved.confidence)}", colors.muted)
|
||||
}
|
||||
knowledge.limitations.forEach { TrainlogInfo("Limite : $it", colors.muted) }
|
||||
knowledge.sourceRefs.mapNotNull(repository::getScienceReference).forEach { reference ->
|
||||
TrainlogInfo(
|
||||
"Source : ${reference.authorsOrOrganization} · ${reference.title}" +
|
||||
(reference.year?.let { " ($it)" } ?: ""),
|
||||
colors.muted,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** CONTRACT: the section root is a catalogue; creation and detail are explicit routes. */
|
||||
@Composable
|
||||
fun ExerciseCatalogueScreen(
|
||||
repository: TrainlogRepository,
|
||||
state: ExerciseScreenState,
|
||||
onCreate: () -> Unit,
|
||||
onOpenDetail: (String) -> Unit,
|
||||
) {
|
||||
var query by state.searchQuery
|
||||
var zoneId by state.filterZoneId
|
||||
var unclassified by state.unclassifiedFilter
|
||||
val zones = repository.listBodyZones()
|
||||
val exercises = repository.listExercises(query, zoneId, true, false, unclassified)
|
||||
TrainlogScreen("Catalogue d'exercices", scrollKey = "exercise-catalogue") {
|
||||
TrainlogPrimaryAction("Créer un exercice", "Ajouter un profil au catalogue local.", onCreate)
|
||||
TrainlogInputField("Recherche par préfixe", query, { query = it })
|
||||
TrainlogChoiceGroup("Filtrer") {
|
||||
TrainlogChoice("Toutes les zones", zoneId == null && !unclassified) {
|
||||
zoneId = null; unclassified = false
|
||||
}
|
||||
zones.filter { it.parentZoneId == null }.forEach { zone ->
|
||||
TrainlogChoice(zone.displayName, zoneId == zone.zoneId && !unclassified) {
|
||||
zoneId = zone.zoneId; unclassified = false
|
||||
}
|
||||
}
|
||||
TrainlogChoice("Non renseignés", unclassified) { zoneId = null; unclassified = true }
|
||||
}
|
||||
TrainlogFrame("Catalogue", active = exercises.isNotEmpty()) {
|
||||
if (exercises.isEmpty()) TrainlogInfo("Aucun exercice trouvé.")
|
||||
exercises.forEach { exercise ->
|
||||
TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise), {
|
||||
state.selectedExerciseId = exercise.exerciseId
|
||||
onOpenDetail(exercise.exerciseId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExerciseDetailScreen(
|
||||
repository: TrainlogRepository,
|
||||
exerciseId: String,
|
||||
onModify: () -> Unit,
|
||||
onOpenMaxima: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val context = remember(exerciseId) { repository.getTrainingExerciseContext(exerciseId) }
|
||||
TrainlogScreen("Fiche exercice", scrollKey = "exercise-detail:$exerciseId") {
|
||||
if (context == null) {
|
||||
TrainlogInfo("Exercice introuvable.", colors.error)
|
||||
return@TrainlogScreen
|
||||
}
|
||||
TrainlogFrame("Profil") {
|
||||
TrainlogInfo(context.exercise.name, colors.accent)
|
||||
TrainlogInfo(exerciseZoneSummary(repository, context.exercise))
|
||||
TrainlogInfo(profilePreview(context.exercise.recordingMode, context.exercise.trackingMode, context.exercise.dataFields))
|
||||
TrainlogAction("Modifier", "Modifier le nom, le profil et les zones selon les règles existantes.", onModify)
|
||||
}
|
||||
TrainlogFrame("Connaissances", active = context.knowledge != null) {
|
||||
context.knowledge?.let { KnowledgePanel(repository, it) }
|
||||
?: TrainlogInfo("Aucune connaissance scientifique liée à cet identifiant.", colors.muted)
|
||||
}
|
||||
TrainlogFrame("Équipements compatibles", active = context.compatibleEquipment.isNotEmpty()) {
|
||||
if (context.compatibleEquipment.isEmpty()) TrainlogInfo("Compatibilité non établie.")
|
||||
context.compatibleEquipment.forEach { equipment ->
|
||||
val name = listOfNotNull(equipment.manufacturer, equipment.model).joinToString(" ").ifBlank { equipment.equipmentId }
|
||||
TrainlogInfo(name)
|
||||
}
|
||||
}
|
||||
context.latestExplicitMax?.let { max ->
|
||||
TrainlogFrame("MAX") {
|
||||
TrainlogInfo("Dernier résultat : ${max.maxWeightKg} kg · ${max.startedAt.take(10)}", colors.warning)
|
||||
}
|
||||
}
|
||||
TrainlogAction("Voir les derniers MAX", "Ouvrir la liste agrégée existante dans Statistiques.", onOpenMaxima)
|
||||
}
|
||||
}
|
||||
|
||||
private fun knowledgeSummary(knowledge: ExerciseKnowledge): String = when (knowledge.resolutionStatus) {
|
||||
|
|
@ -695,6 +812,7 @@ private fun TrainlogChoice(
|
|||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.background(
|
||||
if (selected) {
|
||||
colors.surfaceAlt
|
||||
|
|
@ -702,6 +820,7 @@ private fun TrainlogChoice(
|
|||
colors.surface
|
||||
}
|
||||
)
|
||||
.semantics { this.selected = selected }
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
|
|
@ -722,7 +841,7 @@ private fun TrainlogChoice(
|
|||
TrainlogTypography.normal.copy(
|
||||
color =
|
||||
if (selected) {
|
||||
colors.warning
|
||||
colors.accent
|
||||
} else if (!enabled) {
|
||||
colors.muted
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.LatestExerciseMax
|
||||
import com.labfytools.trainlog.model.SessionSummary
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
|
|
@ -13,162 +11,39 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
|||
fun HomeScreen(
|
||||
activeDraft: ActiveSessionDraft?,
|
||||
draftError: String?,
|
||||
latestSession: SessionSummary?,
|
||||
latestMaximum: LatestExerciseMax?,
|
||||
onSession: () -> Unit,
|
||||
onGenerateSession: () -> Unit,
|
||||
onDiscardDraft: () -> Unit,
|
||||
onExercise: () -> Unit,
|
||||
onBody: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
onOpenLatestSession: (String) -> Unit,
|
||||
onSync: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
var confirmingDiscard by
|
||||
remember(activeDraft != null) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
TrainlogScreen(
|
||||
subtitle = "A C C U E I L"
|
||||
) {
|
||||
TrainlogScreen("Accueil", scrollKey = "home") {
|
||||
draftError?.let { TrainlogInfo(it, colors.error) }
|
||||
TrainlogFrame("Séance") {
|
||||
if (activeDraft != null) {
|
||||
TrainlogFrame(
|
||||
title = "SÉANCE EN COURS"
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Reprendre la séance en cours",
|
||||
description =
|
||||
(if (
|
||||
activeDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
"Test max"
|
||||
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement"
|
||||
TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession)
|
||||
TrainlogAction("Programmer une séance", "La proposition sera conservée séparément.", onGenerateSession)
|
||||
} else {
|
||||
"Entraînement"
|
||||
}) +
|
||||
" · ${activeDraft.exercises.size} exercice(s)",
|
||||
accent = colors.success,
|
||||
onClick = onSession,
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Supprimer la séance en cours",
|
||||
description =
|
||||
"Supprimer le brouillon, sans modifier l'historique.",
|
||||
accent = colors.error,
|
||||
onClick = {
|
||||
confirmingDiscard = true
|
||||
},
|
||||
)
|
||||
|
||||
if (confirmingDiscard) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Confirmer la suppression",
|
||||
description =
|
||||
"Abandonner définitivement cette séance en cours.",
|
||||
accent = colors.error,
|
||||
onClick = {
|
||||
confirmingDiscard = false
|
||||
onDiscardDraft()
|
||||
},
|
||||
)
|
||||
TrainlogAction(
|
||||
label = "Annuler",
|
||||
description =
|
||||
"Conserver la séance en cours.",
|
||||
accent = colors.muted,
|
||||
onClick = {
|
||||
confirmingDiscard = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (draftError != null) {
|
||||
TrainlogFrame(
|
||||
title = "BROUILLON"
|
||||
) {
|
||||
TrainlogInfo(
|
||||
text = draftError,
|
||||
color = colors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "ENREGISTREMENT"
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Enregistrer une séance",
|
||||
description =
|
||||
if (activeDraft == null) {
|
||||
"Saisir un entraînement et ses exercices."
|
||||
} else {
|
||||
"Ouvrir la séance en cours sans l'écraser."
|
||||
},
|
||||
onClick = onSession,
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label = "Générer une séance",
|
||||
description = "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.",
|
||||
onClick = onGenerateSession,
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Enregistrer un exercice",
|
||||
description =
|
||||
"Créer une entrée dans le catalogue Trainlog.",
|
||||
onClick = onExercise,
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Enregistrer des mensurations",
|
||||
description =
|
||||
"Ajouter un relevé corporel.",
|
||||
onClick = onBody,
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "CONSULTATION"
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Historique des séances",
|
||||
description =
|
||||
"Consulter les séances enregistrées et leur détail.",
|
||||
onClick = onHistory,
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "SYNCHRONISATION"
|
||||
) {
|
||||
TrainlogAction(
|
||||
label = "Synchroniser avec le PC",
|
||||
description = "Préparer les données pour le transport MTP.",
|
||||
onClick = onSync,
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "STATUT",
|
||||
active = false,
|
||||
) {
|
||||
TrainlogInfo(
|
||||
"Stockage Android local actif."
|
||||
)
|
||||
|
||||
TrainlogInfo(
|
||||
"Synchronisation MTP : après les workflows locaux."
|
||||
)
|
||||
TrainlogPrimaryAction("Programmer une séance", "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.", onGenerateSession)
|
||||
TrainlogAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
|
||||
}
|
||||
}
|
||||
latestSession?.let { session ->
|
||||
TrainlogFrame("Dernière séance") {
|
||||
TrainlogAction(formatStartedAt(session.startedAt), "${sessionTypeLabel(session.sessionType)} · ${session.exerciseCount} exercice(s)", { onOpenLatestSession(session.sessionId) })
|
||||
}
|
||||
}
|
||||
latestMaximum?.let { maximum ->
|
||||
val weight = "%.2f".format(java.util.Locale.FRANCE, maximum.maxWeightKg).trimEnd('0').trimEnd(',')
|
||||
TrainlogInfo("Dernier MAX · ${maximum.exerciseName} · $weight kg · ${maximum.startedAt.take(10)}", colors.warning)
|
||||
}
|
||||
TrainlogFrame("Accès rapides", active = false) {
|
||||
TrainlogAction("Mensurations", "Ajouter ou consulter les relevés locaux.", onBody)
|
||||
TrainlogAction("Synchronisation", "Consulter l'état connu et lancer une action explicite.", onSync)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||
import com.labfytools.trainlog.data.CatalogInboxResult
|
||||
import com.labfytools.trainlog.data.EquipmentCatalogEntry
|
||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
@Composable
|
||||
fun SessionsHub(
|
||||
draft: ActiveSessionDraft?,
|
||||
onResume: () -> Unit,
|
||||
onGenerate: () -> Unit,
|
||||
onManual: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
TrainlogScreen("Séances") {
|
||||
TrainlogFrame("Séance en cours", active = draft != null) {
|
||||
if (draft == null) TrainlogInfo("Aucune séance en cours.")
|
||||
else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success)
|
||||
}
|
||||
TrainlogFrame("Préparer") {
|
||||
TrainlogAction("Programmer une séance", "Générer une proposition modifiable.", onGenerate, accent = if (draft == null) colors.success else colors.accent)
|
||||
TrainlogAction("Nouvelle séance manuelle", if (draft == null) "Créer explicitement un brouillon de séance." else "Ouvrir la séance en cours sans l'écraser.", onManual)
|
||||
}
|
||||
TrainlogAction("Séances effectuées", "Consulter les actuals, plans et MAX enregistrés.", onHistory)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatisticsHub(onBody: () -> Unit, onMaxima: () -> Unit) {
|
||||
TrainlogScreen("Statistiques") {
|
||||
TrainlogFrame("Données existantes") {
|
||||
TrainlogAction("Mensurations", "Ajouter et consulter les relevés corporels locaux.", onBody)
|
||||
TrainlogAction("Derniers MAX", "Consulter les derniers résultats MAX explicites par exercice.", onMaxima)
|
||||
}
|
||||
TrainlogInfo("Les tendances et analyses supplémentaires restent sur le PC canonique.")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LatestMaximaScreen(repository: TrainlogRepository) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val maxima = remember { repository.listLatestExerciseMaxima() }
|
||||
TrainlogScreen("Derniers MAX") {
|
||||
TrainlogFrame("CAPACITÉS / MAX", active = maxima.isNotEmpty()) {
|
||||
if (maxima.isEmpty()) TrainlogInfo("Aucun max explicite enregistré.")
|
||||
maxima.forEach { max ->
|
||||
val weight = "%.2f".format(java.util.Locale.FRANCE, max.maxWeightKg).trimEnd('0').trimEnd(',')
|
||||
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
|
||||
TrainlogInfo("${formatStartedAt(max.startedAt).take(10)} · Équipement : ${max.equipmentDisplayName ?: "aucun"}", colors.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EquipmentScreenState {
|
||||
var query by mutableStateOf("")
|
||||
var customName by mutableStateOf("")
|
||||
var selectedEquipmentId by mutableStateOf<String?>(null)
|
||||
var catalogueScroll by mutableStateOf(0)
|
||||
var detailScroll by mutableStateOf(0)
|
||||
var message by mutableStateOf<String?>(null)
|
||||
var revision by mutableStateOf(0)
|
||||
val dirty: Boolean get() = customName.isNotEmpty()
|
||||
fun abandonEdits() { customName = ""; message = null }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EquipmentScreen(
|
||||
repository: TrainlogRepository,
|
||||
state: EquipmentScreenState,
|
||||
onCreate: () -> Unit,
|
||||
onOpenDetail: (String) -> Unit,
|
||||
) {
|
||||
val equipment = remember(state.query, state.revision) { repository.searchEquipment(state.query) }
|
||||
TrainlogScreen("Catalogue d'équipements", scrollKey = "equipment-catalogue") {
|
||||
TrainlogPrimaryAction("Créer un équipement", "Ajouter une machine personnelle.", onCreate)
|
||||
TrainlogInputField("Rechercher par nom, étiquette ou alias", state.query, { state.query = it })
|
||||
TrainlogFrame("Catalogue", active = equipment.isNotEmpty()) {
|
||||
if (equipment.isEmpty()) TrainlogInfo("Aucun équipement trouvé.")
|
||||
equipment.forEach { entry ->
|
||||
TrainlogAction(entry.displayName, listOf(entry.labelName, equipmentSemanticsLabel(entry.loadSemantics)).filter { it.isNotBlank() }.joinToString(" · "), {
|
||||
state.selectedEquipmentId = entry.equipmentId
|
||||
onOpenDetail(entry.equipmentId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val entry = remember(equipmentId) { repository.listEquipment().firstOrNull { it.equipmentId == equipmentId } }
|
||||
TrainlogScreen("Fiche équipement", scrollKey = "equipment-detail:$equipmentId") {
|
||||
if (entry == null) {
|
||||
TrainlogInfo("Référence inconnue · $equipmentId", colors.warning)
|
||||
return@TrainlogScreen
|
||||
}
|
||||
TrainlogFrame("Définition") {
|
||||
TrainlogInfo(entry.displayName, colors.accent)
|
||||
TrainlogInfo(if (entry.type == "custom_machine") "Personnel" else "Fourni")
|
||||
if (entry.labelName.isNotBlank()) TrainlogInfo("Étiquette : ${entry.labelName}")
|
||||
TrainlogInfo("Type : ${entry.type}")
|
||||
TrainlogInfo("Charge : ${equipmentSemanticsLabel(entry.loadSemantics)}")
|
||||
if (entry.aliases.isNotEmpty()) TrainlogInfo("Alias : ${entry.aliases.joinToString()}", colors.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
TrainlogScreen("Créer un équipement", scrollKey = "equipment-create") {
|
||||
TrainlogFrame("Équipement personnel") {
|
||||
TrainlogInputField("Nom", state.customName, { state.customName = it; state.message = null })
|
||||
TrainlogPrimaryAction("Créer", "Créer une machine personnelle avec la sémantique actuelle.") {
|
||||
when (val result = repository.createCustomEquipment(state.customName)) {
|
||||
is CreateEquipmentResult.Created -> {
|
||||
state.selectedEquipmentId = result.equipment.equipmentId
|
||||
state.customName = ""; state.message = null; state.revision++; onCreated()
|
||||
}
|
||||
CreateEquipmentResult.Invalid -> state.message = "Saisissez un nom de 1 à 120 caractères."
|
||||
CreateEquipmentResult.Conflict -> state.message = "Un équipement porte déjà ce nom."
|
||||
is CreateEquipmentResult.DatabaseError -> state.message = "Création impossible : ${result.message}."
|
||||
}
|
||||
}
|
||||
state.message?.let { TrainlogInfo(it, colors.error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics) = when (value) {
|
||||
EquipmentLoadSemantics.NONE -> "sans charge"
|
||||
EquipmentLoadSemantics.EXTERNAL -> "charge externe"
|
||||
EquipmentLoadSemantics.ASSISTANCE -> "assistance"
|
||||
EquipmentLoadSemantics.BODYWEIGHT -> "poids du corps"
|
||||
EquipmentLoadSemantics.CARDIO -> "cardio"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null) {
|
||||
authorized = inbox.saveTreeUri(uri)
|
||||
message = if (authorized) "Dossier d'échange autorisé." else "Autorisation du dossier impossible."
|
||||
if (authorized) {
|
||||
when (val result = inbox.importPcCatalog()) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
message = "Dossier autorisé · ${result.imported} exercice(s) importé(s), ${result.reconciled} réconcilié(s)."
|
||||
onCatalogChanged()
|
||||
}
|
||||
CatalogInboxResult.FileNotFound -> message = "Dossier autorisé · aucun catalogue PC reçu."
|
||||
CatalogInboxResult.FolderNotAuthorized -> message = "Le dossier n'est plus autorisé."
|
||||
is CatalogInboxResult.Error -> message = result.message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TrainlogScreen("Paramètres") {
|
||||
TrainlogFrame("DOSSIER D'ÉCHANGE") {
|
||||
TrainlogInfo(if (authorized) "Téléchargements/Trainlog autorisé." else "Aucun dossier Trainlog autorisé.", if (authorized) colors.success else colors.warning)
|
||||
TrainlogAction(if (authorized) "Changer de dossier" else "Autoriser le dossier", "Choisir le dossier d'échange avec le sélecteur Android.", { launcher.launch(null) })
|
||||
if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", {
|
||||
when (val result = inbox.importPcCatalog()) {
|
||||
is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() }
|
||||
CatalogInboxResult.FileNotFound -> message = "Aucun catalogue PC reçu."
|
||||
CatalogInboxResult.FolderNotAuthorized -> { authorized = false; message = "Le dossier n'est plus autorisé." }
|
||||
is CatalogInboxResult.Error -> message = result.message
|
||||
}
|
||||
})
|
||||
message?.let { TrainlogInfo(it, if (authorized) colors.success else colors.error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import com.labfytools.trainlog.data.AcceptGeneratedSessionResult
|
||||
import com.labfytools.trainlog.data.GenerationWarningLevel
|
||||
import com.labfytools.trainlog.data.GeneratorLoadChoice
|
||||
import com.labfytools.trainlog.data.SessionGenerationPreview
|
||||
import com.labfytools.trainlog.data.SessionGenerationRequest
|
||||
import com.labfytools.trainlog.data.SessionGenerationResult
|
||||
|
|
@ -55,9 +56,53 @@ internal object SessionGeneratorFormController {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INVARIANT: a generated proposal is transient application state. Keeping this
|
||||
* holder above the route preserves edits across drawer navigation without ever
|
||||
* representing the proposal as the repository-owned active draft.
|
||||
*/
|
||||
class SessionGeneratorUiState {
|
||||
val zoneId = mutableStateOf("full_body")
|
||||
val goalId = mutableStateOf("general")
|
||||
val durationText = mutableStateOf("30")
|
||||
val preview = mutableStateOf<SessionGenerationPreview?>(null)
|
||||
val message = mutableStateOf<String?>(null)
|
||||
val warningAcknowledged = mutableStateOf(false)
|
||||
val editingIndex = mutableStateOf<Int?>(null)
|
||||
val setsText = mutableStateOf("")
|
||||
val repsText = mutableStateOf("")
|
||||
val restText = mutableStateOf("")
|
||||
val loadText = mutableStateOf("")
|
||||
val loadChoice = mutableStateOf(GeneratorLoadChoice.AUTOMATIC)
|
||||
val maxPercentText = mutableStateOf("70")
|
||||
val busy = mutableStateOf(false)
|
||||
var requestIdentity: Long = 0
|
||||
|
||||
val hasUnacceptedWork: Boolean
|
||||
get() = preview.value != null || zoneId.value != "full_body" ||
|
||||
goalId.value != "general" || durationText.value != "30"
|
||||
|
||||
fun abandon() {
|
||||
zoneId.value = "full_body"
|
||||
goalId.value = "general"
|
||||
durationText.value = "30"
|
||||
preview.value = null
|
||||
message.value = null
|
||||
warningAcknowledged.value = false
|
||||
editingIndex.value = null
|
||||
setsText.value = ""
|
||||
repsText.value = ""
|
||||
restText.value = ""
|
||||
loadText.value = ""
|
||||
loadChoice.value = GeneratorLoadChoice.AUTOMATIC
|
||||
maxPercentText.value = "70"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SessionGeneratorScreen(
|
||||
repository: TrainlogRepository,
|
||||
state: SessionGeneratorUiState,
|
||||
onBack: () -> Unit,
|
||||
onAccepted: () -> Unit,
|
||||
onExistingDraft: () -> Unit,
|
||||
|
|
@ -66,32 +111,38 @@ fun SessionGeneratorScreen(
|
|||
val scope = rememberCoroutineScope()
|
||||
val options = remember { repository.sessionGenerationFormOptions() }
|
||||
val zones = remember { repository.listBodyZones().filter { it.zoneId in options.zoneIds } }
|
||||
var zoneId by remember { mutableStateOf("full_body") }
|
||||
var goalId by remember { mutableStateOf("general") }
|
||||
var durationText by remember { mutableStateOf("30") }
|
||||
var preview by remember { mutableStateOf<SessionGenerationPreview?>(null) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var warningAcknowledged by remember { mutableStateOf(false) }
|
||||
var editingIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var setsText by remember { mutableStateOf("") }
|
||||
var repsText by remember { mutableStateOf("") }
|
||||
var restText by remember { mutableStateOf("") }
|
||||
var loadText by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var zoneId by state.zoneId
|
||||
var goalId by state.goalId
|
||||
var durationText by state.durationText
|
||||
var preview by state.preview
|
||||
var message by state.message
|
||||
var warningAcknowledged by state.warningAcknowledged
|
||||
var editingIndex by state.editingIndex
|
||||
var setsText by state.setsText
|
||||
var repsText by state.repsText
|
||||
var restText by state.restText
|
||||
var loadText by state.loadText
|
||||
var loadChoice by state.loadChoice
|
||||
var maxPercentText by state.maxPercentText
|
||||
var busy by state.busy
|
||||
|
||||
fun generate(request: SessionGenerationRequest) {
|
||||
if (busy) return
|
||||
val requestIdentity = ++state.requestIdentity
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
when (val result = withContext(Dispatchers.IO) { repository.generateSessionPreview(request) }) {
|
||||
is SessionGenerationResult.Generated -> {
|
||||
/* INVARIANT: an obsolete async generation request may
|
||||
* not replace a newer proposal after navigation. */
|
||||
if (requestIdentity != state.requestIdentity) return@launch
|
||||
preview = result.preview
|
||||
warningAcknowledged = result.preview.exposure.warningLevel == GenerationWarningLevel.NONE
|
||||
message = null
|
||||
}
|
||||
is SessionGenerationResult.Invalid -> message = result.message
|
||||
is SessionGenerationResult.DatabaseError -> message = result.message
|
||||
is SessionGenerationResult.Invalid -> if (requestIdentity == state.requestIdentity) message = result.message
|
||||
is SessionGenerationResult.DatabaseError -> if (requestIdentity == state.requestIdentity) message = result.message
|
||||
}
|
||||
} finally {
|
||||
busy = false
|
||||
|
|
@ -99,30 +150,17 @@ fun SessionGeneratorScreen(
|
|||
}
|
||||
}
|
||||
|
||||
TrainlogScreen(subtitle = "G E N E R E R U N E S E A N C E") {
|
||||
TrainlogAction("< Retour", "Annuler sans créer de brouillon ni modifier l'historique.", onClick = {
|
||||
if (!busy) onBack()
|
||||
}, accent = colors.muted)
|
||||
TrainlogScreen(subtitle = "Programmer une séance") {
|
||||
val current = preview
|
||||
if (current == null) {
|
||||
TrainlogFrame("ZONE CORPORELLE") {
|
||||
zones.forEach { zone ->
|
||||
TrainlogAction(zone.displayName, zone.zoneId,
|
||||
onClick = { zoneId = zone.zoneId },
|
||||
accent = if (zone.zoneId == zoneId) colors.success else colors.muted)
|
||||
TrainlogFrame("Zone corporelle") {
|
||||
TrainlogChoiceChips(zones.map { it.zoneId to it.displayName }, zoneId) { zoneId = it }
|
||||
}
|
||||
TrainlogFrame("Objectif") {
|
||||
TrainlogChoiceChips(SessionGeneratorFormController.goals.filter { it.first in options.goalIds }, goalId) { goalId = it }
|
||||
}
|
||||
TrainlogFrame("OBJECTIF") {
|
||||
SessionGeneratorFormController.goals.filter { it.first in options.goalIds }.forEach { (id, label) ->
|
||||
TrainlogAction(label, id, onClick = { goalId = id },
|
||||
accent = if (goalId == id) colors.success else colors.muted)
|
||||
}
|
||||
}
|
||||
TrainlogFrame("DURÉE") {
|
||||
options.durationPresets.forEach { minutes ->
|
||||
TrainlogAction("$minutes min", "Durée disponible.", onClick = { durationText = minutes.toString() },
|
||||
accent = if (durationText == minutes.toString()) colors.success else colors.muted)
|
||||
}
|
||||
TrainlogFrame("Durée") {
|
||||
TrainlogChoiceChips(options.durationPresets.map { it.toString() to "$it min" }, durationText) { durationText = it }
|
||||
TrainlogInputField(
|
||||
"Durée personnalisée (${options.customMinutes.first} à ${options.customMinutes.last} min)",
|
||||
durationText,
|
||||
|
|
@ -135,8 +173,11 @@ fun SessionGeneratorScreen(
|
|||
else generate(SessionGenerationRequest(zoneId, goalId, minutes, OffsetDateTime.now().toString()))
|
||||
})
|
||||
} else {
|
||||
TrainlogFrame("PROPOSITION") {
|
||||
TrainlogInfo("Durée estimée : ${current.estimatedDurationSeconds / 60} min")
|
||||
TrainlogFrame("Proposition") {
|
||||
TrainlogInfo("Durée cible : ${current.request.durationMinutes} min · estimation : ${current.estimatedDurationSeconds / 60} min")
|
||||
if (current.request.durationMinutes * 60 - current.estimatedDurationSeconds >= 300)
|
||||
TrainlogInfo("La proposition est nettement plus courte que la cible ; aucun exercice n'est ajouté pour remplir artificiellement le temps.", colors.warning)
|
||||
TrainlogInfo("Échauffement et retour au calme ne sont pas générés en V1.", colors.muted)
|
||||
if (current.insufficientResolvedCandidates) TrainlogInfo(
|
||||
"La couverture est incomplète faute de contextes résolus disponibles" +
|
||||
current.shortageCodes.takeIf { it.isNotEmpty() }
|
||||
|
|
@ -166,8 +207,7 @@ fun SessionGeneratorScreen(
|
|||
TrainlogInfo("Historique récent similaire détecté ; information uniquement.", colors.warning)
|
||||
item.loadSourceStartedAt?.let {
|
||||
TrainlogInfo(
|
||||
"Charge issue d'une dose réellement observée le $it " +
|
||||
"(séance ${item.loadSourceSessionId}, passage ${item.loadSourceOccurrenceId}) ; " +
|
||||
"Charge issue d'une dose réellement observée le $it ; " +
|
||||
"son applicabilité aujourd'hui reste incertaine.",
|
||||
colors.muted,
|
||||
)
|
||||
|
|
@ -180,13 +220,27 @@ fun SessionGeneratorScreen(
|
|||
TrainlogInputField("Séries", setsText, onValueChange = { setsText = it })
|
||||
TrainlogInputField("Répétitions", repsText, onValueChange = { repsText = it })
|
||||
TrainlogInputField("Repos (secondes)", restText, onValueChange = { restText = it })
|
||||
TrainlogInfo("Charge : choix utilisateur, jamais une recommandation.", colors.muted)
|
||||
TrainlogChoiceChips(
|
||||
listOf("AUTOMATIC" to "Automatique", "PERCENT_MAX" to "% MAX", "NONE" to "Aucune"),
|
||||
loadChoice.name,
|
||||
) { selected ->
|
||||
loadChoice = GeneratorLoadChoice.valueOf(selected)
|
||||
if (loadChoice != GeneratorLoadChoice.AUTOMATIC) loadText = ""
|
||||
}
|
||||
if (loadChoice == GeneratorLoadChoice.PERCENT_MAX)
|
||||
TrainlogInputField("Pourcentage du MAX (1 à 100)", maxPercentText,
|
||||
onValueChange = { maxPercentText = it })
|
||||
if (loadChoice == GeneratorLoadChoice.AUTOMATIC)
|
||||
TrainlogInputField(
|
||||
"Charge cible manuelle (vide = réévaluer)",
|
||||
"Charge cible manuelle (vide = automatique)",
|
||||
loadText,
|
||||
onValueChange = { loadText = it },
|
||||
)
|
||||
TrainlogAction("Appliquer", "Réestimer la durée et requalifier la charge observée.", accent = colors.success, onClick = {
|
||||
val parsedWeight = SessionGeneratorFormController.manualWeight(loadText)
|
||||
val parsedWeight = if (loadChoice == GeneratorLoadChoice.AUTOMATIC)
|
||||
SessionGeneratorFormController.manualWeight(loadText)
|
||||
else Result.success(null)
|
||||
if (parsedWeight.isFailure) {
|
||||
message = parsedWeight.exceptionOrNull()?.message
|
||||
} else {
|
||||
|
|
@ -197,7 +251,8 @@ fun SessionGeneratorScreen(
|
|||
when (val result = withContext(Dispatchers.IO) {
|
||||
repository.editGeneratedDose(current, index,
|
||||
setsText.toIntOrNull() ?: -1, repsText.toIntOrNull() ?: -1,
|
||||
restText.toIntOrNull() ?: -1, weight)
|
||||
restText.toIntOrNull() ?: -1, weight, loadChoice,
|
||||
maxPercentText.toIntOrNull())
|
||||
}) {
|
||||
is SessionGenerationResult.Generated -> { preview = result.preview; editingIndex = null; message = null }
|
||||
is SessionGenerationResult.Invalid -> message = result.message
|
||||
|
|
@ -216,6 +271,12 @@ fun SessionGeneratorScreen(
|
|||
// Preserve an explicit user value across later edits. An
|
||||
// automatic observed value stays display-only: empty asks
|
||||
// the engine to qualify it again for the changed dose.
|
||||
loadChoice = when {
|
||||
"user_selected_max_percentage" in item.rationaleCodes ||
|
||||
"compatible_max_unavailable" in item.rationaleCodes -> GeneratorLoadChoice.PERCENT_MAX
|
||||
"numeric_load_absent" in item.rationaleCodes -> GeneratorLoadChoice.NONE
|
||||
else -> GeneratorLoadChoice.AUTOMATIC
|
||||
}
|
||||
loadText = if ("manual_target_load" in item.rationaleCodes)
|
||||
item.plan.weightKg?.toString().orEmpty() else ""
|
||||
}
|
||||
|
|
@ -252,14 +313,16 @@ fun SessionGeneratorScreen(
|
|||
if (!busy) onBack()
|
||||
}, accent = colors.muted)
|
||||
}
|
||||
if (busy) TrainlogFrame("TRAITEMENT") { TrainlogInfo("Analyse en cours…", colors.muted) }
|
||||
message?.let { TrainlogFrame("MESSAGE") { TrainlogInfo(it, colors.error) } }
|
||||
if (busy) TrainlogFrame("Traitement") { TrainlogInfo("Analyse en cours…", colors.muted) }
|
||||
message?.let { TrainlogFrame("Message") { TrainlogInfo(it, colors.error) } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun generationReasonLabel(code: String): String = when (code) {
|
||||
"observed_repeated_dose_anchor" -> "dose répétée observée"
|
||||
"explicit_max_present_no_numeric_prescription" -> "maximum observé sans prescription numérique"
|
||||
"user_selected_max_percentage" -> "pourcentage de MAX choisi par l'utilisateur"
|
||||
"compatible_max_unavailable" -> "MAX compatible indisponible"
|
||||
"assistance_numeric_load_omitted" -> "charge d'assistance omise"
|
||||
"numeric_load_absent" -> "charge numérique absente"
|
||||
"manual_target_load" -> "charge saisie manuellement"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
|||
import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
|
|
@ -37,6 +38,8 @@ import com.labfytools.trainlog.model.ExerciseProfile
|
|||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionDraftForm
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||
import com.labfytools.trainlog.model.SessionLoadMode
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
|
|
@ -124,20 +127,11 @@ fun SessionScreen(
|
|||
}
|
||||
|
||||
TrainlogScreen(
|
||||
subtitle = "S E A N C E"
|
||||
subtitle = "Séance en cours"
|
||||
) {
|
||||
TrainlogAction(
|
||||
label = "< Retour",
|
||||
description =
|
||||
"Revenir à l'accueil sans supprimer la séance en cours.",
|
||||
/* CONTRACT: ordinary navigation never owns draft deletion. */
|
||||
onClick = onBack,
|
||||
accent = colors.muted,
|
||||
)
|
||||
|
||||
if (activeDraft == null) {
|
||||
TrainlogFrame(
|
||||
title = "ERREUR"
|
||||
title = "Erreur"
|
||||
) {
|
||||
TrainlogInfo(
|
||||
text = message.orEmpty(),
|
||||
|
|
@ -169,7 +163,7 @@ fun SessionScreen(
|
|||
)
|
||||
|
||||
TrainlogFrame(
|
||||
title = "TYPE DE SEANCE"
|
||||
title = "Type de séance"
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
|
|
@ -252,7 +246,7 @@ fun SessionScreen(
|
|||
)
|
||||
|
||||
TrainlogFrame(
|
||||
title = "SEANCE EN COURS"
|
||||
title = "Séance en cours"
|
||||
) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
|
|
@ -367,6 +361,9 @@ fun SessionScreen(
|
|||
sessionType = currentDraft.sessionType,
|
||||
initialForm =
|
||||
currentDraft.form,
|
||||
initialPlan = currentDraft.form.editingExerciseIndex?.let {
|
||||
currentDraft.exercises.getOrNull(it)?.plan
|
||||
},
|
||||
onFormChanged = {
|
||||
form ->
|
||||
persistDraft(
|
||||
|
|
@ -397,9 +394,7 @@ fun SessionScreen(
|
|||
currentDraft.exercises.mapIndexed { index, existing ->
|
||||
/* INVARIANT: normal performed-value edits
|
||||
* preserve generator planning metadata. */
|
||||
if (index == replacingIndex) {
|
||||
draft.copy(plan = existing.plan)
|
||||
} else existing
|
||||
if (index == replacingIndex) draft else existing
|
||||
}
|
||||
} ?: (currentDraft.exercises + draft),
|
||||
form =
|
||||
|
|
@ -412,7 +407,7 @@ fun SessionScreen(
|
|||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "EXERCICES"
|
||||
title = "Exercices"
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
|
|
@ -427,7 +422,7 @@ fun SessionScreen(
|
|||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "ENREGISTREMENT",
|
||||
title = "Enregistrement",
|
||||
active =
|
||||
currentDraft.exercises.isNotEmpty(),
|
||||
) {
|
||||
|
|
@ -554,6 +549,7 @@ private fun CatalogChoice(
|
|||
.then(modifier)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.background(
|
||||
if (selected) {
|
||||
colors.surfaceAlt
|
||||
|
|
@ -621,7 +617,7 @@ private fun ExercisePicker(
|
|||
var query by remember(selectedExercise?.exerciseId) { mutableStateOf("") }
|
||||
val results = remember(exercises, query) { exercisePrefixMatches(exercises, query) }
|
||||
|
||||
TrainlogFrame(title = "EXERCICE", active = exercises.isNotEmpty()) {
|
||||
TrainlogFrame(title = "Exercice", active = exercises.isNotEmpty()) {
|
||||
if (exercises.isEmpty()) {
|
||||
TrainlogInfo("Aucun exercice.")
|
||||
return@TrainlogFrame
|
||||
|
|
@ -704,6 +700,65 @@ private fun normalizeExerciseSearchText(value: String): String =
|
|||
.trim()
|
||||
.replace("\\s+".toRegex(), " ")
|
||||
|
||||
internal enum class ManualTargetChoice { KG, PERCENT_MAX, NONE }
|
||||
|
||||
/**
|
||||
* Build occurrence planning metadata separately from performed rows.
|
||||
* CONTRACT: an existing generated/manual dose keeps its sets/reps/duration/rest;
|
||||
* only the user-selected load target changes. A new manual occurrence derives
|
||||
* its initial dose shape from the confirmed performed-row form without copying
|
||||
* target weight into any performed set.
|
||||
*/
|
||||
internal fun buildManualTargetPlan(
|
||||
draft: SessionExerciseDraft,
|
||||
existing: SessionExercisePlan?,
|
||||
choice: ManualTargetChoice,
|
||||
directKgText: String,
|
||||
percentResult: ManualPercentMaxResult?,
|
||||
equipmentSemantics: EquipmentLoadSemantics?,
|
||||
): Result<SessionExercisePlan?> {
|
||||
if (choice == ManualTargetChoice.NONE) return Result.success(null)
|
||||
val weight = when (choice) {
|
||||
ManualTargetChoice.KG -> directKgText.trim().replace(',', '.').toDoubleOrNull()
|
||||
?.takeIf { it.isFinite() && it > 0.0 }
|
||||
?: return Result.failure(IllegalArgumentException(
|
||||
"Saisissez une charge cible strictement positive."))
|
||||
ManualTargetChoice.PERCENT_MAX ->
|
||||
(percentResult as? ManualPercentMaxResult.Available)?.targetWeightKg
|
||||
?: return Result.failure(IllegalArgumentException(
|
||||
(percentResult as? ManualPercentMaxResult.Unavailable)?.message
|
||||
?: "MAX compatible indisponible."))
|
||||
ManualTargetChoice.NONE -> error("handled above")
|
||||
}
|
||||
val mode = when (equipmentSemantics) {
|
||||
EquipmentLoadSemantics.EXTERNAL -> SessionLoadMode.EXTERNAL
|
||||
EquipmentLoadSemantics.ASSISTANCE -> if (choice == ManualTargetChoice.PERCENT_MAX)
|
||||
return Result.failure(IllegalArgumentException(
|
||||
"Le %MAX est indisponible pour une assistance ; choisissez une résistance externe."))
|
||||
else SessionLoadMode.ASSISTANCE
|
||||
else -> return Result.failure(IllegalArgumentException(
|
||||
"Choisissez un équipement compatible avec une charge cible."))
|
||||
}
|
||||
val dose = existing ?: when (draft.exercise.trackingMode) {
|
||||
TrackingMode.REPS -> SessionExercisePlan(
|
||||
sets = draft.sets.size,
|
||||
reps = draft.sets.firstOrNull()?.reps,
|
||||
)
|
||||
TrackingMode.DURATION -> SessionExercisePlan(
|
||||
sets = draft.sets.size,
|
||||
durationSeconds = draft.sets.firstOrNull()?.durationSeconds,
|
||||
)
|
||||
}
|
||||
if (dose.sets !in 1..MAX_SESSION_SETS ||
|
||||
(draft.exercise.trackingMode == TrackingMode.REPS &&
|
||||
(dose.reps == null || dose.reps !in 1..MAX_REPS_PER_SET)) ||
|
||||
(draft.exercise.trackingMode == TrackingMode.DURATION &&
|
||||
(dose.durationSeconds == null || dose.durationSeconds <= 0)))
|
||||
return Result.failure(IllegalArgumentException(
|
||||
"Définissez une dose cible positive avant la charge cible."))
|
||||
return Result.success(dose.copy(weightKg = weight, loadMode = mode))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionExerciseForm(
|
||||
key: String,
|
||||
|
|
@ -711,6 +766,7 @@ private fun SessionExerciseForm(
|
|||
exercise: ExerciseProfile,
|
||||
sessionType: SessionType,
|
||||
initialForm: SessionDraftForm,
|
||||
initialPlan: SessionExercisePlan?,
|
||||
onFormChanged: (SessionDraftForm) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onAdd:
|
||||
|
|
@ -790,6 +846,14 @@ private fun SessionExerciseForm(
|
|||
var selectedEquipmentId by remember(key) {
|
||||
mutableStateOf(initialForm.selectedEquipmentId)
|
||||
}
|
||||
var targetChoice by remember(key) {
|
||||
mutableStateOf(if (initialPlan?.weightKg != null) ManualTargetChoice.KG
|
||||
else ManualTargetChoice.NONE)
|
||||
}
|
||||
var targetKgText by remember(key) {
|
||||
mutableStateOf(initialPlan?.weightKg?.let(::formatMaxWeight).orEmpty())
|
||||
}
|
||||
var targetPercentText by remember(key) { mutableStateOf("70") }
|
||||
|
||||
var error by
|
||||
remember(key) {
|
||||
|
|
@ -836,6 +900,17 @@ private fun SessionExerciseForm(
|
|||
},
|
||||
)
|
||||
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
||||
val percentResult = remember(
|
||||
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
||||
targetChoice, equipmentRevision,
|
||||
) {
|
||||
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
||||
repository.calculateManualPercentMaxTarget(
|
||||
exercise.exerciseId, selectedEquipmentId,
|
||||
targetPercentText.toIntOrNull() ?: 0,
|
||||
)
|
||||
else null
|
||||
}
|
||||
if (selectedEquipment != null) {
|
||||
TrainlogAction(
|
||||
label = "✓ ${selectedEquipment.displayName}",
|
||||
|
|
@ -883,6 +958,45 @@ private fun SessionExerciseForm(
|
|||
exercise.recordingMode ==
|
||||
RecordingMode.SETS
|
||||
) {
|
||||
TrainlogInfo("Charge cible prévue — séparée des charges réellement effectuées.", colors.muted)
|
||||
TrainlogChoiceChips(
|
||||
listOf(
|
||||
ManualTargetChoice.KG.name to "Valeur en kg",
|
||||
ManualTargetChoice.PERCENT_MAX.name to "% de mon MAX",
|
||||
ManualTargetChoice.NONE.name to "Aucune",
|
||||
),
|
||||
targetChoice.name,
|
||||
) { selected -> targetChoice = ManualTargetChoice.valueOf(selected) }
|
||||
when (targetChoice) {
|
||||
ManualTargetChoice.KG -> SessionNumberField(
|
||||
label = "Charge cible (kg)", value = targetKgText,
|
||||
onValueChange = { targetKgText = it; error = null },
|
||||
)
|
||||
ManualTargetChoice.PERCENT_MAX -> {
|
||||
SessionNumberField(
|
||||
label = "Pourcentage de mon MAX (1 à 100)",
|
||||
value = targetPercentText,
|
||||
onValueChange = { targetPercentText = it; error = null },
|
||||
)
|
||||
when (val result = percentResult) {
|
||||
is ManualPercentMaxResult.Available -> {
|
||||
TrainlogInfo(
|
||||
"MAX compatible : ${formatMaxWeight(result.maxWeightKg)} kg · ${result.maxStartedAt}",
|
||||
colors.muted,
|
||||
)
|
||||
TrainlogInfo(
|
||||
"Cible calculée : ${formatMaxWeight(result.targetWeightKg)} kg",
|
||||
colors.success,
|
||||
)
|
||||
}
|
||||
is ManualPercentMaxResult.Unavailable ->
|
||||
TrainlogInfo(result.message, colors.error)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
ManualTargetChoice.NONE ->
|
||||
TrainlogInfo("Aucune charge cible ne sera enregistrée.", colors.muted)
|
||||
}
|
||||
if (
|
||||
exercise.trackingMode ==
|
||||
TrackingMode.REPS
|
||||
|
|
@ -1153,8 +1267,18 @@ private fun SessionExerciseForm(
|
|||
} else {
|
||||
"Valeurs invalides."
|
||||
}
|
||||
} else if (sessionType == SessionType.TRAINING &&
|
||||
exercise.recordingMode == RecordingMode.SETS) {
|
||||
val plan = buildManualTargetPlan(
|
||||
draft, initialPlan, targetChoice, targetKgText,
|
||||
percentResult, selectedEquipment?.loadSemantics,
|
||||
)
|
||||
plan.fold(
|
||||
onSuccess = { onAdd(draft.copy(plan = it)) },
|
||||
onFailure = { error = it.message ?: "Charge cible invalide." },
|
||||
)
|
||||
} else {
|
||||
onAdd(draft)
|
||||
onAdd(draft.copy(plan = null))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2,329 +2,134 @@ package com.labfytools.trainlog.ui
|
|||
|
||||
/* TRAINLOG_PC_CATALOG_AUTO_APPLY */
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.SyncExporter
|
||||
import com.labfytools.trainlog.data.CatalogInboxResult
|
||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||
import com.labfytools.trainlog.data.SyncExporter
|
||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
|
||||
private enum class TrainlogScreenId {
|
||||
HOME,
|
||||
SESSION,
|
||||
SESSION_GENERATOR,
|
||||
EXERCISE,
|
||||
BODY,
|
||||
HISTORY,
|
||||
SESSION_DETAIL,
|
||||
SYNC,
|
||||
}
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
|
||||
@Composable
|
||||
fun TrainlogApp(
|
||||
repository: TrainlogRepository,
|
||||
exporter: SyncExporter,
|
||||
inbox: SyncCatalogInbox,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
) {
|
||||
var screen by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
TrainlogScreenId.HOME
|
||||
)
|
||||
}
|
||||
|
||||
var exerciseReturnTarget by
|
||||
remember {
|
||||
mutableStateOf(
|
||||
TrainlogScreenId.HOME
|
||||
)
|
||||
}
|
||||
|
||||
var catalogRevision by
|
||||
remember {
|
||||
mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
var draftRevision by
|
||||
remember {
|
||||
mutableIntStateOf(0)
|
||||
}
|
||||
|
||||
var draftMessage by
|
||||
remember {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
var selectedSessionId by
|
||||
remember {
|
||||
mutableStateOf<String?>(
|
||||
null
|
||||
)
|
||||
}
|
||||
fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) {
|
||||
val navigation = appState.navigation
|
||||
val generatorState = appState.generator
|
||||
val equipmentState = appState.equipment
|
||||
val exerciseState = appState.exercise
|
||||
val bodyState = appState.body
|
||||
var catalogRevision by remember { mutableIntStateOf(0) }
|
||||
var draftRevision by remember { mutableIntStateOf(0) }
|
||||
var draftMessage by remember { mutableStateOf<String?>(null) }
|
||||
val navigationController = appState.navigationController
|
||||
|
||||
/* CONTRACT: automatic exchange work belongs to the application lifecycle.
|
||||
* Re-entering a route must never trigger a second import or export. */
|
||||
LaunchedEffect(Unit) {
|
||||
when (
|
||||
inbox.importPcCatalog()
|
||||
) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
catalogRevision += 1
|
||||
when (inbox.importPcCatalog()) {
|
||||
is CatalogInboxResult.Imported -> catalogRevision++
|
||||
CatalogInboxResult.FolderNotAuthorized, CatalogInboxResult.FileNotFound,
|
||||
is CatalogInboxResult.Error -> Unit
|
||||
}
|
||||
|
||||
CatalogInboxResult.FolderNotAuthorized,
|
||||
CatalogInboxResult.FileNotFound,
|
||||
is CatalogInboxResult.Error -> {
|
||||
/* Nothing to import yet. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
exporter.exportMobileBundle()
|
||||
}
|
||||
|
||||
if (
|
||||
screen !=
|
||||
TrainlogScreenId.HOME
|
||||
) {
|
||||
BackHandler {
|
||||
screen =
|
||||
when (screen) {
|
||||
TrainlogScreenId.EXERCISE ->
|
||||
exerciseReturnTarget
|
||||
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
|
||||
val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
||||
|
||||
TrainlogScreenId.SESSION_DETAIL ->
|
||||
TrainlogScreenId.HISTORY
|
||||
|
||||
else ->
|
||||
TrainlogScreenId.HOME
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (screen) {
|
||||
TrainlogScreenId.HOME -> {
|
||||
val draftLoad =
|
||||
remember(
|
||||
draftRevision,
|
||||
catalogRevision,
|
||||
) {
|
||||
repository.loadActiveSessionDraft()
|
||||
}
|
||||
|
||||
HomeScreen(
|
||||
activeDraft =
|
||||
(draftLoad as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.draft,
|
||||
draftError =
|
||||
draftMessage
|
||||
?: (draftLoad as?
|
||||
ActiveDraftLoadResult.Error)
|
||||
?.message
|
||||
?: (draftLoad as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.warning,
|
||||
onSession = {
|
||||
fun open(route: AppRoute) { navigationController.open(route) }
|
||||
fun openSection(section: AppSection) { navigationController.openSection(section) }
|
||||
fun back(): Boolean = navigationController.back()
|
||||
fun openManualSession() {
|
||||
when (draftLoad) {
|
||||
is ActiveDraftLoadResult.Loaded -> {
|
||||
draftMessage = null
|
||||
screen = TrainlogScreenId.SESSION
|
||||
}
|
||||
|
||||
ActiveDraftLoadResult.None -> {
|
||||
when (
|
||||
val result =
|
||||
repository
|
||||
.startActiveSessionDraft()
|
||||
) {
|
||||
ActiveDraftMutationResult.Saved -> {
|
||||
draftMessage = null
|
||||
draftRevision += 1
|
||||
screen =
|
||||
TrainlogScreenId.SESSION
|
||||
}
|
||||
|
||||
is ActiveDraftMutationResult.Error -> {
|
||||
draftMessage = result.message
|
||||
is ActiveDraftLoadResult.Loaded -> open(AppRoute.SessionEditor)
|
||||
ActiveDraftLoadResult.None -> when (val result = repository.startActiveSessionDraft()) {
|
||||
ActiveDraftMutationResult.Saved -> { draftRevision++; open(AppRoute.SessionEditor) }
|
||||
is ActiveDraftMutationResult.Error -> draftMessage = result.message
|
||||
}
|
||||
is ActiveDraftLoadResult.Error -> draftMessage = draftLoad.message
|
||||
}
|
||||
}
|
||||
|
||||
is ActiveDraftLoadResult.Error -> {
|
||||
draftMessage = draftLoad.message
|
||||
}
|
||||
}
|
||||
},
|
||||
onGenerateSession = {
|
||||
draftMessage = null
|
||||
screen = TrainlogScreenId.SESSION_GENERATOR
|
||||
},
|
||||
onDiscardDraft = {
|
||||
when (
|
||||
val result =
|
||||
repository
|
||||
.discardActiveSessionDraft()
|
||||
) {
|
||||
ActiveDraftMutationResult.Saved -> {
|
||||
draftMessage = null
|
||||
draftRevision += 1
|
||||
val routeStateHolder = rememberSaveableStateHolder()
|
||||
val retainedRouteKeys = remember { ArrayDeque<String>() }
|
||||
val routeKey = navigation.route.stateKey()
|
||||
LaunchedEffect(routeKey) {
|
||||
retainedRouteKeys.remove(routeKey)
|
||||
retainedRouteKeys.addLast(routeKey)
|
||||
while (retainedRouteKeys.size > 16) routeStateHolder.removeState(retainedRouteKeys.removeFirst())
|
||||
}
|
||||
|
||||
is ActiveDraftMutationResult.Error -> {
|
||||
draftMessage = result.message
|
||||
}
|
||||
}
|
||||
},
|
||||
onExercise = {
|
||||
exerciseReturnTarget =
|
||||
TrainlogScreenId.HOME
|
||||
|
||||
screen =
|
||||
TrainlogScreenId.EXERCISE
|
||||
},
|
||||
onBody = {
|
||||
screen =
|
||||
TrainlogScreenId.BODY
|
||||
},
|
||||
onHistory = {
|
||||
screen =
|
||||
TrainlogScreenId.HISTORY
|
||||
},
|
||||
onSync = {
|
||||
screen =
|
||||
TrainlogScreenId.SYNC
|
||||
},
|
||||
AndroidAppShell(navigation.route, ::openSection, ::back) {
|
||||
routeStateHolder.SaveableStateProvider(routeKey) {
|
||||
when (val route = navigation.route) {
|
||||
AppRoute.Home -> HomeScreen(
|
||||
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
|
||||
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
|
||||
::openManualSession, { open(AppRoute.SessionGenerator) },
|
||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||
)
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, { open(AppRoute.SessionEditor) }, { open(AppRoute.SessionGenerator) }, ::openManualSession, { open(AppRoute.CompletedSessions) })
|
||||
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exporter.exportMobileBundle(); draftRevision++ })
|
||||
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||
draftMessage = "Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée."
|
||||
draftRevision++
|
||||
/* WHY: ExistingActiveDraft is discovered only after the accept
|
||||
* action, while the proposal is still dirty. CONTRACT: this
|
||||
* route request uses the same owner and keep/discard guard as
|
||||
* Back and drawer navigation. */
|
||||
openSection(AppSection.SESSIONS)
|
||||
})
|
||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
||||
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }) { draftRevision++; open(AppRoute.SessionEditor) }
|
||||
AppRoute.Exercises -> ExerciseCatalogueScreen(repository, exerciseState, { open(AppRoute.ExerciseCreate(AppRoute.Exercises)) }, { open(AppRoute.ExerciseDetail(it)) })
|
||||
is AppRoute.ExerciseDetail -> ExerciseDetailScreen(repository, route.exerciseId, { open(AppRoute.ExerciseEdit(route.exerciseId, route)) }, { open(AppRoute.LatestMaxima) })
|
||||
is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
||||
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
||||
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
|
||||
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId)
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exporter.exportMobileBundle(); catalogRevision++; back() }
|
||||
AppRoute.Statistics -> StatisticsHub({ open(AppRoute.BodyMeasurements) }, { open(AppRoute.LatestMaxima) })
|
||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exporter.exportMobileBundle() }, { back() })
|
||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
AppRoute.Sync -> SyncScreen(inbox, requestOutbox, { exporter.exportMobileBundle(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(inbox) { exporter.exportMobileBundle(); catalogRevision++ }
|
||||
}}
|
||||
}
|
||||
|
||||
TrainlogScreenId.SESSION ->
|
||||
SessionScreen(
|
||||
repository = repository,
|
||||
catalogRevision =
|
||||
catalogRevision,
|
||||
onBack = {
|
||||
/* WHY: Back changes routing only; the repository remains
|
||||
* the canonical owner of the in-progress workout. */
|
||||
draftRevision += 1
|
||||
screen =
|
||||
TrainlogScreenId.HOME
|
||||
},
|
||||
onCreateExercise = {
|
||||
exerciseReturnTarget =
|
||||
TrainlogScreenId.SESSION
|
||||
|
||||
screen =
|
||||
TrainlogScreenId.EXERCISE
|
||||
},
|
||||
onSessionSaved = {
|
||||
exporter.exportMobileBundle()
|
||||
draftRevision += 1
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogScreenId.SESSION_GENERATOR ->
|
||||
SessionGeneratorScreen(
|
||||
repository = repository,
|
||||
onBack = { screen = TrainlogScreenId.HOME },
|
||||
onAccepted = {
|
||||
draftRevision += 1
|
||||
screen = TrainlogScreenId.SESSION
|
||||
},
|
||||
onExistingDraft = {
|
||||
draftMessage = "Une séance est déjà en cours. Reprenez-la ou supprimez-la explicitement depuis l'accueil."
|
||||
draftRevision += 1
|
||||
screen = TrainlogScreenId.HOME
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogScreenId.EXERCISE ->
|
||||
ExerciseScreen(
|
||||
repository = repository,
|
||||
inline =
|
||||
exerciseReturnTarget ==
|
||||
TrainlogScreenId.SESSION,
|
||||
onBack = {
|
||||
screen =
|
||||
exerciseReturnTarget
|
||||
},
|
||||
onSaved = {
|
||||
exporter.exportMobileBundle()
|
||||
|
||||
catalogRevision += 1
|
||||
|
||||
screen =
|
||||
exerciseReturnTarget
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogScreenId.BODY ->
|
||||
BodyScreen(
|
||||
repository = repository,
|
||||
onBodySaved = {
|
||||
exporter.exportMobileBundle()
|
||||
},
|
||||
onBack = {
|
||||
screen =
|
||||
TrainlogScreenId.HOME
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogScreenId.HISTORY ->
|
||||
HistoryScreen(
|
||||
repository = repository,
|
||||
onBack = {
|
||||
screen =
|
||||
TrainlogScreenId.HOME
|
||||
},
|
||||
onOpenSession = {
|
||||
sessionId ->
|
||||
selectedSessionId =
|
||||
sessionId
|
||||
|
||||
screen =
|
||||
TrainlogScreenId.SESSION_DETAIL
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogScreenId.SESSION_DETAIL ->
|
||||
SessionDetailScreen(
|
||||
repository = repository,
|
||||
sessionId =
|
||||
selectedSessionId,
|
||||
onBack = {
|
||||
screen =
|
||||
TrainlogScreenId.HISTORY
|
||||
},
|
||||
onResumeMaxTest = {
|
||||
draftRevision += 1
|
||||
screen = TrainlogScreenId.SESSION
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogScreenId.SYNC ->
|
||||
SyncScreen(
|
||||
inbox = inbox,
|
||||
requestOutbox =
|
||||
requestOutbox,
|
||||
onCatalogChanged = {
|
||||
exporter.exportMobileBundle()
|
||||
|
||||
catalogRevision += 1
|
||||
},
|
||||
onBack = {
|
||||
screen =
|
||||
TrainlogScreenId.HOME
|
||||
if (navigationController.hasPendingNavigation) {
|
||||
val guardedRoute = navigationController.pendingRoute
|
||||
val generator = guardedRoute == AppRoute.SessionGenerator
|
||||
AlertDialog(
|
||||
onDismissRequest = navigationController::cancelPending,
|
||||
title = { Text(if (generator) "Proposition non acceptée" else "Modifications non enregistrées") },
|
||||
text = { Text(if (generator) "La proposition peut rester en mémoire pendant que vous changez de rubrique." else "Les champs restent en mémoire tant que vous ne les abandonnez pas explicitement.") },
|
||||
confirmButton = { TextButton(onClick = navigationController::keepAndNavigate) { Text("Conserver et quitter") } },
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
navigationController.discardAndNavigate()
|
||||
}) { Text(if (generator) "Abandonner la proposition" else "Abandonner les modifications") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AppRoute.stateKey(): String = when (this) {
|
||||
is AppRoute.SessionDetail -> "session:${sessionId}"
|
||||
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
|
||||
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
|
||||
is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}"
|
||||
is AppRoute.EquipmentDetail -> "equipment:${equipmentId}"
|
||||
is AppRoute.EquipmentCreate -> "equipment-create:${caller.section}"
|
||||
else -> this::class.qualifiedName.orEmpty()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,17 @@ package com.labfytools.trainlog.ui
|
|||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -21,6 +23,9 @@ import androidx.compose.foundation.text.BasicText
|
|||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -32,14 +37,15 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
|
||||
@Composable
|
||||
fun TrainlogScreen(
|
||||
subtitle: String,
|
||||
scrollKey: String = subtitle,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val colors =
|
||||
|
|
@ -49,8 +55,6 @@ fun TrainlogScreen(
|
|||
modifier =
|
||||
Modifier
|
||||
.background(colors.background)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
.verticalScroll(
|
||||
rememberScrollState()
|
||||
|
|
@ -62,51 +66,16 @@ fun TrainlogScreen(
|
|||
)
|
||||
),
|
||||
) {
|
||||
TrainlogBanner(
|
||||
subtitle = subtitle
|
||||
BasicText(
|
||||
text = subtitle.lowercase().replaceFirstChar { it.titlecase() },
|
||||
modifier = Modifier.padding(bottom = 18.dp),
|
||||
style = TrainlogTypography.title.copy(color = colors.text),
|
||||
)
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrainlogBanner(
|
||||
subtitle: String
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 18.dp)
|
||||
) {
|
||||
/* WHY: TUI and Android share this compact plaque rather than separate
|
||||
* brand treatments. The terminal box becomes flat spacing on touch. */
|
||||
BasicText(
|
||||
text = "◆ TRAINLOG ◆",
|
||||
style =
|
||||
TrainlogTypography.banner.copy(
|
||||
color = colors.accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 21.sp,
|
||||
),
|
||||
)
|
||||
|
||||
BasicText(
|
||||
text = subtitle,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
style =
|
||||
TrainlogTypography.small.copy(
|
||||
color = colors.muted,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrainlogFrame(
|
||||
title: String,
|
||||
|
|
@ -119,7 +88,7 @@ fun TrainlogFrame(
|
|||
|
||||
val accent =
|
||||
if (active) {
|
||||
colors.warning
|
||||
colors.accent
|
||||
} else {
|
||||
colors.muted
|
||||
}
|
||||
|
|
@ -131,7 +100,7 @@ fun TrainlogFrame(
|
|||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = title.uppercase(),
|
||||
text = title,
|
||||
style =
|
||||
TrainlogTypography.small.copy(
|
||||
color = accent,
|
||||
|
|
@ -149,7 +118,7 @@ fun TrainlogFrame(
|
|||
bottom = 8.dp,
|
||||
)
|
||||
.height(1.dp)
|
||||
.background(accent)
|
||||
.background(colors.surfaceAlt)
|
||||
)
|
||||
|
||||
content()
|
||||
|
|
@ -175,18 +144,11 @@ fun TrainlogAction(
|
|||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 3.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(colors.surface)
|
||||
.background(Color.Transparent)
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(3.dp)
|
||||
.fillMaxHeight()
|
||||
.background(actualAccent)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(
|
||||
|
|
@ -219,6 +181,45 @@ fun TrainlogAction(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
||||
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
|
||||
Text(label)
|
||||
}
|
||||
if (description.isNotBlank()) {
|
||||
BasicText(
|
||||
description,
|
||||
Modifier.padding(top = 4.dp),
|
||||
TrainlogTypography.small.copy(color = colors.muted),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact horizontally scrollable choices; selection is textual and colored. */
|
||||
@Composable
|
||||
fun TrainlogChoiceChips(
|
||||
choices: List<Pair<String, String>>,
|
||||
selectedId: String,
|
||||
onSelected: (String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
choices.forEach { (id, label) ->
|
||||
FilterChip(
|
||||
selected = id == selectedId,
|
||||
onClick = { onSelected(id) },
|
||||
label = { Text(if (id == selectedId) "✓ $label" else label) },
|
||||
modifier = Modifier.heightIn(min = 48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrainlogInputField(
|
||||
label: String,
|
||||
|
|
@ -269,12 +270,18 @@ fun TrainlogInputField(
|
|||
cursorBrush =
|
||||
SolidColor(colors.accent),
|
||||
textStyle =
|
||||
TrainlogTypography.normal.copy(
|
||||
(if (keyboardOptions.keyboardType == KeyboardType.Number ||
|
||||
keyboardOptions.keyboardType == KeyboardType.Decimal) {
|
||||
TrainlogTypography.numeric
|
||||
} else {
|
||||
TrainlogTypography.normal
|
||||
}).copy(
|
||||
color = colors.text,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
android/app/src/main/res/drawable/ic_back.xml
Normal file
1
android/app/src/main/res/drawable/ic_back.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.42,-1.41L7.83,13H20z"/></vector>
|
||||
1
android/app/src/main/res/drawable/ic_equipment.xml
Normal file
1
android/app/src/main/res/drawable/ic_equipment.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M4,4h16v4H4zM6,9h3v11H6zM15,9h3v11h-3zM9,12h6v3H9z"/></vector>
|
||||
1
android/app/src/main/res/drawable/ic_exercises.xml
Normal file
1
android/app/src/main/res/drawable/ic_exercises.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M3,9h3v6H3zM7,7h3v10H7zM14,7h3v10h-3zM18,9h3v6h-3zM10,11h4v2h-4z"/></vector>
|
||||
1
android/app/src/main/res/drawable/ic_home.xml
Normal file
1
android/app/src/main/res/drawable/ic_home.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M12,3 2,12h3v9h6v-6h2v6h6v-9h3z"/></vector>
|
||||
1
android/app/src/main/res/drawable/ic_menu.xml
Normal file
1
android/app/src/main/res/drawable/ic_menu.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M3,6h18v2H3zM3,11h18v2H3zM3,16h18v2H3z"/></vector>
|
||||
1
android/app/src/main/res/drawable/ic_sessions.xml
Normal file
1
android/app/src/main/res/drawable/ic_sessions.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M7,3h10v2h3v16H4V5h3zM6,7v12h12V7h-2v2H8V7z"/></vector>
|
||||
1
android/app/src/main/res/drawable/ic_settings.xml
Normal file
1
android/app/src/main/res/drawable/ic_settings.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M10,2h4l1,3 3,-1 2,3 -2,2 1,3 -1,3 2,2 -2,3 -3,-1 -1,3h-4l-1,-3 -3,1 -2,-3 2,-2 -1,-3 1,-3 -2,-2 2,-3 3,1zM12,8a4,4 0,1 0,0 8,4 4,0 0,0 0,-8z"/></vector>
|
||||
1
android/app/src/main/res/drawable/ic_statistics.xml
Normal file
1
android/app/src/main/res/drawable/ic_statistics.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M4,13h4v7H4zM10,8h4v12h-4zM16,4h4v16h-4z"/></vector>
|
||||
1
android/app/src/main/res/drawable/ic_sync.xml
Normal file
1
android/app/src/main/res/drawable/ic_sync.xml
Normal file
|
|
@ -0,0 +1 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#CDD6F4" android:pathData="M7,7h9l-3,-3 1.4,-1.4L20,8l-5.6,5.4L13,12l3,-3H7a4,4 0,0 0,-4,4H1a6,6 0,0 1,6,-6zM17,17H8l3,3 -1.4,1.4L4,16l5.6,-5.4L11,12l-3,3h9a4,4 0,0 0,4,-4h2a6,6 0,0 1,-6,6z"/></vector>
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.BodyObservationDraft
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionDraft
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class AndroidV11ExerciseAliasMigrationTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var name: String
|
||||
|
||||
@Before fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
name = "exercise-alias-v11-${UUID.randomUUID()}.db"
|
||||
}
|
||||
|
||||
@After fun tearDown() { context.deleteDatabase(name) }
|
||||
|
||||
@Test fun exactVersionElevenFixtureOnlyAddsEmptyAliasTable() {
|
||||
val repository = TrainlogRepository(context, name)
|
||||
val created = repository.createExercise(NewExerciseProfile(
|
||||
"V11 fixture", RecordingMode.SETS, TrackingMode.REPS, 0,
|
||||
)) as CreateExerciseResult.Created
|
||||
assertTrue(repository.saveSession(SessionDraft(listOf(SessionExerciseDraft(
|
||||
entryId = "sxe_11111111-1111-4111-8111-111111111111",
|
||||
exercise = created.exercise,
|
||||
equipmentId = "leg_press",
|
||||
sets = listOf(SessionSetDraft(reps = 7, weightKg = 42.5)),
|
||||
)))) is SaveSessionResult.Saved)
|
||||
assertTrue(repository.saveBodyObservation(BodyObservationDraft(
|
||||
bodyWeightKg = 72.25,
|
||||
)) is SaveBodyObservationResult.Saved)
|
||||
repository.close()
|
||||
|
||||
val path = context.getDatabasePath(name).path
|
||||
lateinit var before: Map<String, List<List<Any?>>>
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("DROP TABLE exercise_aliases")
|
||||
db.execSQL("PRAGMA user_version=11")
|
||||
before = snapshot(db)
|
||||
}
|
||||
|
||||
TrainlogRepository(context, name).let { migrated ->
|
||||
migrated.listSessions()
|
||||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(12, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, snapshot(db).filterKeys { it != "exercise_aliases" })
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM exercise_aliases"))
|
||||
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshot(db: SQLiteDatabase): Map<String, List<List<Any?>>> {
|
||||
val tables = db.rawQuery(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' " +
|
||||
"AND name NOT LIKE 'sqlite_%' AND name<>'android_metadata' ORDER BY name",
|
||||
null,
|
||||
).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } }
|
||||
return tables.associateWith { table ->
|
||||
db.rawQuery("SELECT * FROM `$table` ORDER BY rowid", null).use { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) add(List(cursor.columnCount) { column -> cursor.value(column) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor.value(column: Int): Any? = when (getType(column)) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> getLong(column)
|
||||
Cursor.FIELD_TYPE_FLOAT -> getDouble(column)
|
||||
Cursor.FIELD_TYPE_STRING -> getString(column)
|
||||
Cursor.FIELD_TYPE_BLOB -> getBlob(column).toList()
|
||||
else -> error("Unsupported SQLite value type")
|
||||
}
|
||||
|
||||
private fun scalar(db: SQLiteDatabase, sql: String): Int =
|
||||
db.rawQuery(sql, null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
cursor.getInt(0)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
141
docs/android.md
141
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
73
docs/design/app_shell_v1/android_mockups.html
Normal file
73
docs/design/app_shell_v1/android_mockups.html
Normal file
File diff suppressed because one or more lines are too long
BIN
docs/design/app_shell_v1/android_overview.png
Normal file
BIN
docs/design/app_shell_v1/android_overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
887
docs/design/app_shell_v1/proposal.md
Normal file
887
docs/design/app_shell_v1/proposal.md
Normal file
|
|
@ -0,0 +1,887 @@
|
|||
# APP_SHELL_V1 — proposition soumise au design gate
|
||||
|
||||
Date : 10 septembre 2026. Baseline inspectée : `7cb4996`, après
|
||||
`TRAINING_KNOWLEDGE_V1=PASS` et `SESSION_GENERATOR_V1=PASS`.
|
||||
Le dépôt était propre à l'ouverture. **Design seulement ; aucune autorisation
|
||||
d'implémentation n'est déduite de ce document.** Ce dossier est une proposition,
|
||||
pas une description de fonctionnalités déjà livrées ni un contrat FROZEN.
|
||||
|
||||
Les [maquettes Android](android_mockups.html) sont consultables hors ligne,
|
||||
avec un [aperçu PNG](android_overview.png). Les [maquettes TUI en couleur](tui_mockups.html)
|
||||
et leur [version texte](tui_mockups.txt) donnent les quatre grilles exactes et les
|
||||
overlays compacts. Les données affichées sont fictives, uniquement dans ces
|
||||
documents. Aucun catalogue ni aucune base utilisateur n'est modifié.
|
||||
|
||||
## 1. Décision proposée
|
||||
|
||||
Un même classement de l'information, deux interactions natives :
|
||||
|
||||
- Android : drawer modal de premier niveau, pages de section et navigation
|
||||
vers les détails. Touches d'action de 48 dp minimum, contenu compact et défilant.
|
||||
- TUI : shell persistant Notcurses ; navigation latérale quand la surface est
|
||||
suffisante, panneau de navigation temporaire dans les petits terminaux.
|
||||
- Un titre et un emplacement stables pour chaque fonction. La sélection de la
|
||||
rubrique est dérivée de la destination ; elle n'est pas un second état.
|
||||
- Mocha/Lavender, typographie système sur Android, hiérarchie par poids et
|
||||
alignement dans le terminal. Les surfaces regroupent ; les cadres ne décorent pas.
|
||||
|
||||
L'ancien **Historique** devient **Séances → Séances effectuées**.
|
||||
**Programmer une séance** héberge le générateur existant : préparer une séance
|
||||
unique, modifier sa proposition puis l'accepter dans l'éditeur ordinaire.
|
||||
Ce libellé n'introduit ni calendrier, ni modèles réutilisables, ni programme
|
||||
multi-séances, ni seconde séance planifiée persistante.
|
||||
|
||||
## 2. Ce que l'inspection a établi
|
||||
|
||||
| Sujet | Implémentation constatée | Conséquence pour le shell |
|
||||
|---|---|---|
|
||||
| Android navigation | `TrainlogApp.kt:21` : enum de huit écrans ; `remember`, cible de retour particulière pour la création d'exercice, ID de séance sélectionné ; `BackHandler` revient souvent à Accueil | Introduire un propriétaire unique de routes et des retours hiérarchiques/caller-aware |
|
||||
| Android cadre | `TrainlogComponents.kt:39` : colonne entièrement défilante, bannière comprise ; composants Foundation faits maison | Extraire un vrai shell fixe ; une seule zone défilante par écran |
|
||||
| Android thème | `TrainlogTheme.kt` : accent Teal `#94e2d5`, muted Blue, toute la police monospace | Passage explicite à Lavender et à une hiérarchie sans-serif ; conserver les rôles métier |
|
||||
| Android dépendances | BOM Compose `2026.08.00`, activity-compose `1.13.0`, Foundation/UI ; pas Material 3 ni navigation-compose ni bibliothèque d'icônes | L'ajout de Material 3 est un choix de la future implémentation, pas une dépendance supposée présente |
|
||||
| TUI structure | `tui.c` contient environ 13 000 lignes, des boucles d'écran imbriquées et des états locaux | Extraction progressive des contrôleurs ; une seule boucle d'événements finale |
|
||||
| TUI surfaces | `terminal.c:14` possède le contexte et le plan standard ; `TrainlogPanel` est une vue de coordonnées et son commit est vide | Les panneaux actuels ne peuvent pas servir de shell persistant sans changer l'adaptateur |
|
||||
| TUI état terminal | `tui.c:63` utilise un pointeur file-static pendant `trainlog_tui_run()` | Remplacer cet accès implicite par un contexte d'application passé explicitement |
|
||||
| Notcurses | `pkg-config notcurses-core` et `/usr/include/notcurses/version.h` : **3.0.17**, liaison `-lnotcurses-core` | Sélection d'API fondée sur ce header, sans mise à niveau nécessaire |
|
||||
| Équipements | Android : choix/création dans la saisie ; TUI : catalogue autonome. Création/liste/résolution disponibles, modification de définition absente | Ajouter le point d'entrée Android en réutilisant les opérations existantes ; ne pas inventer un éditeur de définition |
|
||||
| Persistance | Desktop et Android v11 dans le code et `docs/current_state.md` ; brouillon Android durable, brouillon TUI en mémoire | Le shell ne crée aucune migration ; ne pas promettre une reprise TUI après arrêt du processus |
|
||||
|
||||
Le texte desktop v9 de `AGENTS.md` §8 est un état ancien par rapport au code
|
||||
et aux documents actuels v11. La proposition ne résout pas cet écart par une
|
||||
modification de schéma. La formule « un plan standard » de `docs/tui.md`
|
||||
décrit le backend existant ; les plans enfants proposés conservent un seul
|
||||
contexte et un seul plan standard. Les contrats produit et de format restent
|
||||
intacts. La nouvelle séquence demandée ici sera synchronisée dans la roadmap
|
||||
lors de l'implémentation approuvée, sans annoncer le shell comme déjà livré.
|
||||
|
||||
## 3. Architecture de l'information finale
|
||||
|
||||
Légende : **V1** = exposé par la future implémentation APP_SHELL_V1 ;
|
||||
**existant PC** = fonction déjà disponible seulement sur desktop ;
|
||||
**futur** = emplacement réservé, absent des menus V1 tant que non implémenté.
|
||||
|
||||
```text
|
||||
Accueil V1
|
||||
|
||||
Séances V1 : page de section
|
||||
Séance en cours V1 : état/reprise
|
||||
Programmer une séance V1 : générateur existant
|
||||
Nouvelle séance manuelle V1 : action
|
||||
Séances effectuées V1 : ancien Historique
|
||||
Détail d'une séance
|
||||
Exercices / occurrences / séries / activité / MAX / plan
|
||||
Modifier selon capacité actuelle
|
||||
Reprendre ce Test max Android, stable ID
|
||||
|
||||
Exercices V1 : ouvre Catalogue
|
||||
Catalogue V1
|
||||
Fiche exercice V1
|
||||
Zones du corps / connaissances et sources V1
|
||||
Équipements compatibles / utilisés selon données disponibles
|
||||
Performances / MAX liens vers vues existantes
|
||||
Modifier V1, action contextuelle
|
||||
Créer V1, action de catalogue
|
||||
|
||||
Équipements V1 : ouvre Catalogue
|
||||
Catalogue V1, autonome aussi Android
|
||||
Fiche équipement V1
|
||||
Fourni / Personnel / Référence inconnue V1, selon résolution réelle
|
||||
Modifier la définition futur, API métier absente
|
||||
Créer V1, capacités actuelles
|
||||
|
||||
Statistiques V1 : page de section utile
|
||||
Vue d'ensemble futur STATS_V1
|
||||
Fréquence futur STATS_V1
|
||||
Progression futur STATS_V1
|
||||
Par exercice existant PC ; enrichi STATS_V1
|
||||
Zones du corps futur STATS_V1
|
||||
Mensurations V1
|
||||
Relevés / Ajouter / Détail
|
||||
Modifier / Graphiques / Analyse corporelle existant PC
|
||||
Vue sur 12 mois existant PC, déplacée d'Accueil
|
||||
Capacités / MAX V1, vues actuelles seulement
|
||||
Derniers MAX locaux Android
|
||||
Mesures / historique / graphe / charges de travail existant PC
|
||||
|
||||
Synchronisation V1
|
||||
État / Lancer / Résultat / Diagnostic capacités actuelles
|
||||
Journal / Détail d'une synchronisation existant PC
|
||||
Dossier d'échange / Récupération Android
|
||||
|
||||
Paramètres V1, contenu par plateforme
|
||||
Profil d'estimation corporelle existant PC
|
||||
Dossier d'échange Android, grant SAF existant
|
||||
```
|
||||
|
||||
**Créer et Modifier sont des actions, pas trois catalogues parallèles.**
|
||||
On ouvre Exercices directement sur son catalogue ; « Créer » est visible en
|
||||
tête. « Modifier » concerne l'exercice sélectionné ou sa fiche. Même logique
|
||||
pour Équipements lorsque l'opération existe. « Modifier l'équipement de cette
|
||||
occurrence » reste une action de séance : ce n'est pas la modification d'une
|
||||
définition du catalogue.
|
||||
|
||||
Les sous-rubriques futures de Statistiques ne sont ni des boutons grisés, ni
|
||||
des pages « bientôt disponible ». Le hub V1 montre uniquement Mensurations,
|
||||
Capacités / MAX et, sur PC, Par exercice. STATS_V1 ajoutera des destinations
|
||||
en utilisant les mêmes routes de section et les mêmes composants. Les zones
|
||||
actuelles restent pleinement visibles dans Exercices et Programmer ; aucune
|
||||
analyse par zone n'est anticipée. Android conserve sa mission de capture et de
|
||||
consultation locale ; réserver une route n'autorise pas une copie de toutes
|
||||
les analyses desktop.
|
||||
|
||||
Sur Android, « Voir les derniers MAX » depuis une fiche exercice ouvre la
|
||||
liste agrégée existante sous Statistiques → Capacités / MAX. Ce lien ne promet
|
||||
pas une page MAX filtrée par exercice, qui reste du ressort de STATS_V1.
|
||||
Sur TUI, le contexte d'exercice continue à ouvrir ses vues de performance/MAX
|
||||
existantes ; depuis Statistiques, un sélecteur d'exercice fournit ce contexte.
|
||||
|
||||
Paramètres a une véritable première destination sur chaque plateforme.
|
||||
Pas d'interrupteur de thème, notifications, unités ou police sans fonction
|
||||
réelle. Le profil d'estimation et le grant SAF gardent leurs propriétaires
|
||||
actuels ; les anciens accès contextuels pointent vers le même éditeur.
|
||||
|
||||
## 4. Inventaire exhaustif des capacités visibles et destination
|
||||
|
||||
Cette matrice distingue les plateformes : préserver n'exige pas d'inventer
|
||||
sur Android une opération réservée aujourd'hui au PC.
|
||||
|
||||
| Capacité actuelle | Android | TUI | Destination / conservation |
|
||||
|---|---|---|---|
|
||||
| Reprise séance en cours | Brouillon durable, résumé, avertissements de récupération | Éditeur courant en mémoire | Accueil → Reprendre ; Séances → Séance en cours |
|
||||
| Nouvelle séance, normal/Test max | Oui | Oui | Séances → Nouvelle séance manuelle → type |
|
||||
| Ajouter/éditer/retirer une occurrence ; même exercice plusieurs fois | Oui | Oui | Séance en cours → occurrences distinctes par `entry_id` |
|
||||
| Créer un exercice pendant la saisie | Oui, avec retour au formulaire | Oui, via sélecteur | Action « Créer un exercice » dans la sélection ; retour au même appelant |
|
||||
| Séries réelles hétérogènes, charge par série, valeur absente/0, virgule décimale Android | Oui | Oui | Éditeur d'occurrence, valeurs réelles séparées du plan |
|
||||
| SETS + DURATION ; CONTINUOUS + DURATION ; vitesse/distance si configurées | Oui | Oui | Même éditeur piloté par métadonnées, aucun set fictif |
|
||||
| MAX explicite sans séries | Oui | Oui | Type Test max ; résultat d'occurrence |
|
||||
| Abandon explicite / sauvegarde / erreurs de validation | Brouillon durable, abandon confirmé, finalisation atomique | Édition en mémoire, écriture finale transactionnelle | Actions contextuelles, diagnostic persistant et retour sans écrasement |
|
||||
| Génération par zone, objectif, durée personnalisée/préréglée | Oui | Oui | Séances → Programmer une séance |
|
||||
| Exposition récente, avertissement, couverture partielle/vide, raisons et provenance de charge | Oui | Oui | Paramètres/proposition/détail du générateur ; toutes les explications consultables |
|
||||
| Proposition modifiable, supprimer/réordonner/régénérer/accepter/annuler | Oui ; édition dans l'aperçu | Aperçu puis édition ordinaire après acceptation | Même point d'entrée ; respecter les étapes réellement disponibles, pas de réécriture de politique |
|
||||
| Liste des séances terminées, date/type, détail des actuals et du plan | Oui, historique local | Oui, historique canonique | Séances → Séances effectuées → Détail |
|
||||
| Correction complète d'une séance persistée, retrait d'exercice, rollback | Non, pas d'éditeur général actuel | Oui | Détail → Modifier sur PC |
|
||||
| Reprendre le même Test max terminé en gardant ID/date | Oui | Édition persistée existante | Détail du Test max ; respect du conflit de brouillon existant |
|
||||
| Changer/retirer l'équipement d'une occurrence terminée | Oui | Oui via édition | Détail → occurrence → Équipement |
|
||||
| Catalogue d'exercices, recherche préfixe normalisé | Oui | Oui | Exercices → Catalogue |
|
||||
| Créer/éditer nom et zones d'exercice ; profil protégé si référencé | Oui | Nom/zones et création selon capacités actuelles | Créer / Fiche → Modifier ; IDs inchangés ; aucun renommage en masse |
|
||||
| Zone primaire, secondaires, groupes descendants, Non renseignés | Oui | Oui | Catalogue : filtre et résumé ; Fiche : détail ; Éditeur : sélecteur existant |
|
||||
| Connaissances exercice, confiance, références scientifiques, cas non résolu | Oui | Oui, vue défilante | Fiche → Connaissances ; affichage existant sans nouvelle inférence |
|
||||
| Équipements compatibles et usages historiques d'un exercice | Connaissances / contexte existants | Relations du manifeste et usages historiques séparés | Fiche → Équipements ; ne pas présenter un usage passé comme une preuve de compatibilité |
|
||||
| Meilleures performances ordinaires | Pas de page analytique dédiée | Oui | Statistiques → Par exercice ; lien `p` depuis fiche/catalogue |
|
||||
| Derniers MAX explicites | Dans Historique | Vue MAX | Statistiques → Capacités / MAX ; lien dans Séances effectuées |
|
||||
| MAX : historique, graphe, résultat récent/meilleur, arrondi 0,5/1/2,5/5 kg | Consultation locale simple | Oui | Capacités / MAX → exercice ; assistance inverse et absence de pourcentage conservées |
|
||||
| Catalogue équipements fournis, recherche par nom/étiquette/alias, détail | Pendant saisie | Autonome | Équipements → Catalogue ; sélecteur partagé dans l'éditeur |
|
||||
| Création équipement personnel | Nom simple, sémantique actuelle | Nom/étiquette/type/mode de charge | Catalogue → Créer et action dans sélecteur, sans élargir les contrats des formulaires |
|
||||
| Équipement historique introuvable | Résolution selon données reçues | Référence inconnue explicite | Fiche/occurrence : « Référence inconnue » et identifiant lisible |
|
||||
| Saisie de toutes les mensurations actuelles | Oui | Oui | Statistiques → Mensurations → Ajouter ; raccourci Accueil |
|
||||
| Relevés corporels récents | Oui, résumés | Oui, sélection/détail/édition | Mensurations → Relevés |
|
||||
| Correction relevé : identité/date/lien séance préservés | Non | Oui | Mensurations → Détail → Modifier |
|
||||
| Tendances, normalisation multi-mesures, distinction gauche/droite | Non | Oui | Mensurations → Graphiques |
|
||||
| Graphique mensuel 12 mois aujourd'hui sur Accueil | Non | Oui | Mensurations → Vue sur 12 mois, mêmes mois vides et même sélection du dernier relevé |
|
||||
| Composition, tendances, proportions, symétrie et libellés d'estimation | Non | Oui | Mensurations → Analyse corporelle, deux pages actuelles |
|
||||
| Taille et branche de formule du profil d'estimation | Non | Oui | Paramètres → Profil d'estimation ; raccourci `p` conservé dans l'analyse |
|
||||
| USB/MTP appareil, stockage, rafraîchir | Côté PC | Oui | Synchronisation → État |
|
||||
| Android→PC, PC→Android, bidirectionnel et confirmation exacte | Requête Android bidirectionnelle | Trois actions directes | Synchronisation ; aucun nouveau protocole ni menu de mode intermédiaire |
|
||||
| Snapshot automatique, import catalogue automatique, effets après sauvegarde | Oui | Import/export par moteur | Mêmes services et mêmes déclencheurs ; pas de relance à chaque navigation |
|
||||
| Autoriser/changer dossier SAF, relire catalogue PC | Oui | Sans objet | Paramètres → Dossier d'échange ; liens directs dans Synchronisation |
|
||||
| Requête en attente, reçu, diagnostic de résultat | Oui | Moteur/daemon | Synchronisation ; badge global seulement s'il reflète un état connu |
|
||||
| Journal structuré, direction, détail, erreurs/conflits | Reçu courant | Oui | Synchronisation → Journal → Détail |
|
||||
| UTF-8, clavier, petits terminaux, resize, aide et raccourcis | Touch/TalkBack/clavier matériel | Oui | Contrats transversaux du shell |
|
||||
|
||||
L'audit d'implémentation devra parcourir cette matrice ligne par ligne. Les
|
||||
anciens écrans n'ont pas tous la même profondeur de fonctions : la proposition
|
||||
ne promet pas une parité métier qui n'existe pas.
|
||||
|
||||
## 5. Android : drawer, pages de section, retour
|
||||
|
||||
Retenir **ModalNavigationDrawer + ModalDrawerSheet + NavigationDrawerItem**,
|
||||
avec `Scaffold` et barre supérieure Material 3. La dépendance `material3`
|
||||
sera ajoutée en s'alignant sur le BOM déjà utilisé. Les composants officiels
|
||||
prennent en charge le drawer modal ; le choix des hubs est une décision UX
|
||||
de Trainlog. [Documentation Android du drawer](https://developer.android.com/develop/ui/compose/components/drawer).
|
||||
|
||||
Le drawer contient sept lignes : Accueil, Séances, Exercices, Équipements,
|
||||
Statistiques, Synchronisation, Paramètres. Pas de sous-arbre déroulant : les
|
||||
longs libellés et les rubriques futures allongeraient inutilement le parcours
|
||||
sur téléphone. Chaque ligne a un pictogramme, un libellé et une zone tactile
|
||||
complète ; la rubrique active porte un fond Surface 0, une marque latérale
|
||||
Lavender et l'état sémantique sélectionné. Sous Séances, un petit texte peut
|
||||
indiquer « Séance en cours » si elle existe ; ce texte n'est pas un second lien.
|
||||
|
||||
La sélection d'une rubrique ouvre sa racine et ferme le drawer. Réappuyer sur
|
||||
la rubrique courante revient à sa racine, avec le même garde de formulaire si
|
||||
nécessaire. Les listes mémorisent recherche, filtre et position. Pas de piles
|
||||
de navigation indépendantes par item de drawer.
|
||||
|
||||
Aux racines : `☰ TRAINLOG` et un titre de page normal dans le contenu.
|
||||
Dans un détail : flèche Retour et titre court dans la barre, contexte de
|
||||
rubrique en sous-texte ; le menu secondaire contient une action « Navigation »
|
||||
pour changer de rubrique sans empiler des retours. Le drawer demeure le même.
|
||||
Pas de hamburger et flèche Retour concurrents dans le même emplacement.
|
||||
|
||||
Ordre de Retour : IME si ouvert → dialogue/feuille → drawer → route appelante
|
||||
→ hub/racine → Accueil → comportement Android normal. L'éditeur de brouillon
|
||||
Android conserve ses données via le repository ; Retour ne supprime rien.
|
||||
Les formulaires non durables et la proposition non acceptée demandent de
|
||||
confirmer une sortie qui perdrait les modifications. L'ouverture du drawer
|
||||
seule ne quitte pas le formulaire.
|
||||
|
||||
La création inline transporte une intention de retour avec l'ID de l'occurrence
|
||||
ou du formulaire appelant. Après création, l'exercice reste sélectionnable et
|
||||
le même éditeur reprend ; aucun second brouillon et aucun retour forcé Accueil.
|
||||
|
||||
## 6. Android : cadre et composants
|
||||
|
||||
```text
|
||||
TrainlogTheme (MaterialTheme + rôles complémentaires)
|
||||
AndroidAppShell
|
||||
ModalNavigationDrawer
|
||||
Scaffold
|
||||
TopAppBar fixe, contexte/navigation/actions courtes
|
||||
ScreenHost une seule destination, insets appliqués une fois
|
||||
SectionHeading titre 22 sp, contexte court
|
||||
SearchAndFilters si liste
|
||||
LazyColumn / Form contenu défilant
|
||||
ContextActionBar seulement si formulaire/aperçu, au-dessus IME
|
||||
SnackbarHost feedback ponctuel non critique
|
||||
Dialog / ModalBottomSheet garde de sortie, confirmation, choix borné
|
||||
```
|
||||
|
||||
Un écran fournit titre, filiation et liste d'actions sémantiques. Il ne
|
||||
redessine ni bannière globale ni bouton Retour géant dans son contenu.
|
||||
Un seul bouton principal rempli par contexte : Reprendre, Générer, Accepter
|
||||
la proposition ou Terminer la séance. Les actions secondaires sont des
|
||||
boutons texte ; les actions par ligne ont un menu accessible « Actions pour… ».
|
||||
Les suppressions ne dominent pas Accueil : elles vivent dans l'éditeur/menu,
|
||||
avec confirmation. Les avertissements importants restent dans le contenu et
|
||||
près de l'action concernée ; un snackbar ne suffit pas pour une erreur de
|
||||
persistance ou un avertissement du générateur.
|
||||
|
||||
Le formulaire de séance distingue clairement : **Exercice**, **Équipement**,
|
||||
**Objectif prévu**, **Séries réalisées**. Une proposition acceptée affiche zéro
|
||||
série réelle tant que l'utilisateur n'en a pas enregistré. Les nombres et les
|
||||
unités sont alignés ; la cible n'est pas un placeholder qui pourrait être
|
||||
enregistré comme une valeur réelle.
|
||||
|
||||
Les lignes du catalogue utilisent titre + contexte court, pas une grande
|
||||
carte par exercice. Les connaissances détaillées sont une route lisible ou
|
||||
une feuille défilante ; la confiance et les sources restent présentes.
|
||||
|
||||
## 7. Android : typographie, icônes, tailles
|
||||
|
||||
| Rôle | Proposition Android | Usage |
|
||||
|---|---|---|
|
||||
| Titre application | Sans-serif système 18 sp / 24, semibold | Barre TRAINLOG |
|
||||
| Titre écran | 22 sp / 28, semibold | Séances, Exercices ; pas de titre géant |
|
||||
| Titre section | 16 sp / 22, semibold | Dernière séance, Séries réalisées |
|
||||
| Texte courant | 16 sp / 24 | Libellés, explications et champs |
|
||||
| Texte secondaire | 14 sp / 20 | Équipement, date, contexte ; jamais erreur essentielle en 12 sp |
|
||||
| Valeur importante | 24 sp / 30, medium | Un MAX isolé, durée estimée |
|
||||
| Valeurs alignées | 16 sp / 24, chiffres tabulaires si pris en charge | kg, répétitions, durée, colonnes |
|
||||
|
||||
Utiliser la famille Android par défaut ; tenter `fontFeatureSettings="tnum"`
|
||||
dans les valeurs, puis réserver `FontFamily.Monospace` aux cellules numériques
|
||||
si la police système ne fournit pas l'alignement attendu. Pas de téléchargement
|
||||
de police ni nouvelle dépendance typographique. Le texte suit `fontScale` ;
|
||||
une maquette HTML système ne prétend pas reproduire exactement le moteur Compose.
|
||||
|
||||
Cibles tactiles **48 × 48 dp minimum**, ce qui dépasse la demande d'environ
|
||||
44 dp et suit le minimum Android. Ne pas compacter les cibles pour faire entrer
|
||||
une ligne ; empiler les actions. Vérifier TalkBack, ordre de lecture, intitulés
|
||||
de suppression par série/occurrence, annonces d'erreur et absence de doubles
|
||||
descriptions des icônes décoratives. [Accessibilité Compose](https://developer.android.com/develop/ui/compose/accessibility/api-defaults).
|
||||
|
||||
Il n'existe actuellement que les vecteurs du lanceur. Ajouter une petite
|
||||
sélection de **Material Symbols en VectorDrawable XML**, rendus par `Icon` /
|
||||
`painterResource`, même épaisseur et taille optique 24 dp. Pas de police
|
||||
d'icônes ni de gros paquet `material-icons-extended`. Cette forme de ressource
|
||||
est recommandée par la documentation Android actuelle.
|
||||
[Icônes Compose](https://developer.android.com/develop/ui/compose/graphics/images/material).
|
||||
|
||||
## 8. Android : adaptation aux téléphones
|
||||
|
||||
| Largeur utile | Comportement proposé |
|
||||
|---|---|
|
||||
| 320 dp | Marge 16 dp ; toutes les actions principales sur une colonne ; valeurs d'une série sur deux lignes si nécessaire ; drawer `min(360 dp, largeur - 56 dp)` = 264 dp |
|
||||
| 360 dp | Marge 16 dp ; lignes de catalogue sur deux lignes ; éditeur rep/charge compact avec actions de ligne séparées ; drawer 304 dp |
|
||||
| 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.
|
||||
122
docs/design/app_shell_v1/review_and_validation.md
Normal file
122
docs/design/app_shell_v1/review_and_validation.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# APP_SHELL_V1 — éléments de revue du design
|
||||
|
||||
Date : 10 septembre 2026.
|
||||
|
||||
## Périmètre réellement exécuté
|
||||
|
||||
- Inspection du dépôt propre à `7cb4996` : `SESSION_GENERATOR_V1` après
|
||||
`TRAINING_KNOWLEDGE_V1`.
|
||||
- Scout TUI isolé, lecture seule : structure des écrans, clavier, header/footer,
|
||||
géométrie, API terminal et Notcurses installé.
|
||||
- Inspection Android par le parent : routes, state owners, formulaires,
|
||||
brouillon/génération, historique/MAX, catalogues, mensurations, synchronisation,
|
||||
thème, typographie, ressources vectorielles et dépendances.
|
||||
- Advisor `gpt-5.6-terra`, high, contexte isolé, lecture seule : première revue
|
||||
de l'architecture puis vérification bornée des documents/maquettes écrits.
|
||||
- Création exclusive d'artefacts dans `docs/design/app_shell_v1/`.
|
||||
Aucune modification de code de production, DB, catalogue, build, format ou
|
||||
document canonique décrivant l'implémentation actuelle.
|
||||
|
||||
## Conclusion de la revue architecturale
|
||||
|
||||
L'advisor n'a relevé aucune contradiction architecturale bloquante dans le
|
||||
design final examiné. Il a confirmé notamment : propriétaire unique de route,
|
||||
état Android durable / TUI limité au run, alias clavier indépendants de l'ordre
|
||||
visuel, F6/F7 suffisants, rectangles header/content/footer, absence de clipping
|
||||
implicite des plans enfants, ownership des widgets, routes futures masquées,
|
||||
pas d'édition fictive de définition d'équipement, pas de migration métier.
|
||||
|
||||
Deux précisions demandées lors de sa dernière lecture ont été intégrées :
|
||||
|
||||
1. Le lien de fiche exercice Android devient **Voir les derniers MAX** et ouvre
|
||||
la liste existante dans Statistiques. Il ne prétend pas fournir une page
|
||||
d'analyse filtrée par exercice inexistante aujourd'hui.
|
||||
2. Toute sortie d'une proposition générée existante passe par une garde
|
||||
explicite ; seule **Abandonner la proposition** l'efface. Une sortie sûre
|
||||
peut la conserver dans son contrôleur en mémoire. Cette règle couvre
|
||||
Échap/q, Retour, drawer et sortie de l'application.
|
||||
|
||||
Le wrapper reader précise également que Home/End, flèches, suppression et texte
|
||||
restent locaux au champ. Les événements bruts Notcurses demeurent privés à
|
||||
l'adaptateur. Le comportement de destruction de `ncselector`, absent du
|
||||
commentaire local du header, a été établi par sa documentation officielle
|
||||
3.0.17, qui couvre aussi l'échec de création.
|
||||
|
||||
Cette revue est une revue de **design**, pas un audit d'une implémentation du
|
||||
shell ni un gel PASS/FROZEN.
|
||||
|
||||
## Vérifications des artefacts
|
||||
|
||||
| Vérification | Résultat |
|
||||
|---|---|
|
||||
| Android : neuf vues statiques | Présentes ; IDs HTML uniques, références SVG et liens locaux résolus |
|
||||
| Android : inspection visuelle | Rendu Firefox headless consulté à 360 px/100 % ; aperçu 320 px/200 % consulté pour le reflow |
|
||||
| TUI texte | Six grilles : 120×35, 100×30, 80×24, 72×20, navigation 72×20, confirmation 72×20 |
|
||||
| Bornes des grilles | Exactement la hauteur annoncée ; chaque ligne ≤ largeur annoncée en cellules Unicode ; deux lignes finales de footer non vides |
|
||||
| TUI couleur | Rendu HTML du 120×35 inspecté ; il reprend les mêmes grilles, pas une capture de l'application actuelle |
|
||||
| Scripts des deux visionneuses | Vérification de syntaxe `node --check` réussie |
|
||||
| Code suivi par Git | `git diff --exit-code HEAD` et `git diff --cached --exit-code` passent |
|
||||
| Espaces du diff suivi | `git diff --check` passe ; contrôle des nouveaux fichiers textuels fait séparément |
|
||||
|
||||
Contrastes calculés en sRGB pour les paires opaques proposées :
|
||||
|
||||
| Paire | Rapport |
|
||||
|---|---:|
|
||||
| Text / Base | 11,34:1 |
|
||||
| Subtext 1 / Base | 9,26:1 |
|
||||
| Subtext 1 / Surface 0 | 7,10:1 |
|
||||
| Lavender / Surface 0 | 7,03:1 |
|
||||
| Crust / Lavender | 10,48:1 |
|
||||
| Error / Surface 0 | 5,43:1 |
|
||||
| Warning / Surface 0 | 9,89:1 |
|
||||
|
||||
Ces résultats ne certifient pas tous les états d'accessibilité futurs.
|
||||
Ils vérifient les couleurs et les artefacts du design ; les états réellement
|
||||
composés, TalkBack, IME, polices système Android et palette de terminal seront
|
||||
validés pendant l'implémentation.
|
||||
|
||||
Aucun build/test de production n'était requis par une modification de code :
|
||||
il n'y en a pas eu. Aucun lancement de la TUI sur la base réelle, installation
|
||||
Android ou essai MTP matériel n'est revendiqué. Aucun commit ni push.
|
||||
|
||||
## Sources de l'inspection
|
||||
|
||||
Sources du dépôt :
|
||||
|
||||
- [État courant](../../current_state.md), [contrat TUI](../../tui.md),
|
||||
[contrat Android](../../android.md), [architecture](../../architecture.md).
|
||||
- [TUI actuelle](../../../tui/src/tui.c),
|
||||
[adaptateur terminal](../../../tui/src/terminal.c),
|
||||
[clavier Trainlog](../../../tui/include/trainlog/terminal.h),
|
||||
[interfaces DB existantes](../../../tui/include/trainlog/database.h).
|
||||
- [Routes Android](../../../android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt),
|
||||
[composants](../../../android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt),
|
||||
[thème](../../../android/app/src/main/java/com/labfytools/trainlog/ui/theme/TrainlogTheme.kt),
|
||||
[dépendances](../../../android/app/build.gradle.kts).
|
||||
- [Repository Android](../../../android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt),
|
||||
[générateur](../../../android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt),
|
||||
[historique/MAX](../../../android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt).
|
||||
|
||||
Provenance Notcurses : `pkg-config --modversion notcurses-core` → `3.0.17` ;
|
||||
`pkg-config --libs notcurses-core` → `-lnotcurses-core` ;
|
||||
`/usr/include/notcurses/version.h` et `/usr/include/notcurses/notcurses.h`.
|
||||
Les signatures installées font foi, certaines synopsis HTML upstream étant
|
||||
mal formées.
|
||||
|
||||
Références officielles consultées pour les choix de présentation/API :
|
||||
|
||||
- [Drawer Compose](https://developer.android.com/develop/ui/compose/components/drawer).
|
||||
- [Material 3 et sa dépendance](https://developer.android.com/develop/ui/compose/designsystems/material3).
|
||||
- [Accessibilité et minimum tactile 48 dp](https://developer.android.com/develop/ui/compose/accessibility/api-defaults).
|
||||
- [Icônes vectorielles Android](https://developer.android.com/develop/ui/compose/graphics/images/material).
|
||||
- [Palette Catppuccin](https://catppuccin.com/palette/).
|
||||
- [Plans Notcurses](https://notcurses.com/notcurses_plane.3.html),
|
||||
[selector 3.0.17 et propriété du plan](https://notcurses.com/notcurses_selector.3.html),
|
||||
[reader et ses limites](https://notcurses.com/notcurses_reader.3.html).
|
||||
|
||||
## Arrêt demandé
|
||||
|
||||
`APP_SHELL_V1_DESIGN=READY_FOR_HUMAN_REVIEW`
|
||||
|
||||
La tranche s'arrête au design gate demandé. Une approbation explicite reste
|
||||
nécessaire pour commencer l'implémentation de production.
|
||||
172
docs/design/app_shell_v1/tui_mockups.html
Normal file
172
docs/design/app_shell_v1/tui_mockups.html
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<!doctype html><html lang="fr"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>APP_SHELL_V1 — Maquettes TUI</title><style>
|
||||
:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;padding:28px;background:#11111b;color:#cdd6f4;font:16px/1.5 system-ui,sans-serif}h1{font-size:24px;margin:0 0 8px}p{color:#bac2de;max-width:1000px}button{padding:10px 16px;background:#313244;color:#cdd6f4;border:1px solid #45475a;border-radius:6px;cursor:pointer}button[aria-pressed=true]{background:#b4befe;color:#11111b}nav{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0}article{display:none}article.active{display:block}.frame{max-width:100%;overflow:auto;background:#11111b;padding:10px;border:1px solid #45475a;border-radius:8px;width:max-content}.terminal{font:14px/20px monospace;background:#1e1e2e;width:max-content}.line{height:20px;white-space:pre}.chrome{background:#181825}.side{display:inline-block;background:#181825;height:20px}.focus{color:#b4befe;background:#313244}.current{color:#b4befe}.muted{color:#bac2de}a{color:#b4befe}.small{font-size:14px}h2{font-size:18px}
|
||||
</style><h1>Trainlog — shell Notcurses proposé</h1><p>Données fictives. Grilles exactes en cellules. Le cadre extérieur représente le terminal, pas un cadre à ajouter à l'application. La couleur distingue le focus de la rubrique active ; chaque état garde aussi un marqueur textuel.</p><nav>
|
||||
<button data-target="s0" aria-pressed="true">120x35</button>
|
||||
<button data-target="s1" aria-pressed="false">100x30</button>
|
||||
<button data-target="s2" aria-pressed="false">80x24</button>
|
||||
<button data-target="s3" aria-pressed="false">72x20</button>
|
||||
<button data-target="s4" aria-pressed="false">72x20 — Navigation ouverte, contenu conservé dessous</button>
|
||||
<button data-target="s5" aria-pressed="false">72x20 — Exemple de confirmation, footer réservé</button>
|
||||
</nav>
|
||||
<article id="s0" class="active"><h2>120x35 — Séances effectuées</h2><div class="frame"><div class="terminal">
|
||||
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
|
||||
<div class="line chrome"><span class=""> Séances / Séances effectuées </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> Accueil │</span><span class=""> Séances effectuées </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> / Rechercher par date ou type </span></div>
|
||||
<div class="line "><span class="side current"> [Séances] │</span><span class=""> Toutes les séances · plus récentes en premier </span></div>
|
||||
<div class="line "><span class="side"> Séance en cours │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> Programmer │</span><span class=""> Date Type Exercices </span></div>
|
||||
<div class="line "><span class="side"> Nouvelle manuelle │</span><span class=""> 09/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side current"> · Effectuées │</span><span class=""> 07/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class="focus"> > 05/09/2026 Test max 4 </span></div>
|
||||
<div class="line "><span class="side"> Exercices │</span><span class=""> 03/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> Équipements │</span><span class=""> 01/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> Statistiques │</span><span class=""> 29/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> Synchronisation │</span><span class=""> 27/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> Paramètres │</span><span class=""> 25/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 22/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 20/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 18/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 15/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> ↓ Autres séances 3 / 24 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> ───────────────────────────────────────────────────────────────────────────────────────────── </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> Sélection : 5 septembre · Test max </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> Exercice : Pec Fly </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> Équipement : Rear Delt / Pec Fly </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> MAX explicite : 80 kg Entrée : ouvrir le détail complet </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line chrome"><span class=""> ↑↓ Choisir Entrée Détail / Rechercher e Modifier </span></div>
|
||||
<div class="line chrome"><span class=""> F6 Navigation F7 Actions ? Aide Échap Retour </span></div>
|
||||
</div></div></article>
|
||||
<article id="s1" class=""><h2>100x30 — Séances effectuées</h2><div class="frame"><div class="terminal">
|
||||
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
|
||||
<div class="line chrome"><span class=""> Séances / Séances effectuées </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> Accueil │</span><span class=""> Séances effectuées </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> / Rechercher par date ou type </span></div>
|
||||
<div class="line "><span class="side current"> [Séances] │</span><span class=""> Toutes les séances · plus récentes en premier </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> Exercices │</span><span class=""> Date Type Exercices </span></div>
|
||||
<div class="line "><span class="side"> Équipements │</span><span class=""> 09/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> Statistiques │</span><span class=""> 07/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> Synchronisation │</span><span class="focus"> > 05/09/2026 Test max 4 </span></div>
|
||||
<div class="line "><span class="side"> Paramètres │</span><span class=""> 03/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 01/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 29/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 27/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 25/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 22/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 20/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 18/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> 15/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> ↓ Autres séances 3 / 24 </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> ───────────────────────────────────────────────────────────────────────── </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> Sélection : 5 septembre · Test max </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> Exercice : Pec Fly </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> Équipement : Rear Delt / Pec Fly </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> MAX explicite : 80 kg Entrée : ouvrir le détail complet </span></div>
|
||||
<div class="line "><span class="side"> │</span><span class=""> </span></div>
|
||||
<div class="line chrome"><span class=""> ↑↓ Choisir Entrée Détail / Rechercher e Modifier </span></div>
|
||||
<div class="line chrome"><span class=""> F6 Navigation F7 Actions ? Aide Échap Retour </span></div>
|
||||
</div></div></article>
|
||||
<article id="s2" class=""><h2>80x24 — Séances effectuées</h2><div class="frame"><div class="terminal">
|
||||
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
|
||||
<div class="line chrome"><span class=""> Séances / Séances effectuées F6 Navigation </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> Séances effectuées </span></div>
|
||||
<div class="line "><span class=""> / Rechercher par date ou type </span></div>
|
||||
<div class="line "><span class=""> Toutes les séances · plus récentes en premier </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> Date Type Exercices </span></div>
|
||||
<div class="line "><span class=""> 09/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> 07/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="focus"> > 05/09/2026 Test max 4 </span></div>
|
||||
<div class="line "><span class=""> 03/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> 01/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> 29/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> 27/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> 25/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> 22/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> 20/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> ↓ Autres séances 3 / 24 </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> Sélection : 5 septembre · Test max · 4 exercices </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line chrome"><span class=""> ↑↓ Choisir Entrée Détail / Rechercher e Modifier </span></div>
|
||||
<div class="line chrome"><span class=""> F6 Navigation F7 Actions ? Aide Échap Retour </span></div>
|
||||
</div></div></article>
|
||||
<article id="s3" class=""><h2>72x20 — Séances effectuées</h2><div class="frame"><div class="terminal">
|
||||
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
|
||||
<div class="line chrome"><span class=""> Séances / Séances effectuées F6 Navigation </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> Séances effectuées </span></div>
|
||||
<div class="line "><span class=""> / Rechercher par date ou type </span></div>
|
||||
<div class="line "><span class=""> Toutes les séances · plus récentes en premier </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> Date Type Exercices </span></div>
|
||||
<div class="line "><span class=""> 09/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> 07/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class="focus"> > 05/09/2026 Test max 4 </span></div>
|
||||
<div class="line "><span class=""> 03/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> 01/09/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> 29/08/2026 Entraînement 6 </span></div>
|
||||
<div class="line "><span class=""> ↓ Autres séances 3 / 24 </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> Sélection : 5 septembre · Test max </span></div>
|
||||
<div class="line "><span class=""> Entrée : détail et résultats par exercice </span></div>
|
||||
<div class="line chrome"><span class=""> ↑↓ Choisir Entrée Détail / Rechercher e Modifier </span></div>
|
||||
<div class="line chrome"><span class=""> F6 Navigation F7 Actions ? Aide Échap Retour </span></div>
|
||||
</div></div></article>
|
||||
<article id="s4" class=""><h2>72x20 — Navigation ouverte, contenu conservé dessous</h2><div class="frame"><div class="terminal">
|
||||
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
|
||||
<div class="line chrome"><span class=""> Séances / Séances effectuées F6 Navigation </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> Navigation </span></div>
|
||||
<div class="line "><span class=""> Rubrique active : Séances / Effectuées </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> Accueil </span></div>
|
||||
<div class="line "><span class="focus"> > Séances </span></div>
|
||||
<div class="line "><span class=""> Exercices </span></div>
|
||||
<div class="line "><span class=""> Équipements </span></div>
|
||||
<div class="line "><span class=""> Statistiques </span></div>
|
||||
<div class="line "><span class=""> Synchronisation </span></div>
|
||||
<div class="line "><span class=""> Paramètres </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> Entrée ouvre la page de section. Toutes les rubriques sont nommées. </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line chrome"><span class=""> ↑↓ Choisir Entrée Ouvrir Échap Fermer </span></div>
|
||||
<div class="line chrome"><span class=""> Navigation · retour à la liste conservé </span></div>
|
||||
</div></div></article>
|
||||
<article id="s5" class=""><h2>72x20 — Exemple de confirmation, footer réservé</h2><div class="frame"><div class="terminal">
|
||||
<div class="line chrome"><span class=""> TRAINLOG Séance en cours : 3 exercices </span></div>
|
||||
<div class="line chrome"><span class=""> Séances / Séance en cours </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> Séance en cours · 3 exercices </span></div>
|
||||
<div class="line "><span class=""> 1. Leg Press > 2. Pec Fly 3. Abdominal crunch </span></div>
|
||||
<div class="line "><span class=""> ┌─ Retirer cet exercice ? ───────────────────────────────────┐ </span></div>
|
||||
<div class="line "><span class=""> │ │ </span></div>
|
||||
<div class="line "><span class=""> │ Pec Fly · occurrence 2 │ </span></div>
|
||||
<div class="line "><span class=""> │ │ </span></div>
|
||||
<div class="line "><span class=""> │ Les séries de cette occurrence seront retirées. │ </span></div>
|
||||
<div class="line "><span class=""> │ Le catalogue et les autres occurrences sont conservés. │ </span></div>
|
||||
<div class="line "><span class=""> │ │ </span></div>
|
||||
<div class="line "><span class=""> │ > [ Annuler ] [ Retirer ] │ </span></div>
|
||||
<div class="line "><span class=""> │ │ </span></div>
|
||||
<div class="line "><span class=""> └────────────────────────────────────────────────────────────┘ </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line "><span class=""> </span></div>
|
||||
<div class="line chrome"><span class=""> Tab Choisir Entrée Confirmer Échap Annuler </span></div>
|
||||
<div class="line chrome"><span class=""> Confirmation active · navigation suspendue </span></div>
|
||||
</div></div></article>
|
||||
<p class="small">Header : 2 lignes. Footer : 2 lignes. À 100 colonnes, sidebar de 22 cellules ; en compact, contenu sur toute la largeur. Les boutons ci-dessus changent seulement de maquette. <a href="tui_mockups.txt">Version texte</a> · <a href="proposal.md">Proposition complète</a></p><script>document.querySelectorAll('nav button').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('nav button').forEach(b=>b.setAttribute('aria-pressed',String(b===button)));document.querySelectorAll('article').forEach(a=>a.classList.toggle('active',a.id===button.dataset.target));}));</script></html>
|
||||
180
docs/design/app_shell_v1/tui_mockups.txt
Normal file
180
docs/design/app_shell_v1/tui_mockups.txt
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
APP_SHELL_V1 — MAQUETTES TUI (DESIGN UNIQUEMENT)
|
||||
Données fictives. Les coordonnées sont en cellules, pas en octets.
|
||||
Chaque bloc contient exactement H lignes ; les espaces de fin sont omis.
|
||||
La limite du terminal est implicite ; aucun grand cadre extérieur.
|
||||
Lavender : focus (>). Surface 0 : sélection. Mantle : shell.
|
||||
Les barres verticales en mode large représentent un séparateur discret.
|
||||
|
||||
=== 120x35 — Séances effectuées ===
|
||||
```text
|
||||
TRAINLOG Séance en cours : 3 exercices
|
||||
Séances / Séances effectuées
|
||||
│
|
||||
Accueil │ Séances effectuées
|
||||
│ / Rechercher par date ou type
|
||||
[Séances] │ Toutes les séances · plus récentes en premier
|
||||
Séance en cours │
|
||||
Programmer │ Date Type Exercices
|
||||
Nouvelle manuelle │ 09/09/2026 Entraînement 6
|
||||
· Effectuées │ 07/09/2026 Entraînement 6
|
||||
│ > 05/09/2026 Test max 4
|
||||
Exercices │ 03/09/2026 Entraînement 6
|
||||
Équipements │ 01/09/2026 Entraînement 6
|
||||
Statistiques │ 29/08/2026 Entraînement 6
|
||||
Synchronisation │ 27/08/2026 Entraînement 6
|
||||
Paramètres │ 25/08/2026 Entraînement 6
|
||||
│ 22/08/2026 Entraînement 6
|
||||
│ 20/08/2026 Entraînement 6
|
||||
│ 18/08/2026 Entraînement 6
|
||||
│ 15/08/2026 Entraînement 6
|
||||
│ ↓ Autres séances 3 / 24
|
||||
│
|
||||
│ ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ Sélection : 5 septembre · Test max
|
||||
│ Exercice : Pec Fly
|
||||
│ Équipement : Rear Delt / Pec Fly
|
||||
│ MAX explicite : 80 kg Entrée : ouvrir le détail complet
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
↑↓ Choisir Entrée Détail / Rechercher e Modifier
|
||||
F6 Navigation F7 Actions ? Aide Échap Retour
|
||||
```
|
||||
|
||||
=== 100x30 — Séances effectuées ===
|
||||
```text
|
||||
TRAINLOG Séance en cours : 3 exercices
|
||||
Séances / Séances effectuées
|
||||
│
|
||||
Accueil │ Séances effectuées
|
||||
│ / Rechercher par date ou type
|
||||
[Séances] │ Toutes les séances · plus récentes en premier
|
||||
│
|
||||
Exercices │ Date Type Exercices
|
||||
Équipements │ 09/09/2026 Entraînement 6
|
||||
Statistiques │ 07/09/2026 Entraînement 6
|
||||
Synchronisation │ > 05/09/2026 Test max 4
|
||||
Paramètres │ 03/09/2026 Entraînement 6
|
||||
│ 01/09/2026 Entraînement 6
|
||||
│ 29/08/2026 Entraînement 6
|
||||
│ 27/08/2026 Entraînement 6
|
||||
│ 25/08/2026 Entraînement 6
|
||||
│ 22/08/2026 Entraînement 6
|
||||
│ 20/08/2026 Entraînement 6
|
||||
│ 18/08/2026 Entraînement 6
|
||||
│ 15/08/2026 Entraînement 6
|
||||
│ ↓ Autres séances 3 / 24
|
||||
│
|
||||
│ ─────────────────────────────────────────────────────────────────────────
|
||||
│ Sélection : 5 septembre · Test max
|
||||
│ Exercice : Pec Fly
|
||||
│ Équipement : Rear Delt / Pec Fly
|
||||
│ MAX explicite : 80 kg Entrée : ouvrir le détail complet
|
||||
│
|
||||
↑↓ Choisir Entrée Détail / Rechercher e Modifier
|
||||
F6 Navigation F7 Actions ? Aide Échap Retour
|
||||
```
|
||||
|
||||
=== 80x24 — Séances effectuées ===
|
||||
```text
|
||||
TRAINLOG Séance en cours : 3 exercices
|
||||
Séances / Séances effectuées F6 Navigation
|
||||
|
||||
Séances effectuées
|
||||
/ Rechercher par date ou type
|
||||
Toutes les séances · plus récentes en premier
|
||||
|
||||
Date Type Exercices
|
||||
09/09/2026 Entraînement 6
|
||||
07/09/2026 Entraînement 6
|
||||
> 05/09/2026 Test max 4
|
||||
03/09/2026 Entraînement 6
|
||||
01/09/2026 Entraînement 6
|
||||
29/08/2026 Entraînement 6
|
||||
27/08/2026 Entraînement 6
|
||||
25/08/2026 Entraînement 6
|
||||
22/08/2026 Entraînement 6
|
||||
20/08/2026 Entraînement 6
|
||||
↓ Autres séances 3 / 24
|
||||
|
||||
Sélection : 5 septembre · Test max · 4 exercices
|
||||
|
||||
↑↓ Choisir Entrée Détail / Rechercher e Modifier
|
||||
F6 Navigation F7 Actions ? Aide Échap Retour
|
||||
```
|
||||
|
||||
=== 72x20 — Séances effectuées ===
|
||||
```text
|
||||
TRAINLOG Séance en cours : 3 exercices
|
||||
Séances / Séances effectuées F6 Navigation
|
||||
|
||||
Séances effectuées
|
||||
/ Rechercher par date ou type
|
||||
Toutes les séances · plus récentes en premier
|
||||
|
||||
Date Type Exercices
|
||||
09/09/2026 Entraînement 6
|
||||
07/09/2026 Entraînement 6
|
||||
> 05/09/2026 Test max 4
|
||||
03/09/2026 Entraînement 6
|
||||
01/09/2026 Entraînement 6
|
||||
29/08/2026 Entraînement 6
|
||||
↓ Autres séances 3 / 24
|
||||
|
||||
Sélection : 5 septembre · Test max
|
||||
Entrée : détail et résultats par exercice
|
||||
↑↓ Choisir Entrée Détail / Rechercher e Modifier
|
||||
F6 Navigation F7 Actions ? Aide Échap Retour
|
||||
```
|
||||
|
||||
=== 72x20 — Navigation ouverte, contenu conservé dessous ===
|
||||
```text
|
||||
TRAINLOG Séance en cours : 3 exercices
|
||||
Séances / Séances effectuées F6 Navigation
|
||||
|
||||
Navigation
|
||||
Rubrique active : Séances / Effectuées
|
||||
|
||||
Accueil
|
||||
> Séances
|
||||
Exercices
|
||||
Équipements
|
||||
Statistiques
|
||||
Synchronisation
|
||||
Paramètres
|
||||
|
||||
|
||||
Entrée ouvre la page de section. Toutes les rubriques sont nommées.
|
||||
|
||||
|
||||
↑↓ Choisir Entrée Ouvrir Échap Fermer
|
||||
Navigation · retour à la liste conservé
|
||||
```
|
||||
|
||||
=== 72x20 — Exemple de confirmation, footer réservé ===
|
||||
```text
|
||||
TRAINLOG Séance en cours : 3 exercices
|
||||
Séances / Séance en cours
|
||||
|
||||
Séance en cours · 3 exercices
|
||||
1. Leg Press > 2. Pec Fly 3. Abdominal crunch
|
||||
┌─ Retirer cet exercice ? ───────────────────────────────────┐
|
||||
│ │
|
||||
│ Pec Fly · occurrence 2 │
|
||||
│ │
|
||||
│ Les séries de cette occurrence seront retirées. │
|
||||
│ Le catalogue et les autres occurrences sont conservés. │
|
||||
│ │
|
||||
│ > [ Annuler ] [ Retirer ] │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
|
||||
Tab Choisir Entrée Confirmer Échap Annuler
|
||||
Confirmation active · navigation suspendue
|
||||
```
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
157
docs/reviews/app_shell_v1.md
Normal file
157
docs/reviews/app_shell_v1.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# APP_SHELL_V1 implementation and validation record
|
||||
|
||||
## Status
|
||||
|
||||
```text
|
||||
APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2
|
||||
```
|
||||
|
||||
This record describes the implemented cross-platform application shell. It
|
||||
does not mark the checkpoint PASS or FROZEN. Its remaining final audit is the
|
||||
human visual and accessibility review described below.
|
||||
|
||||
The subsequent repair and `PERCENT_MAX_INPUT_V1` tranche was validated by the
|
||||
second automated review. `_2` records that repaired implementation state; it
|
||||
does not claim that the remaining device/geometry review occurred. Repairs
|
||||
include registered `/` dispatch, action-ID footer/F7 deduplication, explicit
|
||||
lavender and textual selection states, F6/F7 focus restoration, reachable
|
||||
Accueil/section hubs/catalogues, compact localized Android generator chips,
|
||||
hidden internal IDs, generator duration honesty, latest-explicit-MAX display
|
||||
semantics, and exact-context `%MAX`.
|
||||
|
||||
## Implemented contract
|
||||
|
||||
Both clients expose the same root information architecture:
|
||||
|
||||
```text
|
||||
Accueil | Séances | Exercices | Équipements | Statistiques | Synchronisation | Paramètres
|
||||
```
|
||||
|
||||
`Séances` contains the current draft, **Programmer une séance**, manual entry
|
||||
and **Séances effectuées**. Existing body, twelve-month graph, analysis,
|
||||
performance and explicit MAX views are relocated beneath `Statistiques`; no
|
||||
new analytical measure, domain interpretation, name policy, schema or exchange
|
||||
artifact was introduced. Exercise and equipment editing remains contextual to
|
||||
operations already supported. There is no unsupported equipment-definition
|
||||
editor.
|
||||
|
||||
The TUI has one run-scoped controller/event loop and persistent header,
|
||||
content, sidebar when geometry permits, and two-line footer planes. It falls
|
||||
back below 72x20, uses the compact shell from 72x20, displays a sidebar from
|
||||
100x26, and an expanded sidebar from 120x32. The controller owns routes,
|
||||
stable selections, focus, overlays and transient durability guards. Rendering
|
||||
does not own persistence or synchronization. Navigation/redraw performs no
|
||||
database write or sync. Overlay input has priority over aliases; local editors
|
||||
have priority over shell aliases. F6 opens Navigation, F7 opens the same
|
||||
action registry advertised by the footer, and closing an overlay restores its
|
||||
saved focus and stable selection. The intentional shortcut changes recorded
|
||||
by design §13 are `2/F2 Séances effectuées`, `5/F5 Mensurations`, F6
|
||||
Navigation and F7 Actions.
|
||||
|
||||
Search and form fields use a bounded 200-byte UTF-8 buffer plus NUL. Invalid
|
||||
or partial input is rejected without a partial mutation; cursor/delete honor
|
||||
grapheme boundaries; Escape clears a non-empty query before closing it. Lists
|
||||
preserve selection by stable ID. The terminal adapter maps Notcurses events to
|
||||
Trainlog input semantics and consumes RELEASE events. Semantic RGB role tokens
|
||||
are shared in intent with Android; color never carries state alone. Nerd Font
|
||||
symbols are optional and every symbol has a text fallback.
|
||||
|
||||
Android uses a Material 3 drawer and `AppNavigationController` with typed
|
||||
routes, caller-aware inline create/edit return, bounded history and derived
|
||||
drawer selection. It retains the existing durable draft and blocks loss of
|
||||
non-durable forms or a generator preview behind keep/discard resolution.
|
||||
Routes themselves do not call repositories, finalization, schema work or sync.
|
||||
The shell uses local vector resources, a sans-serif hierarchy and actions of at
|
||||
least 48 dp; it has no runtime icon/parser dependency. Sync retains its
|
||||
existing direction confirmation and diagnostic behavior.
|
||||
|
||||
The desktop custom-equipment page reader is additive UI infrastructure only:
|
||||
it is a deterministic bounded, read-only API with 1..128 row capacity,
|
||||
`capacity + 1` lookahead, explicit invalid/corruption errors and no schema
|
||||
migration.
|
||||
|
||||
## Automated evidence
|
||||
|
||||
The final post-repair validation record is
|
||||
`/tmp/trainlog-app-shell-v1/postrepair-validation/postrepair-validation.md`.
|
||||
It recorded:
|
||||
|
||||
- normal `meson test -C build --print-errorlogs`: **46/46 passed**;
|
||||
- ASan/UBSan `meson test -C build-asan --print-errorlogs`: **46/46 passed**;
|
||||
- normal real-PTY shell validation: **100/100 checks passed**;
|
||||
- ASan/UBSan real-PTY shell validation: **100/100 checks passed**, including
|
||||
clean zero-status exit and no temporary-database mutation;
|
||||
- five standalone strict C17 public-header checks passed (`app_shell`,
|
||||
`database`, `terminal`, `theme`, `tui`);
|
||||
- `git diff --check` passed.
|
||||
|
||||
The sanitizer PTY runs use
|
||||
`ASAN_OPTIONS=use_sigaltstack=0:detect_leaks=1:halt_on_error=1` with fail-fast
|
||||
UBSan stack traces. Leak detection remains enabled and no ASan/UBSan report was
|
||||
emitted. The prior default alternate-signal-stack teardown failure was isolated
|
||||
to the installed Notcurses v3.0.17/ASan compatibility boundary: the minimal
|
||||
upstream reproduction records default behavior as 0/10 passes and the
|
||||
upstream-prescribed setting as 10/10 passes. No Trainlog production behavior
|
||||
was changed to accommodate that validation environment.
|
||||
|
||||
The earlier full validation also passed four JSON/import/policy/knowledge
|
||||
validators and strict headers. Android
|
||||
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew testDebugUnitTest assembleDebug`
|
||||
recorded 84 tests, 0 failures, 0 errors and one skipped external
|
||||
`TRAINLOG_ANDROID_V9_FIXTURE` case. The debug APK is
|
||||
`android/app/build/outputs/apk/debug/app-debug.apk`.
|
||||
|
||||
Launch the desktop UI with:
|
||||
|
||||
```bash
|
||||
cd /home/fy59/Documents/trainlog
|
||||
./build/tui/trainlog
|
||||
```
|
||||
|
||||
## Remaining human review
|
||||
|
||||
No emulator was available and the debug APK was not installed or exercised on
|
||||
the user's daily phone. The pending human checklist is therefore explicit:
|
||||
|
||||
- TUI at 72x20, 80x24, 100x25, 100x30, 120x31 and 120x35, including compact
|
||||
Navigation, F6/F7, route/focus restoration, text fallbacks and resize;
|
||||
- Android at 320, 360, 393 and 412 dp, including 100%, 130% and 200% text
|
||||
scale, long text, drawer and scroll restoration;
|
||||
- Android IME forms and durable-draft keep/discard guards;
|
||||
- TalkBack navigation; and
|
||||
- manual connected-device MTP behavior where applicable.
|
||||
|
||||
These unperformed checks are why APP_SHELL_V1 remains
|
||||
`IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`, rather than PASS or FROZEN.
|
||||
|
||||
## Bounded review repairs
|
||||
|
||||
The full TUI delta review initially found two bounded issues: compact resize
|
||||
did not preserve a dispatchable Navigation focus target, and the public
|
||||
AppShell API contract comments were incomplete. The focused repair review
|
||||
verified both corrections: compact Navigation receives sidebar focus after a
|
||||
resize, Enter/F6 and reverse Tab reach the rendered control, overlays restore
|
||||
focus, and the public API now documents bounds, ownership and result behavior.
|
||||
|
||||
The Android review found one bounded callback path that could bypass the root
|
||||
navigation guard when a durable draft already existed. The repair routed
|
||||
production host transitions through `AppNavigationController`; its regression
|
||||
keeps the generator route and transient proposal until an explicit keep or
|
||||
discard decision.
|
||||
|
||||
The independent final audit found one blocking class only: two canonical
|
||||
statements still described the retired per-page `◆ TRAINLOG ◆` plaque. This
|
||||
documentation-only repair synchronizes `docs/current_state.md` and
|
||||
`docs/architecture.md` with the implemented `AndroidAppShell` Material 3
|
||||
`Scaffold`/`TopAppBar` and content host. The bounded documentation-repair
|
||||
review passed (`/tmp/trainlog-app-shell-v1/final-doc-review.md`); no second
|
||||
deep audit is required.
|
||||
|
||||
Normal closeout validation also passed
|
||||
(`/tmp/trainlog-app-shell-v1/closeout-validation/closeout-validation.md`):
|
||||
46/46 desktop tests, the four JSON/import/knowledge/policy validators, Android
|
||||
84 tests with 0 failures, 0 errors and one external-fixture skip, all 35
|
||||
catalog/fixture hashes, the unchanged APK hash, and an empty index. The
|
||||
automated portion is closed. Human visual/accessibility/device review remains
|
||||
the sole outstanding boundary, so APP_SHELL_V1 remains
|
||||
`IMPLEMENTED_AWAITING_VISUAL_REVIEW_2` rather than PASS or FROZEN.
|
||||
72
docs/reviews/app_shell_v1_android.md
Normal file
72
docs/reviews/app_shell_v1_android.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# APP_SHELL_V1 Android retrospective review
|
||||
|
||||
## Scope
|
||||
|
||||
This record covers the settled Android portion of APP_SHELL_V1 only. It records
|
||||
the resulting shell and the evidence available on 10 September 2026; it does
|
||||
not declare the cross-platform APP_SHELL_V1 checkpoint PASS or FROZEN.
|
||||
|
||||
The Android shell now has seven Material 3 drawer destinations: Accueil,
|
||||
Séances, Exercices, Équipements, Statistiques, Synchronisation and Paramètres.
|
||||
Typed routes carry stable detail IDs and caller context. Catalogue-first
|
||||
exercise and equipment workflows supply dedicated detail routes; create and
|
||||
edit retain the existing domain operations. The exercise detail presents
|
||||
persisted profile/zones, available knowledge with explicit uncertainty and
|
||||
sources, compatible equipment, and any local explicit MAX. Equipment detail
|
||||
reports the existing supplied/personal definition or an unknown reference.
|
||||
|
||||
Navigation is owned by `AppNavigationController`. It derives drawer selection
|
||||
from the route, maintains bounded route history, and returns inline creation to
|
||||
its declared caller exactly once. Pending unaccepted generator work and dirty
|
||||
non-durable forms require keep/discard resolution before navigation. Keeping
|
||||
retains the transient values; discard clears only the initiating transient
|
||||
state. Navigation itself makes no repository, synchronization, finalization,
|
||||
schema, format, naming, catalog, knowledge, planned/actual, MAX, or equipment
|
||||
semantic change.
|
||||
|
||||
The active v11 draft and active V3 completed-session exchange remain
|
||||
repository-owned and unchanged. `SaveableStateHolder` keeps small route/list
|
||||
state with a 16-route bound; the root ViewModel retains UI transients through
|
||||
Activity recreation. It serializes neither a domain session nor a generated
|
||||
proposal, so process death resumes only the durable active draft.
|
||||
|
||||
## Findings and repairs
|
||||
|
||||
Initial Android review found that production callbacks in `TrainlogApp` could
|
||||
bypass the root navigation controller. In the concrete
|
||||
`existing_active_draft` generator result, that bypass could leave a dirty
|
||||
proposal without presenting the required keep/discard choice. The repair
|
||||
routed all production host transitions through controller wrappers and added a
|
||||
Robolectric Compose regression that invokes the actual acceptance callback.
|
||||
|
||||
The final bounded callback review closed with that finding repaired. The
|
||||
regression proves that an existing durable draft keeps the route at the
|
||||
generator while a pending guard is installed; choosing keep reaches Séances
|
||||
while retaining preview and raw input, and the initialized SQLite database
|
||||
remains byte-identical. The review also confirmed that only explicit discard
|
||||
clears generator state and that no raw route mutation remains in the production
|
||||
root host.
|
||||
|
||||
The second repair review also confirmed that compact generator choices retain
|
||||
their localized user-facing labels without exposing internal identifiers. Its
|
||||
`%MAX` context shows the chronologically latest explicit compatible MAX for the
|
||||
exact exercise and external-resistance equipment identity; assistance and an
|
||||
incompatible or absent context remain unavailable. This is a read-only user
|
||||
calculator: it makes no recommendation and acceptance persists only the
|
||||
resulting target weight, not a percentage or MAX provenance.
|
||||
|
||||
## Validation and remaining limits
|
||||
|
||||
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew testDebugUnitTest assembleDebug`
|
||||
completed successfully: 84 tests total, 83 passed, 0 failures, 0 errors, and
|
||||
1 skipped. The skipped external historical-fixture case requires
|
||||
`TRAINLOG_ANDROID_V9_FIXTURE`. The debug APK is 12,649,975 bytes with SHA-256
|
||||
`ddb1221d25db5be60eb2261d4b1dcf0fb7446e2a780c67aae862f37c15b7396d`; its nine
|
||||
shell icons and ten referenced catalogue assets matched their expected bytes.
|
||||
`git diff --check -- android` passed during Android validation.
|
||||
|
||||
No emulator was available and the debug APK was not installed or exercised on
|
||||
the connected daily phone. Human review remains pending at 320, 360, 393 and
|
||||
412 dp; 100%, 130% and 200% text scale; IME/drawer/form interactions; long
|
||||
translated and source text; scroll restoration; and TalkBack. These limits
|
||||
prevent a human visual/accessibility completion claim.
|
||||
67
docs/reviews/exercise_alias_sync_canonicalization_audit.md
Normal file
67
docs/reviews/exercise_alias_sync_canonicalization_audit.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Exercise alias sync canonicalization audit
|
||||
|
||||
Date: 2026-09-11
|
||||
|
||||
Scope: actual Android/desktop sync artifacts and the production import/export
|
||||
paths which carry an exercise identity. This is an engineering review artifact,
|
||||
not a format or architecture change.
|
||||
|
||||
## Identity rule
|
||||
|
||||
`exercise_aliases` contains flattened compatibility identities. A source ID is
|
||||
accepted as input identity and resolves to its live canonical exercise before
|
||||
identity/conflict comparison. It must not recreate an exercise row. Catalogue,
|
||||
session, body-zone, and equipment exports publish live canonical exercise IDs;
|
||||
the alias companion is the only output which publishes retired source IDs.
|
||||
|
||||
## Surface matrix
|
||||
|
||||
| Direction / artifact | Producer or consumer | Incoming/working treatment | Exported identity | Audit result |
|
||||
|---|---|---|---|---|
|
||||
| Android → PC `trainlog-exercise-aliases-v1.json` | `tools/import_exercise_aliases.py` | Validates sorted, flattened A→B mappings; moves references from a compatible live A to B and deletes A before persisting the alias | n/a | Canonical; establishes durable identity before other imports |
|
||||
| Android → PC `trainlog-mobile-export-v1/v2/v3.json` exercise catalogue | `tools/import_mobile_export.py:import_exercises` | Resolves a persisted source alias before normalized-name reconciliation and builds raw-input-ID → canonical-ID mapping | n/a | Canonical; no source resurrection |
|
||||
| Android → PC mobile session occurrences | `tools/import_mobile_export.py:import_sessions` and `session_semantically_matches` | Uses the catalogue mapping before occurrence insertion and replay identity/content comparison | n/a | Canonical; preserves session ID and entry ID |
|
||||
| Android → PC `trainlog-exercise-body-zones-v1.json` | `tools/import_exercise_body_zones.py` | Resolves each input ID to one exercise row, groups equal A/B claims by canonical row, rejects divergent claims before writes | n/a | Canonical and order-independent |
|
||||
| Android → PC `trainlog-equipment-associations-v2.json` | `tools/import_equipment_associations.py` | **Previously compared raw incoming A with stored B.** Now resolves both through the flattened alias before corroborating `(session_id, entry_id)`; distinct live canonical IDs and equipment divergence remain conflicts | n/a | Repaired |
|
||||
| PC → Android `trainlog-exercise-aliases-v1.json` | `tools/export_exercise_aliases.py` | Reads the flattened compatibility table | A→B by definition | Correct exception: sole retired-ID publication |
|
||||
| PC → Android `trainlog-pc-catalog-v1.json` | `tools/export_pc_catalog.py` | Reads live `exercises` rows after merge removes A | B | Canonical |
|
||||
| PC → Android `trainlog-pc-mobile-export-v2/v3.json` catalogue and occurrences | `tools/export_pc_mobile.py` | Reads live exercise rows and occurrence FKs joined to those rows | B | Canonical; entry/session identities unchanged |
|
||||
| Bidirectional `trainlog-exercise-body-zones-v1.json` export | `tools/export_exercise_body_zones.py` | Reads body-zone state joined to live exercises | B | Canonical |
|
||||
| Bidirectional `trainlog-equipment-associations-v2.json` export | `tools/export_equipment_associations.py` | Reads occurrences joined to live exercises | B | Canonical; equipment identity unchanged |
|
||||
| PC → Android alias companion | `TrainlogRepository.applyExerciseAliasesJson` | Imports/merges aliases before the catalogue; moves session/draft/metadata references and removes retired source | n/a | Canonical; no source resurrection |
|
||||
| PC → Android catalogue | `TrainlogRepository.applyPcCatalogJson` | Calls `resolveExerciseId` before lookup/reconciliation | n/a | Canonical |
|
||||
| PC → Android mobile session V2/V3 | `TrainlogRepository.applyPcMobileExportJson` | Resolves catalogue and occurrence exercise IDs before row lookup, replay checks, and resumed-MAX checks | n/a | Canonical; entry/session identities unchanged |
|
||||
| PC → Android body-zone companion | `TrainlogRepository.applyExerciseBodyZonesJson` | Resolves input ID before row/baseline lookup | n/a | Canonical |
|
||||
| PC → Android equipment companion V1/V2 | `TrainlogRepository.applyPcEquipmentAssociationsJson` | Resolves input ID before occurrence lookup/corroboration | n/a | Canonical; genuine exercise/equipment conflicts rejected |
|
||||
| Android → PC mobile/session, body-zone, and equipment producers | `TrainlogRepository.buildMobileExportV3Json`, `buildExerciseBodyZonesJson`, and `buildEquipmentAssociationsJson` | Read live exercise rows referenced by catalogue/occurrence FKs | B | Canonical |
|
||||
| Android → PC alias producer | `TrainlogRepository.buildExerciseAliasesJson` | Reads the flattened compatibility table | A→B by definition | Correct exception: sole retired-ID publication |
|
||||
|
||||
## Root cause and repaired precondition
|
||||
|
||||
The desktop equipment V2 consumer looked up the occurrence correctly by stable
|
||||
`(session_id, entry_id)`, but then compared `exists[0] != exercise_id` using raw
|
||||
exercise-ID strings. Its only fallback required a selected V2 mobile proof and
|
||||
the incoming exercise row to have disappeared. Production `trainlog_sync_run()`
|
||||
does not pass that optional proof to this importer, and current Android publishes
|
||||
V3, so a valid persistent A→B alias still failed as `conflit exercice
|
||||
association`.
|
||||
|
||||
The repaired precondition is equality after durable alias resolution. The V2
|
||||
proof fallback remains bounded to historic reconciliation without a persistent
|
||||
alias. No import path changes a session ID, entry ID, equipment ID, occurrence,
|
||||
schema, or artifact version.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
`tests/test_equipment_associations_exchange.py` covers direct A→B acceptance
|
||||
without V2 proof, no source recreation, and rejection of an unrelated live
|
||||
canonical exercise.
|
||||
|
||||
`tui/tests/test_sync_body_zone_wiring.c` drives the real
|
||||
`trainlog_sync_run(TRAINLOG_SYNC_BIDIRECTIONAL)` orchestration against mocked
|
||||
MTP and temporary storage. It uses live
|
||||
`ex_2d488c08-194c-4051-a3c9-34471646c1d3` plus persistent alias
|
||||
`ex_43c7375f-934c-4650-930c-45807d2f2929` → live ID, imports historical A/B
|
||||
session and companion rows, replays the run, checks both stable entries remain
|
||||
bound to B, and verifies that all non-alias outbound artifacts contain B and do
|
||||
not contain A.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
109
docs/tui.md
109
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
121
tests/test_exercise_alias_import.py
Normal file
121
tests/test_exercise_alias_import.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Strict JSON validation regressions for exercise-alias imports."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"import_exercise_aliases", ROOT / "tools/import_exercise_aliases.py")
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def test_boolean_version_rejected():
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
artifact = Path(directory) / "aliases.json"
|
||||
artifact.write_text(json.dumps({
|
||||
"format": "trainlog-exercise-aliases",
|
||||
"version": True,
|
||||
"aliases": [],
|
||||
}), encoding="utf-8")
|
||||
try:
|
||||
MODULE.load(artifact)
|
||||
except ValueError as error:
|
||||
assert str(error) == "artifact alias v1 invalide"
|
||||
return
|
||||
raise AssertionError("boolean alias artifact version was accepted")
|
||||
|
||||
|
||||
def test_merge_preserves_same_session_child_graph():
|
||||
source = "ex_11111111-1111-4111-8111-111111111111"
|
||||
target = "ex_22222222-2222-4222-8222-222222222222"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
database = root / "trainlog.db"
|
||||
artifact = root / "aliases.json"
|
||||
artifact.write_text(json.dumps({
|
||||
"format": "trainlog-exercise-aliases", "version": 1,
|
||||
"aliases": [{"source_exercise_id": source,
|
||||
"canonical_exercise_id": target}],
|
||||
}), encoding="utf-8")
|
||||
connection = sqlite3.connect(database)
|
||||
connection.executescript(f"""
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,
|
||||
tracking_mode TEXT,recording_mode TEXT,data_fields INTEGER);
|
||||
CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE);
|
||||
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,
|
||||
session_row_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
exercise_row_id INTEGER REFERENCES exercises(id),entry_id TEXT UNIQUE,
|
||||
equipment_id TEXT);
|
||||
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,
|
||||
session_exercise_row_id INTEGER REFERENCES session_exercises(id) ON DELETE CASCADE,
|
||||
duration_seconds INTEGER,weight_kg REAL);
|
||||
CREATE TABLE continuous_activity(session_exercise_row_id INTEGER PRIMARY KEY
|
||||
REFERENCES session_exercises(id) ON DELETE CASCADE,duration_seconds INTEGER);
|
||||
CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY
|
||||
REFERENCES session_exercises(id) ON DELETE CASCADE,max_weight_kg REAL);
|
||||
CREATE TABLE exercise_body_zones(exercise_row_id INTEGER REFERENCES exercises(id)
|
||||
ON DELETE CASCADE,zone_id TEXT,role TEXT,PRIMARY KEY(exercise_row_id,zone_id));
|
||||
CREATE TABLE exercise_body_zone_sync(exercise_row_id INTEGER PRIMARY KEY
|
||||
REFERENCES exercises(id) ON DELETE CASCADE,synced_state TEXT);
|
||||
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,
|
||||
canonical_exercise_id TEXT REFERENCES exercises(exercise_id) ON DELETE RESTRICT);
|
||||
INSERT INTO exercises VALUES(1,'{source}','duration','sets',0);
|
||||
INSERT INTO exercises VALUES(2,'{target}','duration','sets',0);
|
||||
INSERT INTO sessions VALUES(1,'se_same_session');
|
||||
INSERT INTO session_exercises VALUES(10,1,1,'sxe_source_set','eq_cable');
|
||||
INSERT INTO session_exercises VALUES(11,1,1,'sxe_source_continuous','eq_treadmill');
|
||||
INSERT INTO session_exercises VALUES(12,1,1,'sxe_source_max','eq_cable');
|
||||
INSERT INTO session_exercises VALUES(13,1,2,'sxe_target','eq_target');
|
||||
INSERT INTO performed_sets VALUES(20,10,45,37.5);
|
||||
INSERT INTO continuous_activity VALUES(11,900);
|
||||
INSERT INTO max_results VALUES(12,82.5);
|
||||
INSERT INTO performed_sets VALUES(21,13,30,20.0);
|
||||
INSERT INTO exercise_body_zones VALUES(1,'arms','primary');
|
||||
INSERT INTO exercise_body_zones VALUES(2,'arms','primary');
|
||||
INSERT INTO exercise_body_zone_sync VALUES(1,'arms|');
|
||||
INSERT INTO exercise_body_zone_sync VALUES(2,'arms|');
|
||||
PRAGMA user_version=12;
|
||||
""")
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
result = subprocess.run(
|
||||
["python3", str(ROOT / "tools/import_exercise_aliases.py"),
|
||||
str(artifact), "--database", str(database)],
|
||||
cwd=ROOT, text=True, capture_output=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
connection = sqlite3.connect(database)
|
||||
try:
|
||||
assert connection.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases"
|
||||
).fetchall() == [(source, target)]
|
||||
assert connection.execute(
|
||||
"SELECT id,exercise_row_id,entry_id,equipment_id FROM session_exercises ORDER BY id"
|
||||
).fetchall() == [
|
||||
(10, 2, "sxe_source_set", "eq_cable"),
|
||||
(11, 2, "sxe_source_continuous", "eq_treadmill"),
|
||||
(12, 2, "sxe_source_max", "eq_cable"),
|
||||
(13, 2, "sxe_target", "eq_target"),
|
||||
]
|
||||
assert connection.execute("SELECT * FROM performed_sets ORDER BY id").fetchall() == [
|
||||
(20, 10, 45, 37.5), (21, 13, 30, 20.0),
|
||||
]
|
||||
assert connection.execute("SELECT * FROM continuous_activity").fetchall() == [(11, 900)]
|
||||
assert connection.execute("SELECT * FROM max_results").fetchall() == [(12, 82.5)]
|
||||
assert connection.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_boolean_version_rejected()
|
||||
test_merge_preserves_same_session_child_graph()
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
38
tools/export_exercise_aliases.py
Normal file
38
tools/export_exercise_aliases.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export the bounded EXERCISE_MERGE_V1 identity companion."""
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
MAX_ALIASES = 4096
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--database", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
con = sqlite3.connect(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] != 12:
|
||||
raise ValueError("schema desktop v12 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
).fetchall()
|
||||
if len(rows) > MAX_ALIASES:
|
||||
raise ValueError("trop d'alias exercice")
|
||||
payload = {"format": "trainlog-exercise-aliases", "version": 1,
|
||||
"aliases": [{"source_exercise_id": row[0],
|
||||
"canonical_exercise_id": row[1]} for row in rows]}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(payload, ensure_ascii=False,
|
||||
separators=(",", ":")), encoding="utf-8")
|
||||
finally:
|
||||
con.close()
|
||||
print(f"EXERCISE_ALIAS_EXPORT=PASS aliases={len(rows)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
103
tools/import_exercise_aliases.py
Normal file
103
tools/import_exercise_aliases.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate and import the bounded EXERCISE_MERGE_V1 identity companion."""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
MAX_BYTES = 1024 * 1024
|
||||
MAX_ALIASES = 4096
|
||||
EXERCISE_ID = re.compile(r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
|
||||
|
||||
def fail(message):
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def load(path):
|
||||
if path.stat().st_size > MAX_BYTES:
|
||||
fail("artifact alias trop volumineux")
|
||||
def unique(pairs):
|
||||
value = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
fail(f"champ JSON dupliqué: {key}")
|
||||
value[key] = item
|
||||
return value
|
||||
value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique)
|
||||
if set(value) != {"format", "version", "aliases"} or \
|
||||
value["format"] != "trainlog-exercise-aliases" or \
|
||||
type(value["version"]) is not int or value["version"] != 1:
|
||||
fail("artifact alias v1 invalide")
|
||||
aliases = value["aliases"]
|
||||
if not isinstance(aliases, list) or len(aliases) > MAX_ALIASES:
|
||||
fail("tableau aliases invalide ou hors borne")
|
||||
result, seen = [], set()
|
||||
for index, item in enumerate(aliases):
|
||||
if not isinstance(item, dict) or set(item) != {"source_exercise_id", "canonical_exercise_id"}:
|
||||
fail(f"aliases[{index}] invalide")
|
||||
source, canonical = item["source_exercise_id"], item["canonical_exercise_id"]
|
||||
if not isinstance(source, str) or not EXERCISE_ID.fullmatch(source) or \
|
||||
not isinstance(canonical, str) or not EXERCISE_ID.fullmatch(canonical):
|
||||
fail(f"aliases[{index}]: identité exercice invalide")
|
||||
if source == canonical or source in seen:
|
||||
fail(f"aliases[{index}]: cycle ou source dupliquée")
|
||||
seen.add(source); result.append((source, canonical))
|
||||
# CONTRACT: deterministic order is part of the companion representation.
|
||||
if result != sorted(result):
|
||||
fail("aliases non triés")
|
||||
sources = {source for source, _ in result}
|
||||
if any(canonical in sources for _, canonical in result):
|
||||
fail("chaîne/cycle alias interdite; mappings aplatis requis")
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("artifact", type=Path)
|
||||
parser.add_argument("--database", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
aliases = load(args.artifact)
|
||||
con = sqlite3.connect(args.database)
|
||||
try:
|
||||
con.execute("PRAGMA foreign_keys=ON")
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] != 12:
|
||||
fail("schema desktop v12 requis")
|
||||
con.execute("BEGIN IMMEDIATE")
|
||||
for source, canonical in aliases:
|
||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||
if target is None:
|
||||
fail(f"cible canonique absente: {canonical}")
|
||||
current = con.execute("SELECT canonical_exercise_id FROM exercise_aliases WHERE source_exercise_id=?", (source,)).fetchone()
|
||||
if current is not None:
|
||||
if current[0] != canonical: fail(f"conflit alias: {source}")
|
||||
continue
|
||||
retired = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (source,)).fetchone()
|
||||
if retired is not None:
|
||||
if retired[1:] != target[1:]: fail(f"profil incompatible: {source}")
|
||||
primary = lambda row_id: con.execute("SELECT zone_id FROM exercise_body_zones WHERE exercise_row_id=? AND role='primary'", (row_id,)).fetchone()
|
||||
sp, tp = primary(retired[0]), primary(target[0])
|
||||
if sp and tp and sp[0] != tp[0]: fail(f"zone primaire incompatible: {source}")
|
||||
chosen = tp[0] if tp else (sp[0] if sp else None)
|
||||
if chosen:
|
||||
con.execute("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?", (target[0], chosen))
|
||||
con.execute("INSERT INTO exercise_body_zones VALUES(?,?,'primary')", (target[0], chosen))
|
||||
con.execute("INSERT OR IGNORE INTO exercise_body_zones SELECT ?,zone_id,'secondary' FROM exercise_body_zones WHERE exercise_row_id=? AND role='secondary' AND zone_id<>COALESCE(?, '')", (target[0], retired[0], chosen))
|
||||
con.execute("UPDATE session_exercises SET exercise_row_id=? WHERE exercise_row_id=?", (target[0], retired[0]))
|
||||
con.execute("DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?)", (retired[0], target[0]))
|
||||
# INVARIANT: earlier sources stay one hop from a live target;
|
||||
# deleting an intermediate canonical can never create a chain.
|
||||
con.execute("UPDATE exercise_aliases SET canonical_exercise_id=? WHERE canonical_exercise_id=?", (canonical, source))
|
||||
con.execute("DELETE FROM exercises WHERE id=?", (retired[0],))
|
||||
con.execute("INSERT INTO exercise_aliases VALUES(?,?)", (source, canonical))
|
||||
con.commit()
|
||||
except Exception:
|
||||
con.rollback(); raise
|
||||
finally:
|
||||
con.close()
|
||||
print(f"EXERCISE_ALIAS_IMPORT=PASS aliases={len(aliases)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
278
tools/validate_app_shell_pty.py
Normal file
278
tools/validate_app_shell_pty.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Exercise APP_SHELL_V1 through a real Notcurses process in an isolated tmux PTY.
|
||||
|
||||
This is deliberately a black-box companion to the C geometry tests. Capture-pane
|
||||
is text, so it proves routes, overlays, footer reservation and interaction; it does
|
||||
not infer terminal cell widths from Python UTF-8 string lengths.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SIZES = ((120, 35), (120, 31), (100, 30), (100, 25), (80, 24), (72, 20))
|
||||
|
||||
|
||||
class Failure(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class PtyValidation:
|
||||
def __init__(self, binary, output):
|
||||
self.binary = binary.resolve()
|
||||
self.output_root = output.resolve()
|
||||
self.output = self.output_root / ("run-" + uuid.uuid4().hex)
|
||||
self.data_home = self.output / "xdg-data"
|
||||
self.socket = f"trainlog-app-shell-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
||||
self.session = "app-shell"
|
||||
self.checks = []
|
||||
self.captures = {}
|
||||
self.height = 35
|
||||
|
||||
def tmux(self, *args, timeout=5, check=True):
|
||||
command = ["tmux", "-L", self.socket, *args]
|
||||
result = subprocess.run(command, text=True, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=timeout)
|
||||
if check and result.returncode:
|
||||
raise Failure("tmux failed: %s\nstderr: %s" % (command, result.stderr.strip()))
|
||||
return result
|
||||
|
||||
def send(self, *keys):
|
||||
self.tmux("send-keys", "-t", self.session, *keys)
|
||||
time.sleep(0.35)
|
||||
|
||||
def literal(self, value):
|
||||
self.tmux("send-keys", "-t", self.session, "-l", value)
|
||||
time.sleep(0.35)
|
||||
|
||||
def capture(self, name):
|
||||
text = self.tmux("capture-pane", "-p", "-t", self.session).stdout
|
||||
path = self.output / f"pty-{name}.txt"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
self.captures[name] = path.name
|
||||
return text
|
||||
|
||||
def require(self, condition, name, detail=""):
|
||||
if not condition:
|
||||
raise Failure("assertion failed: %s%s" % (name, ": " + detail if detail else ""))
|
||||
self.checks.append(name)
|
||||
|
||||
def footer(self, text):
|
||||
rows = text.splitlines()
|
||||
return "\n".join(rows[-2:])
|
||||
|
||||
def check_shell(self, name, text, marker):
|
||||
rows = text.splitlines()
|
||||
self.require(len(rows) == self.height, name + ": terminal height")
|
||||
self.require("TRAINLOG" in rows[0], name + ": header")
|
||||
# CONTRACT: section labels in the persistent sidebar are not evidence
|
||||
# that a destination opened. Only the reserved route header identifies it.
|
||||
route = "Accueil" if marker in ("Votre entraînement", "Accueil") else marker
|
||||
self.require(rows[1].strip().startswith(route), name + ": active route", route)
|
||||
self.require(marker in text, name + ": route marker", marker)
|
||||
self.require("F7 Actions" in self.footer(text), name + ": reserved two-line footer")
|
||||
|
||||
def resize(self, width, height):
|
||||
self.tmux("resize-window", "-t", self.session, "-x", str(width), "-y", str(height))
|
||||
self.height = height
|
||||
time.sleep(0.45)
|
||||
|
||||
def pane_dead(self):
|
||||
return self.tmux("display-message", "-p", "-t", self.session,
|
||||
"#{pane_dead}").stdout.strip()
|
||||
|
||||
def data_snapshot(self):
|
||||
"""Hash initialized data after startup; navigation must not mutate it."""
|
||||
snapshot = {}
|
||||
for path in self.data_home.rglob("*"):
|
||||
if path.is_file():
|
||||
snapshot[str(path.relative_to(self.data_home))] = hashlib.sha256(
|
||||
path.read_bytes()).hexdigest()
|
||||
return snapshot
|
||||
|
||||
@staticmethod
|
||||
def selected_list_line(text):
|
||||
"""Return the content selection marker, excluding the sidebar marker."""
|
||||
for line in text.splitlines():
|
||||
# Content may share a physical row with a sidebar label. Its marker
|
||||
# follows the content padding; the sidebar marker starts in column 1.
|
||||
selected = re.search(r" {2,}(>\s+\S.*)", line)
|
||||
if selected:
|
||||
return selected.group(1).strip()
|
||||
return ""
|
||||
|
||||
def run(self):
|
||||
self.output.mkdir(parents=True, exist_ok=False)
|
||||
self.data_home.mkdir(parents=True, exist_ok=True)
|
||||
if not self.binary.is_file() or not os.access(self.binary, os.X_OK):
|
||||
raise Failure("binary is not executable: %s" % self.binary)
|
||||
# A distinct server/socket and XDG directory make this run unable to touch
|
||||
# a user's tmux server or normal Trainlog database. Do not set HOME.
|
||||
environment = ["env", "XDG_DATA_HOME=" + str(self.data_home),
|
||||
"TERM=xterm-256color", str(self.binary)]
|
||||
self.tmux("new-session", "-d", "-s", self.session, "-x", "120", "-y", "35", *environment,
|
||||
timeout=8)
|
||||
self.tmux("set-option", "-t", self.session, "remain-on-exit", "on")
|
||||
# Notcurses may wait for DA. Send it as terminal input, then normalize any
|
||||
# resulting '?' help route with a physical Escape and explicit Home route.
|
||||
self.literal("\x1b[?1;2c")
|
||||
self.send("Escape")
|
||||
self.send("0")
|
||||
time.sleep(0.6)
|
||||
home = self.capture("home")
|
||||
self.check_shell("home", home, "Votre entraînement")
|
||||
database_before_navigation = self.data_snapshot()
|
||||
|
||||
# All six contract dimensions must retain a usable shell and footer.
|
||||
for width, height in SIZES:
|
||||
self.resize(width, height)
|
||||
view = self.capture(f"size-{width}x{height}")
|
||||
self.check_shell(f"geometry-{width}x{height}", view,
|
||||
"Votre entraînement" if width >= 80 else "Accueil")
|
||||
self.resize(120, 35)
|
||||
|
||||
self.send("?")
|
||||
help_view = self.capture("help")
|
||||
self.require("Aide contextuelle" in help_view, "question opens help")
|
||||
self.send("Escape")
|
||||
self.check_shell("escape closes help", self.capture("after-help-escape"), "Votre entraînement")
|
||||
|
||||
# F6 is a sidebar focus at wide sizes, and an overlay at compact sizes.
|
||||
self.send("F6", "Down", "Down", "Down", "Enter")
|
||||
equipment = self.capture("equipment-from-f6")
|
||||
self.check_shell("F6 navigation opens equipment", equipment, "Équipements")
|
||||
self.send("F6", "Escape")
|
||||
restored = self.capture("equipment-after-f6-escape")
|
||||
self.check_shell("F6 Escape restores equipment", restored, "Équipements")
|
||||
self.send("Tab", "BTab")
|
||||
self.check_shell("Tab and Shift-Tab preserve route", self.capture("equipment-after-tabs"), "Équipements")
|
||||
|
||||
# A sidebar focus survives compact resize as the visible Navigation
|
||||
# control. Its overlay restores both focus and section selection.
|
||||
self.send("F6", "Down", "Down", "Down")
|
||||
self.resize(100, 25)
|
||||
compact_navigation = self.capture("compact-navigation-focus")
|
||||
self.require("> F6 Navigation" in compact_navigation,
|
||||
"sidebar focus becomes visible compact Navigation control")
|
||||
self.send("Enter")
|
||||
self.require("Navigation" in self.capture("compact-navigation-overlay"),
|
||||
"compact Navigation Enter opens overlay")
|
||||
self.send("Escape")
|
||||
restored_navigation = self.capture("compact-navigation-restored")
|
||||
self.require("> F6 Navigation" in restored_navigation,
|
||||
"Navigation Escape restores compact control focus")
|
||||
self.send("BTab")
|
||||
self.require("> F7 Actions" in self.capture("compact-reverse-actions"),
|
||||
"reverse Tab reaches rendered Actions control")
|
||||
self.send("BTab", "BTab", "BTab")
|
||||
self.require("> F6 Navigation" in self.capture("compact-reverse-navigation"),
|
||||
"reverse Tab follows actions, content, search, navigation")
|
||||
self.send("Tab", "Tab")
|
||||
self.resize(120, 35)
|
||||
|
||||
self.send("Down")
|
||||
before_detail = self.capture("equipment-selected")
|
||||
selected_before_detail = self.selected_list_line(before_detail)
|
||||
self.require(bool(selected_before_detail), "equipment selection visible before detail")
|
||||
self.send("Enter")
|
||||
detail = self.capture("equipment-detail")
|
||||
self.require("Équipements / Fiche" in detail, "equipment detail opens")
|
||||
self.send("Escape")
|
||||
back_to_list = self.capture("equipment-detail-back")
|
||||
self.check_shell("detail Escape restores equipment list", back_to_list, "Équipements")
|
||||
self.require("Équipements / Fiche" not in back_to_list,
|
||||
"detail Escape restores list route")
|
||||
self.require(self.selected_list_line(back_to_list) == selected_before_detail,
|
||||
"detail Escape restores equipment selection")
|
||||
|
||||
self.send("F7")
|
||||
actions = self.capture("actions")
|
||||
self.require("Actions" in actions, "F7 opens actions")
|
||||
self.send("Escape")
|
||||
self.check_shell("F7 Escape restores equipment", self.capture("after-actions-escape"), "Équipements")
|
||||
|
||||
# The minimum terminal still supports list search and overlay isolation.
|
||||
self.resize(72, 20)
|
||||
self.send("4")
|
||||
minimum_equipment = self.capture("minimum-equipment")
|
||||
self.check_shell("72x20 equipment", minimum_equipment, "Équipements")
|
||||
self.send("/", "l", "e", "g")
|
||||
filtered = self.capture("equipment-filtered")
|
||||
self.require("leg" in filtered.lower(), "live equipment filter")
|
||||
self.send("Escape")
|
||||
cleared = self.capture("equipment-search-cleared")
|
||||
self.require("Recherche : — [saisie]" in cleared,
|
||||
"first Escape clears query and retains search focus")
|
||||
self.send("Escape")
|
||||
search_closed = self.capture("equipment-search-closed")
|
||||
self.check_shell("second Escape returns equipment list", search_closed, "Équipements")
|
||||
self.require("[saisie]" not in search_closed, "second Escape clears search focus")
|
||||
self.send("?")
|
||||
self.require("Aide contextuelle" in self.capture("minimum-help"), "help at minimum geometry")
|
||||
self.send("3")
|
||||
self.require("Aide contextuelle" in self.capture("help-overlay-isolates-global-route"),
|
||||
"global route ignored while help overlay open")
|
||||
self.resize(100, 25)
|
||||
self.require("Aide contextuelle" in self.capture("help-after-resize"), "resize preserves help overlay")
|
||||
self.send("Escape")
|
||||
self.check_shell("Escape restores equipment after overlay resize",
|
||||
self.capture("after-overlay-resize-escape"), "Équipements")
|
||||
|
||||
self.send("0", "q")
|
||||
time.sleep(0.5)
|
||||
self.require(self.pane_dead() == "1", "q exits pane")
|
||||
self.require(self.tmux("display-message", "-p", "-t", self.session,
|
||||
"#{pane_dead_status}").stdout.strip() == "0", "q exits zero")
|
||||
self.require(self.data_snapshot() == database_before_navigation,
|
||||
"navigation leaves initialized temporary database unchanged")
|
||||
|
||||
def close(self):
|
||||
# This is our unique socket, never the caller's tmux server.
|
||||
self.tmux("kill-server", check=False)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", type=Path, default=ROOT / "build/tui/trainlog")
|
||||
parser.add_argument("--output", type=Path, default=Path("/tmp/trainlog-app-shell-v1"))
|
||||
args = parser.parse_args()
|
||||
runner = PtyValidation(args.binary, args.output)
|
||||
result = {"binary": str(args.binary), "output": str(runner.output), "checks": [], "captures": {}}
|
||||
try:
|
||||
runner.run()
|
||||
result.update(status="PASS", checks=runner.checks, captures=runner.captures)
|
||||
print("APP_SHELL_PTY=PASS")
|
||||
print("ARTIFACT_DIR=" + str(runner.output))
|
||||
for check in runner.checks:
|
||||
print("CHECK=" + check)
|
||||
except (Failure, subprocess.TimeoutExpired, OSError) as error:
|
||||
result.update(status="FAIL", error=str(error), checks=runner.checks, captures=runner.captures)
|
||||
print("APP_SHELL_PTY=FAIL: " + str(error), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
runner.output.mkdir(parents=True, exist_ok=True)
|
||||
(runner.output / "results.json").write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8")
|
||||
(runner.output / "pty-script.md").write_text(
|
||||
"# APP_SHELL_V1 PTY evidence\n\n"
|
||||
"`validate_app_shell_pty.py` starts the supplied binary in a unique tmux "
|
||||
"server with a temporary `XDG_DATA_HOME`. Captures are text snapshots of "
|
||||
"the actual pane. They verify route and overlay behavior and the reserved "
|
||||
"footer content at each requested geometry; they do not attempt to prove "
|
||||
"terminal-cell width from UTF-8 byte or Python-string length. The C geometry "
|
||||
"tests provide that complementary cell-bound coverage.\n",
|
||||
encoding="utf-8")
|
||||
runner.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
304
tui/include/trainlog/app_shell.h
Normal file
304
tui/include/trainlog/app_shell.h
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
#ifndef TRAINLOG_APP_SHELL_H
|
||||
#define TRAINLOG_APP_SHELL_H
|
||||
|
||||
/**
|
||||
* @file app_shell.h
|
||||
* @brief Trainlog-owned state contracts for the persistent desktop shell.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#define TRAINLOG_SHELL_MIN_COLUMNS 72
|
||||
#define TRAINLOG_SHELL_MIN_ROWS 20
|
||||
#define TRAINLOG_SHELL_SIDEBAR_COLUMNS 22
|
||||
#define TRAINLOG_SHELL_OVERLAY_LIMIT 3U
|
||||
#define TRAINLOG_SHELL_ROUTE_DEPTH 8U
|
||||
#define TRAINLOG_SHELL_ACTION_LIMIT 24U
|
||||
#define TRAINLOG_SHELL_SEARCH_CAPACITY 201U
|
||||
#define TRAINLOG_SHELL_STABLE_ID_CAPACITY 80U
|
||||
|
||||
typedef struct TrainlogRect {
|
||||
int y;
|
||||
int x;
|
||||
int height;
|
||||
int width;
|
||||
} TrainlogRect;
|
||||
|
||||
typedef struct TrainlogShellLayout {
|
||||
int rows;
|
||||
int columns;
|
||||
bool usable;
|
||||
bool sidebar_visible;
|
||||
bool sidebar_expanded;
|
||||
TrainlogRect header;
|
||||
TrainlogRect sidebar;
|
||||
TrainlogRect separator;
|
||||
TrainlogRect content;
|
||||
TrainlogRect footer;
|
||||
} TrainlogShellLayout;
|
||||
|
||||
typedef enum TrainlogAppRoute {
|
||||
TRAINLOG_ROUTE_HOME = 0,
|
||||
TRAINLOG_ROUTE_SESSIONS,
|
||||
TRAINLOG_ROUTE_SESSION_CURRENT,
|
||||
TRAINLOG_ROUTE_SESSION_GENERATOR,
|
||||
TRAINLOG_ROUTE_SESSION_MANUAL,
|
||||
TRAINLOG_ROUTE_SESSIONS_COMPLETED,
|
||||
TRAINLOG_ROUTE_SESSION_DETAIL,
|
||||
TRAINLOG_ROUTE_EXERCISES,
|
||||
TRAINLOG_ROUTE_EXERCISE_DETAIL,
|
||||
TRAINLOG_ROUTE_EXERCISE_KNOWLEDGE,
|
||||
TRAINLOG_ROUTE_EXERCISE_PERFORMANCE,
|
||||
TRAINLOG_ROUTE_EXERCISE_MAX,
|
||||
TRAINLOG_ROUTE_EQUIPMENT,
|
||||
TRAINLOG_ROUTE_EQUIPMENT_DETAIL,
|
||||
TRAINLOG_ROUTE_STATS,
|
||||
TRAINLOG_ROUTE_STATS_EXERCISE,
|
||||
TRAINLOG_ROUTE_BODY,
|
||||
TRAINLOG_ROUTE_BODY_DETAIL,
|
||||
TRAINLOG_ROUTE_BODY_METRIC,
|
||||
TRAINLOG_ROUTE_BODY_TRENDS,
|
||||
TRAINLOG_ROUTE_BODY_GLOBAL,
|
||||
TRAINLOG_ROUTE_BODY_ANALYTICS,
|
||||
TRAINLOG_ROUTE_MAX,
|
||||
TRAINLOG_ROUTE_SYNC,
|
||||
TRAINLOG_ROUTE_SETTINGS,
|
||||
TRAINLOG_ROUTE_COUNT
|
||||
} TrainlogAppRoute;
|
||||
|
||||
typedef enum TrainlogFocusTarget {
|
||||
TRAINLOG_FOCUS_NAVIGATION = 0,
|
||||
TRAINLOG_FOCUS_SEARCH,
|
||||
TRAINLOG_FOCUS_CONTENT,
|
||||
TRAINLOG_FOCUS_EDITOR,
|
||||
TRAINLOG_FOCUS_ACTIONS,
|
||||
TRAINLOG_FOCUS_OVERLAY
|
||||
} TrainlogFocusTarget;
|
||||
|
||||
typedef enum TrainlogIntent {
|
||||
TRAINLOG_INTENT_NONE = 0,
|
||||
TRAINLOG_INTENT_OPEN_ROUTE,
|
||||
TRAINLOG_INTENT_OPEN_NAVIGATION,
|
||||
TRAINLOG_INTENT_OPEN_ACTIONS,
|
||||
TRAINLOG_INTENT_OPEN_HELP,
|
||||
TRAINLOG_INTENT_OPEN_SEARCH,
|
||||
TRAINLOG_INTENT_PRIMARY,
|
||||
TRAINLOG_INTENT_BACK,
|
||||
TRAINLOG_INTENT_QUIT,
|
||||
TRAINLOG_INTENT_DISCARD,
|
||||
TRAINLOG_INTENT_SAVE,
|
||||
TRAINLOG_INTENT_REFRESH
|
||||
} TrainlogIntent;
|
||||
|
||||
typedef struct TrainlogRouteTarget {
|
||||
TrainlogAppRoute route;
|
||||
char stable_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
||||
} TrainlogRouteTarget;
|
||||
|
||||
typedef struct TrainlogNavigationState {
|
||||
TrainlogRouteTarget current;
|
||||
TrainlogRouteTarget back[TRAINLOG_SHELL_ROUTE_DEPTH];
|
||||
size_t back_count;
|
||||
} TrainlogNavigationState;
|
||||
|
||||
typedef struct TrainlogAction {
|
||||
const char *identifier;
|
||||
int key;
|
||||
const char *label;
|
||||
bool enabled;
|
||||
unsigned priority;
|
||||
TrainlogIntent intent;
|
||||
TrainlogAppRoute route;
|
||||
} TrainlogAction;
|
||||
|
||||
typedef struct TrainlogActionModel {
|
||||
TrainlogAction items[TRAINLOG_SHELL_ACTION_LIMIT];
|
||||
size_t count;
|
||||
} TrainlogActionModel;
|
||||
|
||||
typedef enum TrainlogOverlayType {
|
||||
TRAINLOG_OVERLAY_NAVIGATION = 0,
|
||||
TRAINLOG_OVERLAY_ACTIONS,
|
||||
TRAINLOG_OVERLAY_HELP,
|
||||
TRAINLOG_OVERLAY_CONFIRMATION,
|
||||
TRAINLOG_OVERLAY_RECOVERY,
|
||||
TRAINLOG_OVERLAY_EXERCISE_MERGE
|
||||
} TrainlogOverlayType;
|
||||
|
||||
typedef struct TrainlogOverlay {
|
||||
TrainlogOverlayType type;
|
||||
TrainlogFocusTarget restore_focus;
|
||||
char restore_stable_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
||||
size_t selected;
|
||||
} TrainlogOverlay;
|
||||
|
||||
typedef struct TrainlogOverlayStack {
|
||||
TrainlogOverlay items[TRAINLOG_SHELL_OVERLAY_LIMIT];
|
||||
size_t count;
|
||||
} TrainlogOverlayStack;
|
||||
|
||||
typedef struct TrainlogListState {
|
||||
char selected_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
||||
size_t selected_index;
|
||||
size_t viewport_start;
|
||||
size_t visible_rows;
|
||||
size_t item_count;
|
||||
bool total_known;
|
||||
bool more_available;
|
||||
} TrainlogListState;
|
||||
|
||||
typedef struct TrainlogSearchState {
|
||||
char text[TRAINLOG_SHELL_SEARCH_CAPACITY];
|
||||
size_t bytes;
|
||||
size_t cursor;
|
||||
bool open;
|
||||
bool focused;
|
||||
bool capacity_error;
|
||||
} TrainlogSearchState;
|
||||
|
||||
typedef enum TrainlogFormResult {
|
||||
TRAINLOG_FORM_IGNORED = 0,
|
||||
TRAINLOG_FORM_EDITED,
|
||||
TRAINLOG_FORM_SUBMIT,
|
||||
TRAINLOG_FORM_CANCEL,
|
||||
TRAINLOG_FORM_NEXT,
|
||||
TRAINLOG_FORM_PREVIOUS,
|
||||
TRAINLOG_FORM_OPEN_NAVIGATION,
|
||||
TRAINLOG_FORM_OPEN_ACTIONS
|
||||
} TrainlogFormResult;
|
||||
|
||||
typedef struct TrainlogFormField {
|
||||
char text[TRAINLOG_SHELL_SEARCH_CAPACITY];
|
||||
size_t bytes;
|
||||
size_t cursor;
|
||||
bool active;
|
||||
bool capacity_error;
|
||||
} TrainlogFormField;
|
||||
|
||||
typedef struct TrainlogDurabilityState {
|
||||
bool session_draft;
|
||||
bool session_dirty;
|
||||
bool generator_configuration_dirty;
|
||||
bool generator_preview;
|
||||
bool generator_preview_dirty;
|
||||
bool transient_form_dirty;
|
||||
} TrainlogDurabilityState;
|
||||
|
||||
typedef enum TrainlogLeaveDecision {
|
||||
TRAINLOG_LEAVE_ALLOW = 0,
|
||||
TRAINLOG_LEAVE_CONFIRM_KEEP,
|
||||
TRAINLOG_LEAVE_CONFIRM_DISCARD
|
||||
} TrainlogLeaveDecision;
|
||||
|
||||
/* WHY: one geometry policy keeps renderers free of terminal-specific bounds.
|
||||
* CONTRACT: NULL layout is ignored; otherwise output is fully reset. Usable
|
||||
* requires at least 72x20; sidebar thresholds are 100x26 and 120x32. */
|
||||
void trainlog_shell_layout_compute(int columns, int rows,
|
||||
TrainlogShellLayout *layout);
|
||||
/* CONTRACT: returns an all-zero rectangle for NULL/unusable layout; preferred
|
||||
* nonpositive/oversize dimensions are clamped. Result is a value copy. */
|
||||
TrainlogRect trainlog_shell_overlay_rect(const TrainlogShellLayout *layout,
|
||||
int preferred_width,
|
||||
int preferred_height);
|
||||
/* CONTRACT: false for NULL or geometrically invalid rectangles; no mutation. */
|
||||
bool trainlog_shell_rect_contains(const TrainlogRect *outer,
|
||||
const TrainlogRect *inner);
|
||||
|
||||
/* CONTRACT: NULL is ignored. init selects Home. open copies at most 79 bytes
|
||||
* of stable_id (NULL means empty), keeps at most eight back entries, and
|
||||
* returns false only for NULL navigation. back mutates only when history exists. */
|
||||
void trainlog_navigation_init(TrainlogNavigationState *navigation);
|
||||
bool trainlog_navigation_open(TrainlogNavigationState *navigation,
|
||||
TrainlogAppRoute route,
|
||||
const char *stable_id);
|
||||
bool trainlog_navigation_back(TrainlogNavigationState *navigation);
|
||||
|
||||
/* CONTRACT: clear ignores NULL. add copies the action struct, but borrows its
|
||||
* identifier and label strings for the model lifetime; it rejects NULL strings,
|
||||
* duplicate stable identifiers and the 24-action bound. Lookup returns a
|
||||
* borrowed item or NULL. Priority
|
||||
* ordering is enabled actions by ascending priority, then insertion order. */
|
||||
void trainlog_actions_clear(TrainlogActionModel *model);
|
||||
bool trainlog_actions_add(TrainlogActionModel *model,
|
||||
const TrainlogAction *action);
|
||||
const TrainlogAction *trainlog_actions_find_key(const TrainlogActionModel *model,
|
||||
int key);
|
||||
const TrainlogAction *trainlog_actions_at_priority(const TrainlogActionModel *model,
|
||||
size_t position);
|
||||
|
||||
/* CONTRACT: init ignores NULL. push copies up to 79 stable-ID bytes and fails
|
||||
* for NULL/full (three overlays) stacks. top borrows an item until the stack
|
||||
* mutates. pop fails when empty; on success it mutates the stack and writes
|
||||
* only non-NULL outputs, whose stable-ID buffer must hold 80 bytes. */
|
||||
void trainlog_overlays_init(TrainlogOverlayStack *stack);
|
||||
bool trainlog_overlays_push(TrainlogOverlayStack *stack,
|
||||
TrainlogOverlayType type,
|
||||
TrainlogFocusTarget restore_focus,
|
||||
const char *restore_stable_id);
|
||||
const TrainlogOverlay *trainlog_overlays_top(const TrainlogOverlayStack *stack);
|
||||
bool trainlog_overlays_pop(TrainlogOverlayStack *stack,
|
||||
TrainlogFocusTarget *restore_focus,
|
||||
char restore_stable_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY]);
|
||||
|
||||
/* CONTRACT: list state never owns stable_ids: callers keep every supplied
|
||||
* string and array alive through the matching move/set operation. set accepts
|
||||
* zero items (clears selection), treats visible_rows zero as one, and copies a
|
||||
* selected stable ID with the 79-byte truncation rule. move ignores NULL,
|
||||
* empty, or out-of-range state and clamps deltas at list bounds. */
|
||||
void trainlog_list_init(TrainlogListState *list);
|
||||
void trainlog_list_set_items(TrainlogListState *list,
|
||||
const char *const *stable_ids,
|
||||
size_t count,
|
||||
size_t visible_rows,
|
||||
bool total_known,
|
||||
bool more_available);
|
||||
void trainlog_list_move(TrainlogListState *list,
|
||||
const char *const *stable_ids,
|
||||
int delta);
|
||||
|
||||
/* CONTRACT: value is borrowed and NULL means empty. output must be non-NULL
|
||||
* with nonzero capacity or is untouched; otherwise it is NUL-terminated.
|
||||
* maximum_cells <= 0 yields empty output. Invalid UTF-8 is copied bytewise;
|
||||
* output stays complete UTF-8 where possible and uses a final ellipsis when
|
||||
* truncated by display cells or capacity. */
|
||||
void trainlog_shell_format_list_label(const char *value,
|
||||
int maximum_cells,
|
||||
char *output,
|
||||
size_t output_capacity);
|
||||
|
||||
/* CONTRACT: search/form text is owned inline storage, capped at 200 UTF-8
|
||||
* bytes plus NUL. init ignores NULL; insert borrows utf8 for this call and
|
||||
* rejects NULL, invalid UTF-8, overflow, and partial codepoints without
|
||||
* mutation except capacity_error. Cursor operations ignore NULL and preserve
|
||||
* UTF-8 boundaries. Escape clears nonempty text (false) or closes empty input
|
||||
* (true). Form init copies/truncates initial input; handle ignores NULL and
|
||||
* returns the exact result enum, including F6/F7 shell requests. */
|
||||
void trainlog_search_init(TrainlogSearchState *search);
|
||||
bool trainlog_search_insert(TrainlogSearchState *search,
|
||||
const char *utf8,
|
||||
size_t length);
|
||||
bool trainlog_search_backspace(TrainlogSearchState *search);
|
||||
void trainlog_search_home(TrainlogSearchState *search);
|
||||
void trainlog_search_end(TrainlogSearchState *search);
|
||||
void trainlog_search_left(TrainlogSearchState *search);
|
||||
void trainlog_search_right(TrainlogSearchState *search);
|
||||
/* Returns true when Escape closes the empty editor; non-empty Escape clears. */
|
||||
bool trainlog_search_escape(TrainlogSearchState *search);
|
||||
|
||||
void trainlog_form_init(TrainlogFormField *field, const char *initial_value);
|
||||
TrainlogFormResult trainlog_form_handle(TrainlogFormField *field, int key);
|
||||
|
||||
/* CONTRACT: title returns a borrowed static string, including a fallback for
|
||||
* invalid routes. section returns a route value without mutation. Leave
|
||||
* decision reads state only (NULL allows leaving); explicit discard selects
|
||||
* discard confirmation when transient durable state exists. discard ignores
|
||||
* NULL and clears only transient in-memory flags: it performs no DB write. */
|
||||
const char *trainlog_route_title(TrainlogAppRoute route);
|
||||
TrainlogAppRoute trainlog_route_section(TrainlogAppRoute route);
|
||||
TrainlogLeaveDecision trainlog_shell_leave_decision(
|
||||
const TrainlogDurabilityState *state,
|
||||
bool explicit_discard);
|
||||
void trainlog_shell_discard_transient(TrainlogDurabilityState *state);
|
||||
|
||||
#endif
|
||||
|
|
@ -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 */
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ trainlog_core_dep = declare_dependency(
|
|||
|
||||
trainlog_tui_sources = files(
|
||||
'src/main.c',
|
||||
'src/app_shell.c',
|
||||
'src/sync_screen_action.c',
|
||||
'src/terminal.c',
|
||||
'src/theme.c',
|
||||
|
|
@ -542,6 +543,12 @@ test(
|
|||
args: [meson.project_source_root() / 'tests/test_body_zone_catalog.py'],
|
||||
)
|
||||
|
||||
test(
|
||||
'exercise_alias_import_validation',
|
||||
python3_trainlog_tests,
|
||||
args: [meson.project_source_root() / 'tests/test_exercise_alias_import.py'],
|
||||
)
|
||||
|
||||
test_sync_direction = executable(
|
||||
'test_sync_direction',
|
||||
'tests/test_sync_direction.c',
|
||||
|
|
@ -551,6 +558,15 @@ test_sync_direction = executable(
|
|||
|
||||
test('sync_direction', test_sync_direction)
|
||||
|
||||
test_sync_body_zone_wiring = executable(
|
||||
'test_sync_body_zone_wiring',
|
||||
'tests/test_sync_body_zone_wiring.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
|
||||
test('sync_body_zone_wiring', test_sync_body_zone_wiring)
|
||||
|
||||
test_sync_history = executable(
|
||||
'test_sync_history',
|
||||
'tests/test_sync_history.c',
|
||||
|
|
@ -606,7 +622,7 @@ test_terminal_input = executable(
|
|||
'tests/test_terminal_input.c',
|
||||
'src/terminal.c',
|
||||
include_directories: trainlog_include,
|
||||
dependencies: notcurses_dep,
|
||||
dependencies: [notcurses_dep, utf8proc_dep],
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
|
||||
|
|
@ -618,6 +634,7 @@ test(
|
|||
test_tui_workflows = executable(
|
||||
'test_tui_workflows',
|
||||
'tests/test_tui_workflows.c',
|
||||
'src/app_shell.c',
|
||||
'src/theme.c',
|
||||
'src/sync_screen_action.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
|
|
@ -625,3 +642,13 @@ test_tui_workflows = executable(
|
|||
)
|
||||
|
||||
test('tui_workflows', test_tui_workflows)
|
||||
|
||||
test_app_shell = executable(
|
||||
'test_app_shell',
|
||||
'tests/test_app_shell.c',
|
||||
'src/app_shell.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
|
||||
test('app_shell', test_app_shell)
|
||||
|
|
|
|||
569
tui/src/app_shell.c
Normal file
569
tui/src/app_shell.c
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
/**
|
||||
* @file app_shell.c
|
||||
* @brief Pure state machinery shared by the Notcurses application shell.
|
||||
*/
|
||||
|
||||
#include "trainlog/app_shell.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <utf8proc.h>
|
||||
|
||||
#include "trainlog/terminal.h"
|
||||
|
||||
static void copy_id(char output[TRAINLOG_SHELL_STABLE_ID_CAPACITY],
|
||||
const char *value)
|
||||
{
|
||||
(void)snprintf(output, TRAINLOG_SHELL_STABLE_ID_CAPACITY, "%s",
|
||||
value != NULL ? value : "");
|
||||
}
|
||||
|
||||
void trainlog_shell_layout_compute(int columns, int rows,
|
||||
TrainlogShellLayout *layout)
|
||||
{
|
||||
int content_x;
|
||||
if (layout == NULL) return;
|
||||
(void)memset(layout, 0, sizeof(*layout));
|
||||
layout->rows = rows;
|
||||
layout->columns = columns;
|
||||
layout->usable = columns >= TRAINLOG_SHELL_MIN_COLUMNS &&
|
||||
rows >= TRAINLOG_SHELL_MIN_ROWS;
|
||||
if (!layout->usable) return;
|
||||
|
||||
/* CONTRACT: chrome owns four rows on every valid geometry. Content and
|
||||
* overlays can therefore never clip or overwrite the footer. */
|
||||
layout->header = (TrainlogRect){0, 0, 2, columns};
|
||||
layout->footer = (TrainlogRect){rows - 2, 0, 2, columns};
|
||||
layout->sidebar_visible = columns >= 100 && rows >= 26;
|
||||
layout->sidebar_expanded = columns >= 120 && rows >= 32;
|
||||
content_x = layout->sidebar_visible ? 23 : 0;
|
||||
if (layout->sidebar_visible) {
|
||||
layout->sidebar = (TrainlogRect){2, 0, rows - 4,
|
||||
TRAINLOG_SHELL_SIDEBAR_COLUMNS};
|
||||
layout->separator = (TrainlogRect){2, 22, rows - 4, 1};
|
||||
}
|
||||
layout->content = (TrainlogRect){2, content_x, rows - 4,
|
||||
columns - content_x};
|
||||
}
|
||||
|
||||
TrainlogRect trainlog_shell_overlay_rect(const TrainlogShellLayout *layout,
|
||||
int preferred_width,
|
||||
int preferred_height)
|
||||
{
|
||||
TrainlogRect result = {0, 0, 0, 0};
|
||||
int maximum_width;
|
||||
int maximum_height;
|
||||
if (layout == NULL || !layout->usable) return result;
|
||||
maximum_width = layout->columns >= 100 ? 80 : layout->columns;
|
||||
if (layout->columns == 100 && maximum_width > 74) maximum_width = 74;
|
||||
if (layout->columns == 80 && maximum_width > 78) maximum_width = 78;
|
||||
maximum_height = layout->content.height;
|
||||
if (layout->columns >= 120 && maximum_height > 29) maximum_height = 29;
|
||||
if (layout->columns == 100 && maximum_height > 24) maximum_height = 24;
|
||||
result.width = preferred_width > 0 && preferred_width < maximum_width
|
||||
? preferred_width : maximum_width;
|
||||
result.height = preferred_height > 0 && preferred_height < maximum_height
|
||||
? preferred_height : maximum_height;
|
||||
result.x = (layout->columns - result.width) / 2;
|
||||
result.y = layout->content.y +
|
||||
(layout->content.height - result.height) / 2;
|
||||
return result;
|
||||
}
|
||||
|
||||
bool trainlog_shell_rect_contains(const TrainlogRect *outer,
|
||||
const TrainlogRect *inner)
|
||||
{
|
||||
return outer != NULL && inner != NULL && inner->height >= 0 &&
|
||||
inner->width >= 0 && inner->y >= outer->y && inner->x >= outer->x &&
|
||||
inner->y + inner->height <= outer->y + outer->height &&
|
||||
inner->x + inner->width <= outer->x + outer->width;
|
||||
}
|
||||
|
||||
void trainlog_navigation_init(TrainlogNavigationState *navigation)
|
||||
{
|
||||
if (navigation == NULL) return;
|
||||
(void)memset(navigation, 0, sizeof(*navigation));
|
||||
navigation->current.route = TRAINLOG_ROUTE_HOME;
|
||||
}
|
||||
|
||||
bool trainlog_navigation_open(TrainlogNavigationState *navigation,
|
||||
TrainlogAppRoute route,
|
||||
const char *stable_id)
|
||||
{
|
||||
if (navigation == NULL) return false;
|
||||
if (navigation->current.route == route &&
|
||||
strcmp(navigation->current.stable_id, stable_id != NULL ? stable_id : "") == 0)
|
||||
return true;
|
||||
if (navigation->back_count == TRAINLOG_SHELL_ROUTE_DEPTH) {
|
||||
(void)memmove(&navigation->back[0], &navigation->back[1],
|
||||
(TRAINLOG_SHELL_ROUTE_DEPTH - 1U) * sizeof(navigation->back[0]));
|
||||
--navigation->back_count;
|
||||
}
|
||||
navigation->back[navigation->back_count++] = navigation->current;
|
||||
navigation->current.route = route;
|
||||
copy_id(navigation->current.stable_id, stable_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool trainlog_navigation_back(TrainlogNavigationState *navigation)
|
||||
{
|
||||
if (navigation == NULL || navigation->back_count == 0U) return false;
|
||||
navigation->current = navigation->back[--navigation->back_count];
|
||||
return true;
|
||||
}
|
||||
|
||||
void trainlog_actions_clear(TrainlogActionModel *model)
|
||||
{
|
||||
if (model != NULL) (void)memset(model, 0, sizeof(*model));
|
||||
}
|
||||
|
||||
bool trainlog_actions_add(TrainlogActionModel *model,
|
||||
const TrainlogAction *action)
|
||||
{
|
||||
size_t index;
|
||||
if (model == NULL || action == NULL || action->identifier == NULL ||
|
||||
action->label == NULL || model->count >= TRAINLOG_SHELL_ACTION_LIMIT)
|
||||
return false;
|
||||
/* INVARIANT: identifier is the action's stable identity. Keeping it unique
|
||||
* prevents the footer and F7 registry from advertising the same semantic
|
||||
* action twice when contextual and shell-wide contributors overlap. */
|
||||
for (index = 0U; index < model->count; ++index)
|
||||
if (strcmp(model->items[index].identifier, action->identifier) == 0)
|
||||
return false;
|
||||
model->items[model->count++] = *action;
|
||||
return true;
|
||||
}
|
||||
|
||||
const TrainlogAction *trainlog_actions_find_key(const TrainlogActionModel *model,
|
||||
int key)
|
||||
{
|
||||
size_t index;
|
||||
if (model == NULL) return NULL;
|
||||
for (index = 0U; index < model->count; ++index) {
|
||||
if (model->items[index].enabled && model->items[index].key == key)
|
||||
return &model->items[index];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const TrainlogAction *trainlog_actions_at_priority(const TrainlogActionModel *model,
|
||||
size_t position)
|
||||
{
|
||||
const TrainlogAction *result = NULL;
|
||||
size_t rank;
|
||||
size_t index;
|
||||
if (model == NULL) return NULL;
|
||||
for (rank = 0U; rank <= position; ++rank) {
|
||||
result = NULL;
|
||||
for (index = 0U; index < model->count; ++index) {
|
||||
const TrainlogAction *candidate = &model->items[index];
|
||||
size_t prior = 0U;
|
||||
size_t other;
|
||||
if (!candidate->enabled) continue;
|
||||
for (other = 0U; other < model->count; ++other) {
|
||||
if (!model->items[other].enabled) continue;
|
||||
if (model->items[other].priority < candidate->priority ||
|
||||
(model->items[other].priority == candidate->priority &&
|
||||
other < index)) ++prior;
|
||||
}
|
||||
if (prior == rank) { result = candidate; break; }
|
||||
}
|
||||
if (result == NULL || rank == position) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void trainlog_overlays_init(TrainlogOverlayStack *stack)
|
||||
{
|
||||
if (stack != NULL) (void)memset(stack, 0, sizeof(*stack));
|
||||
}
|
||||
|
||||
bool trainlog_overlays_push(TrainlogOverlayStack *stack,
|
||||
TrainlogOverlayType type,
|
||||
TrainlogFocusTarget restore_focus,
|
||||
const char *restore_stable_id)
|
||||
{
|
||||
TrainlogOverlay *overlay;
|
||||
if (stack == NULL || stack->count >= TRAINLOG_SHELL_OVERLAY_LIMIT) return false;
|
||||
overlay = &stack->items[stack->count++];
|
||||
(void)memset(overlay, 0, sizeof(*overlay));
|
||||
overlay->type = type;
|
||||
overlay->restore_focus = restore_focus;
|
||||
copy_id(overlay->restore_stable_id, restore_stable_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const TrainlogOverlay *trainlog_overlays_top(const TrainlogOverlayStack *stack)
|
||||
{
|
||||
return stack != NULL && stack->count > 0U
|
||||
? &stack->items[stack->count - 1U] : NULL;
|
||||
}
|
||||
|
||||
bool trainlog_overlays_pop(TrainlogOverlayStack *stack,
|
||||
TrainlogFocusTarget *restore_focus,
|
||||
char restore_stable_id[TRAINLOG_SHELL_STABLE_ID_CAPACITY])
|
||||
{
|
||||
TrainlogOverlay *overlay;
|
||||
if (stack == NULL || stack->count == 0U) return false;
|
||||
overlay = &stack->items[--stack->count];
|
||||
if (restore_focus != NULL) *restore_focus = overlay->restore_focus;
|
||||
if (restore_stable_id != NULL)
|
||||
copy_id(restore_stable_id, overlay->restore_stable_id);
|
||||
(void)memset(overlay, 0, sizeof(*overlay));
|
||||
return true;
|
||||
}
|
||||
|
||||
void trainlog_list_init(TrainlogListState *list)
|
||||
{
|
||||
if (list != NULL) (void)memset(list, 0, sizeof(*list));
|
||||
}
|
||||
|
||||
void trainlog_list_set_items(TrainlogListState *list,
|
||||
const char *const *stable_ids,
|
||||
size_t count,
|
||||
size_t visible_rows,
|
||||
bool total_known,
|
||||
bool more_available)
|
||||
{
|
||||
size_t index;
|
||||
size_t selected = 0U;
|
||||
bool found = false;
|
||||
if (list == NULL) return;
|
||||
for (index = 0U; index < count && stable_ids != NULL; ++index) {
|
||||
if (strcmp(list->selected_id, stable_ids[index]) == 0) {
|
||||
selected = index; found = true; break;
|
||||
}
|
||||
}
|
||||
if (!found && count > 0U) {
|
||||
selected = list->selected_index < count ? list->selected_index : count - 1U;
|
||||
copy_id(list->selected_id, stable_ids[selected]);
|
||||
} else if (count == 0U) {
|
||||
list->selected_id[0] = '\0'; selected = 0U;
|
||||
}
|
||||
list->selected_index = selected;
|
||||
list->item_count = count;
|
||||
list->visible_rows = visible_rows > 0U ? visible_rows : 1U;
|
||||
list->total_known = total_known;
|
||||
list->more_available = more_available;
|
||||
if (selected < list->viewport_start) list->viewport_start = selected;
|
||||
if (selected >= list->viewport_start + list->visible_rows)
|
||||
list->viewport_start = selected - list->visible_rows + 1U;
|
||||
if (count <= list->visible_rows) list->viewport_start = 0U;
|
||||
else if (list->viewport_start > count - list->visible_rows)
|
||||
list->viewport_start = count - list->visible_rows;
|
||||
}
|
||||
|
||||
void trainlog_list_move(TrainlogListState *list,
|
||||
const char *const *stable_ids,
|
||||
int delta)
|
||||
{
|
||||
size_t next;
|
||||
if (list == NULL || stable_ids == NULL || list->item_count == 0U) return;
|
||||
if (delta < 0) {
|
||||
size_t amount = (size_t)(-(long long)delta);
|
||||
next = amount > list->selected_index ? 0U : list->selected_index - amount;
|
||||
} else {
|
||||
size_t amount = (size_t)delta;
|
||||
next = amount > list->item_count - 1U - list->selected_index
|
||||
? list->item_count - 1U : list->selected_index + amount;
|
||||
}
|
||||
list->selected_index = next;
|
||||
copy_id(list->selected_id, stable_ids[next]);
|
||||
if (next < list->viewport_start) list->viewport_start = next;
|
||||
if (next >= list->viewport_start + list->visible_rows)
|
||||
list->viewport_start = next - list->visible_rows + 1U;
|
||||
}
|
||||
|
||||
void trainlog_shell_format_list_label(const char *value,
|
||||
int maximum_cells,
|
||||
char *output,
|
||||
size_t output_capacity)
|
||||
{
|
||||
const char *input = value != NULL ? value : "";
|
||||
size_t input_bytes = 0U;
|
||||
size_t output_bytes = 0U;
|
||||
int cells = 0;
|
||||
bool truncated = false;
|
||||
|
||||
if (output == NULL || output_capacity == 0U) return;
|
||||
output[0] = '\0';
|
||||
if (maximum_cells <= 0) return;
|
||||
|
||||
/* INVARIANT: reserve one display cell for the truncation marker before
|
||||
* copying a code point. This keeps labels valid UTF-8 and makes clipped
|
||||
* stable-ID-backed rows visibly distinct from complete names. */
|
||||
while (input[input_bytes] != '\0') {
|
||||
utf8proc_int32_t codepoint;
|
||||
utf8proc_ssize_t parsed = utf8proc_iterate(
|
||||
(const utf8proc_uint8_t *)input + input_bytes, -1, &codepoint);
|
||||
int width;
|
||||
if (parsed <= 0) { parsed = 1; codepoint = (unsigned char)input[input_bytes]; }
|
||||
width = utf8proc_charwidth(codepoint);
|
||||
if (width < 0) width = 1;
|
||||
if (cells + width > maximum_cells ||
|
||||
output_bytes + (size_t)parsed >= output_capacity) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
(void)memcpy(output + output_bytes, input + input_bytes, (size_t)parsed);
|
||||
output_bytes += (size_t)parsed;
|
||||
input_bytes += (size_t)parsed;
|
||||
cells += width;
|
||||
}
|
||||
if (input[input_bytes] != '\0') truncated = true;
|
||||
if (truncated) {
|
||||
static const char ellipsis[] = "…";
|
||||
while (output_bytes > 0U && cells >= maximum_cells) {
|
||||
utf8proc_int32_t codepoint;
|
||||
size_t start = output_bytes - 1U;
|
||||
while (start > 0U && ((unsigned char)output[start] & 0xc0U) == 0x80U)
|
||||
--start;
|
||||
if (utf8proc_iterate((const utf8proc_uint8_t *)output + start,
|
||||
(utf8proc_ssize_t)(output_bytes - start), &codepoint) > 0) {
|
||||
int width = utf8proc_charwidth(codepoint);
|
||||
cells -= width > 0 ? width : 1;
|
||||
}
|
||||
output_bytes = start;
|
||||
}
|
||||
if (output_bytes + sizeof(ellipsis) <= output_capacity) {
|
||||
(void)memcpy(output + output_bytes, ellipsis, sizeof(ellipsis));
|
||||
return;
|
||||
}
|
||||
}
|
||||
output[output_bytes] = '\0';
|
||||
}
|
||||
|
||||
void trainlog_search_init(TrainlogSearchState *search)
|
||||
{
|
||||
if (search == NULL) return;
|
||||
(void)memset(search, 0, sizeof(*search));
|
||||
}
|
||||
|
||||
bool trainlog_search_insert(TrainlogSearchState *search,
|
||||
const char *utf8,
|
||||
size_t length)
|
||||
{
|
||||
utf8proc_int32_t codepoint;
|
||||
utf8proc_ssize_t parsed;
|
||||
if (search == NULL || utf8 == NULL || length == 0U ||
|
||||
search->bytes + length >= TRAINLOG_SHELL_SEARCH_CAPACITY) {
|
||||
if (search != NULL) search->capacity_error = true;
|
||||
return false;
|
||||
}
|
||||
parsed = utf8proc_iterate((const utf8proc_uint8_t *)utf8,
|
||||
(utf8proc_ssize_t)length, &codepoint);
|
||||
if (parsed <= 0 || (size_t)parsed != length || codepoint < 0x20 ||
|
||||
codepoint == 0x7f) return false;
|
||||
(void)memmove(search->text + search->cursor + length,
|
||||
search->text + search->cursor, search->bytes - search->cursor + 1U);
|
||||
(void)memcpy(search->text + search->cursor, utf8, length);
|
||||
search->cursor += length;
|
||||
search->bytes += length;
|
||||
search->capacity_error = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static size_t next_grapheme_boundary(const char *text, size_t bytes, size_t start)
|
||||
{
|
||||
utf8proc_int32_t previous;
|
||||
utf8proc_ssize_t parsed;
|
||||
size_t cursor = start;
|
||||
utf8proc_int32_t state = 0;
|
||||
if (text == NULL || start >= bytes) return bytes;
|
||||
parsed = utf8proc_iterate((const utf8proc_uint8_t *)text + cursor,
|
||||
(utf8proc_ssize_t)(bytes - cursor), &previous);
|
||||
if (parsed <= 0) return start + 1U;
|
||||
cursor += (size_t)parsed;
|
||||
while (cursor < bytes) {
|
||||
utf8proc_int32_t current;
|
||||
parsed = utf8proc_iterate((const utf8proc_uint8_t *)text + cursor,
|
||||
(utf8proc_ssize_t)(bytes - cursor), ¤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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
#include <stdlib.h>
|
||||
|
||||
#include <notcurses/notcurses.h>
|
||||
#include <utf8proc.h>
|
||||
|
||||
struct TrainlogTerminal {
|
||||
struct notcurses *notcurses;
|
||||
|
|
@ -27,11 +28,40 @@ struct TrainlogPanel {
|
|||
int left;
|
||||
};
|
||||
|
||||
struct TrainlogSurface {
|
||||
TrainlogTerminal *terminal;
|
||||
struct ncplane *plane;
|
||||
int height;
|
||||
int width;
|
||||
};
|
||||
|
||||
static unsigned role_rgb(TrainlogColorRole role)
|
||||
{
|
||||
switch (role) {
|
||||
case TRAINLOG_COLOR_ACCENT: return TRAINLOG_RGB_LAVENDER;
|
||||
case TRAINLOG_COLOR_SUCCESS: return TRAINLOG_RGB_SUCCESS;
|
||||
case TRAINLOG_COLOR_WARNING: return TRAINLOG_RGB_WARNING;
|
||||
case TRAINLOG_COLOR_ERROR: return TRAINLOG_RGB_ERROR;
|
||||
case TRAINLOG_COLOR_MUTED: return TRAINLOG_RGB_SUBTEXT;
|
||||
case TRAINLOG_COLOR_GRAPH: return 0xf5c2e7U;
|
||||
case TRAINLOG_COLOR_INFO: return TRAINLOG_RGB_INFO;
|
||||
case TRAINLOG_COLOR_NOTICE: return TRAINLOG_RGB_NOTICE;
|
||||
case TRAINLOG_COLOR_DEFAULT:
|
||||
default: return TRAINLOG_RGB_TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
static void plane_set_rgb(struct ncplane *plane, bool foreground, unsigned rgb)
|
||||
{
|
||||
unsigned red = (rgb >> 16U) & 0xffU;
|
||||
unsigned green = (rgb >> 8U) & 0xffU;
|
||||
unsigned blue = rgb & 0xffU;
|
||||
if (foreground) (void)ncplane_set_fg_rgb8(plane, red, green, blue);
|
||||
else (void)ncplane_set_bg_rgb8(plane, red, green, blue);
|
||||
}
|
||||
|
||||
static void terminal_apply_style(TrainlogTerminal *terminal)
|
||||
{
|
||||
unsigned red = 205U;
|
||||
unsigned green = 214U;
|
||||
unsigned blue = 244U;
|
||||
TrainlogColorRole role;
|
||||
|
||||
if (terminal == NULL || terminal->plane == NULL) {
|
||||
|
|
@ -39,24 +69,14 @@ static void terminal_apply_style(TrainlogTerminal *terminal)
|
|||
}
|
||||
|
||||
role = (TrainlogColorRole)((terminal->style >> 8U) & 0xffU);
|
||||
switch (role) {
|
||||
case TRAINLOG_COLOR_ACCENT: red = 148U; green = 226U; blue = 213U; break;
|
||||
case TRAINLOG_COLOR_SUCCESS: red = 166U; green = 227U; blue = 161U; break;
|
||||
case TRAINLOG_COLOR_WARNING: red = 249U; green = 226U; blue = 175U; break;
|
||||
case TRAINLOG_COLOR_ERROR: red = 243U; green = 139U; blue = 168U; break;
|
||||
case TRAINLOG_COLOR_MUTED: red = 137U; green = 180U; blue = 250U; break;
|
||||
case TRAINLOG_COLOR_GRAPH: red = 245U; green = 194U; blue = 231U; break;
|
||||
case TRAINLOG_COLOR_DEFAULT:
|
||||
default: break;
|
||||
}
|
||||
(void)ncplane_set_fg_rgb8(terminal->plane, red, green, blue);
|
||||
plane_set_rgb(terminal->plane, true, role_rgb(role));
|
||||
/* CONTRACT: selection remains visible without relying only on foreground
|
||||
* color. A role-aware surface fill survives terminals with weak color
|
||||
* contrast while ordinary drawing uses the canonical dark background. */
|
||||
if ((terminal->style & TRAINLOG_TEXT_REVERSE) != 0U) {
|
||||
(void)ncplane_set_bg_rgb8(terminal->plane, 49U, 50U, 68U);
|
||||
plane_set_rgb(terminal->plane, false, TRAINLOG_RGB_SURFACE0);
|
||||
} else {
|
||||
(void)ncplane_set_bg_rgb8(terminal->plane, 30U, 30U, 46U);
|
||||
plane_set_rgb(terminal->plane, false, TRAINLOG_RGB_BASE);
|
||||
}
|
||||
ncplane_set_styles(terminal->plane,
|
||||
(terminal->style & TRAINLOG_TEXT_BOLD) != 0U ? NCSTYLE_BOLD : 0U);
|
||||
|
|
@ -82,7 +102,7 @@ TrainlogTerminal *trainlog_terminal_create(void)
|
|||
free(terminal);
|
||||
return NULL;
|
||||
}
|
||||
(void)ncplane_set_bg_rgb8(terminal->plane, 30U, 30U, 46U);
|
||||
plane_set_rgb(terminal->plane, false, TRAINLOG_RGB_BASE);
|
||||
terminal->pushed_key = TRAINLOG_KEY_NONE;
|
||||
terminal_apply_style(terminal);
|
||||
return terminal;
|
||||
|
|
@ -261,6 +281,10 @@ static int terminal_key(uint32_t id, bool shifted)
|
|||
}
|
||||
|
||||
switch (id) {
|
||||
/* WHY: Notcurses reports the physical Escape key as the Unicode control
|
||||
* byte on common terminals. Normalize it here so overlays, forms and
|
||||
* route guards consume one Trainlog-owned semantic key. */
|
||||
case 27U: return TRAINLOG_KEY_ESCAPE;
|
||||
case NCKEY_UP: return TRAINLOG_KEY_UP; case NCKEY_DOWN: return TRAINLOG_KEY_DOWN;
|
||||
case NCKEY_LEFT: return TRAINLOG_KEY_LEFT; case NCKEY_RIGHT: return TRAINLOG_KEY_RIGHT;
|
||||
case NCKEY_ENTER: return TRAINLOG_KEY_ENTER; case NCKEY_TAB: return TRAINLOG_KEY_TAB;
|
||||
|
|
@ -270,7 +294,8 @@ static int terminal_key(uint32_t id, bool shifted)
|
|||
case NCKEY_PGDOWN: return TRAINLOG_KEY_PAGE_DOWN; case NCKEY_RESIZE: return TRAINLOG_KEY_RESIZE;
|
||||
case NCKEY_F01: return TRAINLOG_KEY_F1; case NCKEY_F02: return TRAINLOG_KEY_F2;
|
||||
case NCKEY_F03: return TRAINLOG_KEY_F3; case NCKEY_F04: return TRAINLOG_KEY_F4;
|
||||
case NCKEY_F05: return TRAINLOG_KEY_F5; default: return (int)id;
|
||||
case NCKEY_F05: return TRAINLOG_KEY_F5; case NCKEY_F06: return TRAINLOG_KEY_F6;
|
||||
case NCKEY_F07: return TRAINLOG_KEY_F7; default: return (int)id;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -362,6 +387,139 @@ bool trainlog_terminal_push_key(TrainlogTerminal *terminal, int key)
|
|||
return true;
|
||||
}
|
||||
|
||||
bool trainlog_terminal_refresh_geometry(TrainlogTerminal *terminal)
|
||||
{
|
||||
/* CONTRACT: NCKEY_RESIZE only reports that geometry changed. Notcurses
|
||||
* must refresh its standard plane before shell rectangles are recomputed;
|
||||
* otherwise a keyless resize can leave chrome at the former dimensions. */
|
||||
return terminal != NULL && terminal->notcurses != NULL &&
|
||||
notcurses_refresh(terminal->notcurses, NULL, NULL) == 0;
|
||||
}
|
||||
|
||||
static bool surface_rect_valid(const TrainlogTerminal *terminal,
|
||||
int top, int left, int height, int width)
|
||||
{
|
||||
return terminal != NULL && top >= 0 && left >= 0 && height > 0 && width > 0 &&
|
||||
top <= trainlog_terminal_rows(terminal) - height &&
|
||||
left <= trainlog_terminal_columns(terminal) - width;
|
||||
}
|
||||
|
||||
TrainlogSurface *trainlog_surface_create(TrainlogTerminal *terminal,
|
||||
const char *name,
|
||||
int top, int left,
|
||||
int height, int width)
|
||||
{
|
||||
ncplane_options options = {0};
|
||||
TrainlogSurface *surface;
|
||||
if (!surface_rect_valid(terminal, top, left, height, width)) return NULL;
|
||||
surface = calloc(1U, sizeof(*surface));
|
||||
if (surface == NULL) return NULL;
|
||||
options.y = top; options.x = left;
|
||||
options.rows = (unsigned)height; options.cols = (unsigned)width;
|
||||
options.name = name;
|
||||
surface->plane = ncplane_create(terminal->plane, &options);
|
||||
if (surface->plane == NULL) { free(surface); return NULL; }
|
||||
surface->terminal = terminal; surface->height = height; surface->width = width;
|
||||
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
|
||||
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
|
||||
return surface;
|
||||
}
|
||||
|
||||
bool trainlog_surface_set_rect(TrainlogSurface *surface,
|
||||
int top, int left, int height, int width)
|
||||
{
|
||||
if (surface == NULL || surface->plane == NULL ||
|
||||
!surface_rect_valid(surface->terminal, top, left, height, width)) return false;
|
||||
if (ncplane_resize_simple(surface->plane, (unsigned)height,
|
||||
(unsigned)width) != 0 ||
|
||||
ncplane_move_yx(surface->plane, top, left) != 0) return false;
|
||||
surface->height = height; surface->width = width;
|
||||
return true;
|
||||
}
|
||||
|
||||
void trainlog_surface_destroy(TrainlogSurface *surface)
|
||||
{
|
||||
if (surface == NULL) return;
|
||||
if (surface->plane != NULL) (void)ncplane_destroy(surface->plane);
|
||||
free(surface);
|
||||
}
|
||||
|
||||
void trainlog_surface_erase(TrainlogSurface *surface)
|
||||
{
|
||||
if (surface != NULL && surface->plane != NULL) ncplane_erase(surface->plane);
|
||||
}
|
||||
|
||||
void trainlog_surface_set_role(TrainlogSurface *surface,
|
||||
TrainlogColorRole foreground,
|
||||
unsigned background_rgb,
|
||||
TrainlogTextStyle style)
|
||||
{
|
||||
if (surface == NULL || surface->plane == NULL) return;
|
||||
plane_set_rgb(surface->plane, true, role_rgb(foreground));
|
||||
plane_set_rgb(surface->plane, false, background_rgb);
|
||||
ncplane_set_styles(surface->plane,
|
||||
(style & TRAINLOG_TEXT_BOLD) != 0U ? NCSTYLE_BOLD : 0U);
|
||||
(void)ncplane_set_base(surface->plane, " ", 0U, ncplane_channels(surface->plane));
|
||||
}
|
||||
|
||||
void trainlog_surface_printf(TrainlogSurface *surface,
|
||||
int row, int column,
|
||||
const char *format, ...)
|
||||
{
|
||||
va_list arguments;
|
||||
va_list copy;
|
||||
char *value;
|
||||
int count;
|
||||
if (surface == NULL || surface->plane == NULL || format == NULL || row < 0 ||
|
||||
column < 0 || row >= surface->height || column >= surface->width) return;
|
||||
va_start(arguments, format); va_copy(copy, arguments);
|
||||
count = vsnprintf(NULL, 0U, format, copy); va_end(copy);
|
||||
if (count < 0) { va_end(arguments); return; }
|
||||
value = malloc((size_t)count + 1U);
|
||||
if (value != NULL) {
|
||||
size_t bytes = 0U;
|
||||
int cells = 0;
|
||||
int available = surface->width - column;
|
||||
(void)vsnprintf(value, (size_t)count + 1U, format, arguments);
|
||||
/* INVARIANT: child-plane clipping is not inherited. Bound all output
|
||||
* to complete UTF-8 code points and cell width so content cannot paint
|
||||
* the footer or leave an invalid trailing byte sequence. */
|
||||
while (value[bytes] != '\0') {
|
||||
utf8proc_int32_t codepoint;
|
||||
utf8proc_ssize_t parsed = utf8proc_iterate(
|
||||
(const utf8proc_uint8_t *)value + bytes, -1, &codepoint);
|
||||
int width;
|
||||
if (parsed <= 0) break;
|
||||
width = utf8proc_charwidth(codepoint);
|
||||
if (width < 0) width = 1;
|
||||
if (cells + width > available) break;
|
||||
cells += width; bytes += (size_t)parsed;
|
||||
}
|
||||
(void)ncplane_putnstr_yx(surface->plane, row, column,
|
||||
bytes, value);
|
||||
free(value);
|
||||
}
|
||||
va_end(arguments);
|
||||
}
|
||||
|
||||
void trainlog_surface_draw(TrainlogSurface *surface,
|
||||
int row, int column, uint32_t codepoint)
|
||||
{
|
||||
nccell cell = NCCELL_TRIVIAL_INITIALIZER;
|
||||
if (surface == NULL || surface->plane == NULL || row < 0 || column < 0 ||
|
||||
row >= surface->height || column >= surface->width) return;
|
||||
if (nccell_load_ucs32(surface->plane, &cell, codepoint) >= 0) {
|
||||
(void)ncplane_putc_yx(surface->plane, row, column, &cell);
|
||||
nccell_release(surface->plane, &cell);
|
||||
}
|
||||
}
|
||||
|
||||
void trainlog_surface_move_top(TrainlogSurface *surface)
|
||||
{
|
||||
if (surface != NULL && surface->plane != NULL)
|
||||
(void)ncplane_move_family_top(surface->plane);
|
||||
}
|
||||
|
||||
TrainlogPanel *tui_panel_create(TrainlogTerminal *terminal, int height, int width,
|
||||
int top, int left)
|
||||
{
|
||||
|
|
|
|||
16061
tui/src/tui.c
16061
tui/src/tui.c
File diff suppressed because it is too large
Load diff
222
tui/tests/test_app_shell.c
Normal file
222
tui/tests/test_app_shell.c
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* @file test_app_shell.c
|
||||
* @brief APP_SHELL_V1 state and geometry contract regressions.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "trainlog/app_shell.h"
|
||||
#include "trainlog/terminal.h"
|
||||
|
||||
#define CHECK(value) do { if (!(value)) { \
|
||||
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
|
||||
__FILE__, __LINE__, #value); return false; } } while (0)
|
||||
|
||||
static bool test_geometry(void)
|
||||
{
|
||||
static const int sizes[][2] = {
|
||||
{120, 35}, {120, 31}, {100, 30}, {100, 25}, {80, 24}, {72, 20}
|
||||
};
|
||||
size_t index;
|
||||
for (index = 0U; index < sizeof(sizes) / sizeof(sizes[0]); ++index) {
|
||||
TrainlogShellLayout layout;
|
||||
TrainlogRect overlay;
|
||||
trainlog_shell_layout_compute(sizes[index][0], sizes[index][1], &layout);
|
||||
CHECK(layout.usable);
|
||||
CHECK(layout.header.y == 0 && layout.header.height == 2);
|
||||
CHECK(layout.content.y == 2 && layout.content.height == sizes[index][1] - 4);
|
||||
CHECK(layout.footer.y == sizes[index][1] - 2 && layout.footer.height == 2);
|
||||
CHECK(layout.content.y + layout.content.height == layout.footer.y);
|
||||
CHECK(layout.sidebar_visible ==
|
||||
(sizes[index][0] >= 100 && sizes[index][1] >= 26));
|
||||
CHECK(layout.sidebar_expanded ==
|
||||
(sizes[index][0] >= 120 && sizes[index][1] >= 32));
|
||||
if (layout.sidebar_visible) {
|
||||
CHECK(layout.sidebar.width == 22 && layout.separator.x == 22);
|
||||
CHECK(layout.content.x == 23);
|
||||
} else CHECK(layout.content.x == 0);
|
||||
overlay = trainlog_shell_overlay_rect(&layout, 80, 29);
|
||||
CHECK(trainlog_shell_rect_contains(&(TrainlogRect){2, 0,
|
||||
sizes[index][1] - 4, sizes[index][0]}, &overlay));
|
||||
CHECK(overlay.y + overlay.height <= layout.footer.y);
|
||||
}
|
||||
{
|
||||
TrainlogShellLayout layout;
|
||||
trainlog_shell_layout_compute(99, 40, &layout);
|
||||
CHECK(!layout.sidebar_visible);
|
||||
trainlog_shell_layout_compute(160, 25, &layout);
|
||||
CHECK(!layout.sidebar_visible);
|
||||
trainlog_shell_layout_compute(71, 20, &layout);
|
||||
CHECK(!layout.usable);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_navigation_and_actions(void)
|
||||
{
|
||||
TrainlogNavigationState navigation;
|
||||
TrainlogActionModel actions;
|
||||
TrainlogAction action = {"open.exercises", '3', "Exercices", true, 20U,
|
||||
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EXERCISES};
|
||||
const TrainlogAction *found;
|
||||
trainlog_navigation_init(&navigation);
|
||||
CHECK(trainlog_navigation_open(&navigation, TRAINLOG_ROUTE_EXERCISES,
|
||||
"ex_11111111-1111-4111-8111-111111111111"));
|
||||
CHECK(navigation.current.route == TRAINLOG_ROUTE_EXERCISES);
|
||||
CHECK(trainlog_navigation_open(&navigation, TRAINLOG_ROUTE_STATS_EXERCISE,
|
||||
navigation.current.stable_id));
|
||||
CHECK(trainlog_navigation_back(&navigation));
|
||||
CHECK(navigation.current.route == TRAINLOG_ROUTE_EXERCISES);
|
||||
CHECK(strcmp(navigation.current.stable_id,
|
||||
"ex_11111111-1111-4111-8111-111111111111") == 0);
|
||||
trainlog_actions_clear(&actions);
|
||||
CHECK(trainlog_actions_add(&actions, &action));
|
||||
CHECK(!trainlog_actions_add(&actions, &action));
|
||||
CHECK(actions.count == 1U);
|
||||
action.identifier = "help"; action.key = '?'; action.label = "Aide";
|
||||
action.priority = 10U; action.intent = TRAINLOG_INTENT_OPEN_HELP;
|
||||
CHECK(trainlog_actions_add(&actions, &action));
|
||||
found = trainlog_actions_find_key(&actions, '3');
|
||||
CHECK(found != NULL && found->intent == TRAINLOG_INTENT_OPEN_ROUTE);
|
||||
CHECK(strcmp(trainlog_actions_at_priority(&actions, 0U)->identifier, "help") == 0);
|
||||
actions.items[0].enabled = false;
|
||||
CHECK(trainlog_actions_find_key(&actions, '3') == NULL);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_overlay_isolation_and_focus(void)
|
||||
{
|
||||
TrainlogOverlayStack stack;
|
||||
TrainlogFocusTarget focus = TRAINLOG_FOCUS_NAVIGATION;
|
||||
char selected[TRAINLOG_SHELL_STABLE_ID_CAPACITY];
|
||||
trainlog_overlays_init(&stack);
|
||||
CHECK(trainlog_overlays_push(&stack, TRAINLOG_OVERLAY_NAVIGATION,
|
||||
TRAINLOG_FOCUS_CONTENT, "se_1"));
|
||||
CHECK(trainlog_overlays_push(&stack, TRAINLOG_OVERLAY_CONFIRMATION,
|
||||
TRAINLOG_FOCUS_OVERLAY, "se_1"));
|
||||
CHECK(trainlog_overlays_push(&stack, TRAINLOG_OVERLAY_HELP,
|
||||
TRAINLOG_FOCUS_OVERLAY, "se_1"));
|
||||
CHECK(!trainlog_overlays_push(&stack, TRAINLOG_OVERLAY_HELP,
|
||||
TRAINLOG_FOCUS_OVERLAY, "se_1"));
|
||||
CHECK(trainlog_overlays_top(&stack)->type == TRAINLOG_OVERLAY_HELP);
|
||||
CHECK(trainlog_overlays_pop(&stack, &focus, selected));
|
||||
CHECK(focus == TRAINLOG_FOCUS_OVERLAY && strcmp(selected, "se_1") == 0);
|
||||
CHECK(trainlog_overlays_top(&stack)->type == TRAINLOG_OVERLAY_CONFIRMATION);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_search_utf8(void)
|
||||
{
|
||||
TrainlogSearchState search;
|
||||
char fill[2] = {'a', '\0'};
|
||||
size_t index;
|
||||
trainlog_search_init(&search);
|
||||
search.open = true; search.focused = true;
|
||||
CHECK(trainlog_search_insert(&search, "é", strlen("é")));
|
||||
CHECK(trainlog_search_insert(&search, "界", strlen("界")));
|
||||
trainlog_search_home(&search); trainlog_search_right(&search);
|
||||
CHECK(search.cursor == strlen("é"));
|
||||
trainlog_search_left(&search); CHECK(search.cursor == 0U);
|
||||
trainlog_search_end(&search);
|
||||
CHECK(trainlog_search_backspace(&search));
|
||||
CHECK(strcmp(search.text, "é") == 0 && search.bytes == strlen("é"));
|
||||
CHECK(!trainlog_search_escape(&search));
|
||||
CHECK(search.open && search.focused && search.bytes == 0U);
|
||||
CHECK(trainlog_search_escape(&search));
|
||||
CHECK(!search.open && !search.focused);
|
||||
trainlog_search_init(&search);
|
||||
for (index = 0U; index < 200U; ++index)
|
||||
CHECK(trainlog_search_insert(&search, fill, 1U));
|
||||
CHECK(!trainlog_search_insert(&search, fill, 1U));
|
||||
CHECK(search.capacity_error && search.bytes == 200U);
|
||||
trainlog_search_init(&search);
|
||||
CHECK(trainlog_search_insert(&search, "e", 1U));
|
||||
CHECK(trainlog_search_insert(&search, "\xcc\x81", 2U));
|
||||
CHECK(trainlog_search_insert(&search, "x", 1U));
|
||||
trainlog_search_left(&search);
|
||||
CHECK(search.cursor == 3U);
|
||||
trainlog_search_left(&search);
|
||||
CHECK(search.cursor == 0U);
|
||||
trainlog_search_end(&search);
|
||||
CHECK(trainlog_search_backspace(&search));
|
||||
CHECK(strcmp(search.text, "e\xcc\x81") == 0);
|
||||
CHECK(trainlog_search_backspace(&search));
|
||||
CHECK(search.bytes == 0U && search.cursor == 0U);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_stable_selection_and_resize(void)
|
||||
{
|
||||
const char *first[] = {"a", "b", "c", "d", "e"};
|
||||
const char *filtered[] = {"b", "d", "e"};
|
||||
TrainlogListState list;
|
||||
trainlog_list_init(&list);
|
||||
trainlog_list_set_items(&list, first, 5U, 2U, false, true);
|
||||
trainlog_list_move(&list, first, 3);
|
||||
CHECK(strcmp(list.selected_id, "d") == 0 && list.viewport_start == 2U);
|
||||
trainlog_list_set_items(&list, filtered, 3U, 1U, false, true);
|
||||
CHECK(strcmp(list.selected_id, "d") == 0 && list.selected_index == 1U);
|
||||
trainlog_list_set_items(&list, filtered, 3U, 3U, true, false);
|
||||
CHECK(strcmp(list.selected_id, "d") == 0 && list.viewport_start == 0U);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_list_label_clipping(void)
|
||||
{
|
||||
char output[64];
|
||||
trainlog_shell_format_list_label("Développé couché très long", 12,
|
||||
output, sizeof(output));
|
||||
CHECK(strcmp(output, "Développé c…") == 0);
|
||||
trainlog_shell_format_list_label("Court", 12, output, sizeof(output));
|
||||
CHECK(strcmp(output, "Court") == 0);
|
||||
trainlog_shell_format_list_label("界界界", 5, output, sizeof(output));
|
||||
CHECK(strcmp(output, "界界…") == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_draft_and_generator_guards(void)
|
||||
{
|
||||
TrainlogDurabilityState state = {0};
|
||||
CHECK(trainlog_shell_leave_decision(&state, false) == TRAINLOG_LEAVE_ALLOW);
|
||||
state.session_draft = true; state.session_dirty = true;
|
||||
CHECK(trainlog_shell_leave_decision(&state, false) == TRAINLOG_LEAVE_ALLOW);
|
||||
CHECK(trainlog_shell_leave_decision(&state, true) == TRAINLOG_LEAVE_CONFIRM_DISCARD);
|
||||
state.generator_preview = true; state.generator_preview_dirty = true;
|
||||
CHECK(trainlog_shell_leave_decision(&state, false) == TRAINLOG_LEAVE_CONFIRM_KEEP);
|
||||
trainlog_shell_discard_transient(&state);
|
||||
CHECK(trainlog_shell_leave_decision(&state, true) == TRAINLOG_LEAVE_ALLOW);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_shared_form_adapter(void)
|
||||
{
|
||||
TrainlogFormField field;
|
||||
trainlog_form_init(&field, "12");
|
||||
CHECK(field.active && strcmp(field.text, "12") == 0);
|
||||
CHECK(trainlog_form_handle(&field, TRAINLOG_KEY_HOME) ==
|
||||
TRAINLOG_FORM_IGNORED);
|
||||
CHECK(trainlog_form_handle(&field, 0x00e9) == TRAINLOG_FORM_EDITED);
|
||||
CHECK(strcmp(field.text, "é12") == 0);
|
||||
CHECK(trainlog_form_handle(&field, TRAINLOG_KEY_TAB) ==
|
||||
TRAINLOG_FORM_NEXT);
|
||||
CHECK(trainlog_form_handle(&field, TRAINLOG_KEY_F6) ==
|
||||
TRAINLOG_FORM_OPEN_NAVIGATION);
|
||||
CHECK(trainlog_form_handle(&field, TRAINLOG_KEY_F7) ==
|
||||
TRAINLOG_FORM_OPEN_ACTIONS);
|
||||
CHECK(trainlog_form_handle(&field, TRAINLOG_KEY_ESCAPE) ==
|
||||
TRAINLOG_FORM_CANCEL);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
if (!test_geometry() || !test_navigation_and_actions() ||
|
||||
!test_overlay_isolation_and_focus() || !test_search_utf8() ||
|
||||
!test_stable_selection_and_resize() ||
|
||||
!test_list_label_clipping() ||
|
||||
!test_draft_and_generator_guards() ||
|
||||
!test_shared_form_adapter()) return 1;
|
||||
(void)printf("PASS app_shell\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
|
@ -114,9 +116,148 @@ static bool test_custom_equipment_round_trip(void)
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool insert_paged_equipment(
|
||||
TrainlogDatabase *database, const char *equipment_id, const char *display_name)
|
||||
{
|
||||
TrainlogCustomEquipment equipment;
|
||||
(void)memset(&equipment, 0, sizeof(equipment));
|
||||
(void)snprintf(equipment.equipment_id, sizeof(equipment.equipment_id), "%s", equipment_id);
|
||||
(void)snprintf(equipment.display_name, sizeof(equipment.display_name), "%s", display_name);
|
||||
(void)snprintf(equipment.label_name, sizeof(equipment.label_name), "%s", display_name);
|
||||
(void)snprintf(equipment.equipment_type, sizeof(equipment.equipment_type), "%s", "machine");
|
||||
(void)snprintf(equipment.load_semantics, sizeof(equipment.load_semantics), "%s", "external");
|
||||
return trainlog_database_create_custom_equipment(database, &equipment) == TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static bool test_custom_equipment_page_corruption(void)
|
||||
{
|
||||
char path[] = "/tmp/trainlog-custom-equipment-page-XXXXXX";
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogCustomEquipment page[2];
|
||||
sqlite3 *raw = NULL;
|
||||
sqlite3_stmt *statement = NULL;
|
||||
const unsigned char blob[] = { 'x', 'y' };
|
||||
const char embedded_nul[] = { 'x', '\0', 'y' };
|
||||
char oversized[TRAINLOG_NAME_MAX + 2U];
|
||||
const void *invalid_values[] = { oversized, blob, embedded_nul };
|
||||
const int invalid_lengths[] = { (int)sizeof(oversized), (int)sizeof(blob), (int)sizeof(embedded_nul) };
|
||||
size_t count;
|
||||
bool more;
|
||||
size_t index;
|
||||
int fd = mkstemp(path);
|
||||
|
||||
CHECK(fd >= 0);
|
||||
CHECK(close(fd) == 0);
|
||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(insert_paged_equipment(database, "eq_page_valid", "Alpha"));
|
||||
CHECK(insert_paged_equipment(database, "eq_page_invalid", "Bravo"));
|
||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_prepare_v2(raw, "UPDATE custom_equipment SET label_name=?1 WHERE equipment_id='eq_page_invalid';",
|
||||
-1, &statement, NULL) == SQLITE_OK);
|
||||
(void)memset(oversized, 'x', sizeof(oversized));
|
||||
/* label_name is NOT NULL, so NULL cannot reach the reader's defensive branch. */
|
||||
for (index = 0U; index < 3U; ++index) {
|
||||
if (index == 1U) {
|
||||
CHECK(sqlite3_bind_blob(statement, 1, invalid_values[index], invalid_lengths[index], SQLITE_TRANSIENT) == SQLITE_OK);
|
||||
} else {
|
||||
CHECK(sqlite3_bind_text(statement, 1, (const char *)invalid_values[index],
|
||||
invalid_lengths[index], SQLITE_TRANSIENT) == SQLITE_OK);
|
||||
}
|
||||
CHECK(sqlite3_step(statement) == SQLITE_DONE);
|
||||
CHECK(sqlite3_reset(statement) == SQLITE_OK);
|
||||
count = 9U;
|
||||
more = true;
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 1U, &count, &more) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(count == 0U && !more);
|
||||
count = 9U;
|
||||
more = true;
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 2U, &count, &more) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(count == 0U && !more);
|
||||
CHECK(sqlite3_bind_text(statement, 1, "Bravo", -1, SQLITE_STATIC) == SQLITE_OK);
|
||||
CHECK(sqlite3_step(statement) == SQLITE_DONE);
|
||||
CHECK(sqlite3_reset(statement) == SQLITE_OK);
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 2U, &count, &more) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 2U && !more && strcmp(page[0].equipment_id, "eq_page_valid") == 0);
|
||||
}
|
||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
trainlog_database_close(database);
|
||||
CHECK(unlink(path) == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool test_custom_equipment_page_reader(void)
|
||||
{
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogCustomEquipment page[TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX];
|
||||
char equipment_id[32];
|
||||
char display_name[32];
|
||||
size_t count = 99U;
|
||||
bool more = true;
|
||||
size_t index;
|
||||
|
||||
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(insert_paged_equipment(database, "eq_tie_b", "alpha"));
|
||||
CHECK(insert_paged_equipment(database, "eq_tie_a", "ALPHA"));
|
||||
for (index = 0U; index < 130U; ++index) {
|
||||
(void)snprintf(equipment_id, sizeof(equipment_id), "eq_page_%03zu", index);
|
||||
(void)snprintf(display_name, sizeof(display_name), "Page %03zu", index);
|
||||
CHECK(insert_paged_equipment(database, equipment_id, display_name));
|
||||
}
|
||||
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page,
|
||||
TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX, &count, &more) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX);
|
||||
CHECK(more);
|
||||
CHECK(strcmp(page[0].equipment_id, "eq_tie_a") == 0);
|
||||
CHECK(strcmp(page[1].equipment_id, "eq_tie_b") == 0);
|
||||
CHECK(strcmp(page[2].equipment_id, "eq_page_000") == 0);
|
||||
CHECK(strcmp(page[127].equipment_id, "eq_page_125") == 0);
|
||||
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, 128U, page,
|
||||
TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX, &count, &more) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 4U);
|
||||
CHECK(!more);
|
||||
CHECK(strcmp(page[0].equipment_id, "eq_page_126") == 0);
|
||||
CHECK(strcmp(page[3].equipment_id, "eq_page_129") == 0);
|
||||
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, 131U, page, 1U,
|
||||
&count, &more) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 1U);
|
||||
CHECK(!more);
|
||||
CHECK(strcmp(page[0].equipment_id, "eq_page_129") == 0);
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, 132U, page, 1U,
|
||||
&count, &more) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 0U);
|
||||
CHECK(!more);
|
||||
|
||||
count = 7U;
|
||||
more = true;
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page, 0U,
|
||||
&count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
CHECK(count == 7U);
|
||||
CHECK(more);
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, NULL, 1U,
|
||||
&count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, 0U, page,
|
||||
TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX + 1U, &count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
if ((uintmax_t)SIZE_MAX > (uintmax_t)INT64_MAX) {
|
||||
count = 7U;
|
||||
more = true;
|
||||
CHECK(trainlog_database_list_custom_equipment_page(database, SIZE_MAX, page, 1U,
|
||||
&count, &more) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
CHECK(count == 0U);
|
||||
CHECK(!more);
|
||||
}
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
if (!test_custom_equipment_round_trip()) return 1;
|
||||
if (!test_custom_equipment_page_corruption()) return 1;
|
||||
if (!test_custom_equipment_page_reader()) return 1;
|
||||
(void)printf("PASS custom_equipment\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
614
tui/tests/test_sync_body_zone_wiring.c
Normal file
614
tui/tests/test_sync_body_zone_wiring.c
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/mtp.h"
|
||||
#include "trainlog/sync.h"
|
||||
#include "trainlog/usb.h"
|
||||
|
||||
#define CHECK(value) do { \
|
||||
if (!(value)) { \
|
||||
(void)fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #value); \
|
||||
return false; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static const char *const SOURCE_ID =
|
||||
"ex_43c7375f-934c-4650-930c-45807d2f2929";
|
||||
static const char *const CANONICAL_ID =
|
||||
"ex_2d488c08-194c-4051-a3c9-34471646c1d3";
|
||||
static const char *const EXERCISE_NAME = "Sync wiring exercise";
|
||||
static char remote_root[4096];
|
||||
static const char *remote_mobile_name = "trainlog-mobile-export-v2.json";
|
||||
static unsigned int remote_mobile_version = 2U;
|
||||
|
||||
static bool copy_file(const char *source, const char *target)
|
||||
{
|
||||
FILE *input = fopen(source, "rb");
|
||||
FILE *output;
|
||||
char buffer[4096];
|
||||
size_t count;
|
||||
bool copied = true;
|
||||
|
||||
if (input == NULL) {
|
||||
return false;
|
||||
}
|
||||
output = fopen(target, "wb");
|
||||
if (output == NULL) {
|
||||
(void)fclose(input);
|
||||
return false;
|
||||
}
|
||||
while ((count = fread(buffer, 1U, sizeof(buffer), input)) != 0U) {
|
||||
if (fwrite(buffer, 1U, count, output) != count) {
|
||||
copied = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ferror(input) != 0) {
|
||||
copied = false;
|
||||
}
|
||||
if (fclose(input) != 0) {
|
||||
copied = false;
|
||||
}
|
||||
if (fclose(output) != 0) {
|
||||
copied = false;
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_usb_list_mtp_devices(
|
||||
TrainlogUsbDevice *output,
|
||||
size_t capacity,
|
||||
size_t *output_count
|
||||
)
|
||||
{
|
||||
if (output_count == NULL || output == NULL || capacity < 1U) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
(void)memset(output, 0, sizeof(*output));
|
||||
output->bus_number = 1U;
|
||||
output->device_number = 2U;
|
||||
output->mtp = true;
|
||||
*output_count = 1U;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_mtp_list_storages(
|
||||
unsigned int bus_number,
|
||||
unsigned int device_number,
|
||||
TrainlogMtpStorage *output,
|
||||
size_t capacity,
|
||||
size_t *output_count
|
||||
)
|
||||
{
|
||||
(void)bus_number;
|
||||
(void)device_number;
|
||||
if (output_count == NULL || output == NULL || capacity < 1U) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
(void)memset(output, 0, sizeof(*output));
|
||||
output->storage_id = 3U;
|
||||
*output_count = 1U;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static void set_entry(
|
||||
TrainlogMtpEntry *entry,
|
||||
uint32_t item_id,
|
||||
uint32_t parent_id,
|
||||
const char *name,
|
||||
bool folder
|
||||
)
|
||||
{
|
||||
(void)memset(entry, 0, sizeof(*entry));
|
||||
entry->item_id = item_id;
|
||||
entry->parent_id = parent_id;
|
||||
entry->storage_id = 3U;
|
||||
entry->folder = folder;
|
||||
entry->modification_unix_seconds = 1U;
|
||||
(void)snprintf(entry->name, sizeof(entry->name), "%s", name);
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_mtp_list_folder(
|
||||
unsigned int bus_number,
|
||||
unsigned int device_number,
|
||||
uint32_t storage_id,
|
||||
uint32_t parent_folder_id,
|
||||
TrainlogMtpEntry *output,
|
||||
size_t capacity,
|
||||
size_t *output_count
|
||||
)
|
||||
{
|
||||
(void)bus_number;
|
||||
(void)device_number;
|
||||
(void)storage_id;
|
||||
if (output_count == NULL || output == NULL) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
if (parent_folder_id == UINT32_MAX) {
|
||||
if (capacity < 1U) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
set_entry(&output[0], 1U, UINT32_MAX, "Download", true);
|
||||
*output_count = 1U;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
if (parent_folder_id == 1U) {
|
||||
if (capacity < 1U) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
set_entry(&output[0], 2U, 1U, "Trainlog", true);
|
||||
*output_count = 1U;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
if (parent_folder_id == 2U) {
|
||||
if (capacity < 3U) {
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
set_entry(&output[0], 10U, 2U, remote_mobile_name, false);
|
||||
set_entry(&output[1], 11U, 2U,
|
||||
"trainlog-exercise-body-zones-v1.json", false);
|
||||
set_entry(&output[2], 12U, 2U,
|
||||
"trainlog-equipment-associations-v2.json", false);
|
||||
*output_count = 3U;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
*output_count = 0U;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_mtp_receive_file(
|
||||
unsigned int bus_number,
|
||||
unsigned int device_number,
|
||||
uint32_t item_id,
|
||||
const char *local_path
|
||||
)
|
||||
{
|
||||
char source[4096];
|
||||
const char *name;
|
||||
int written;
|
||||
(void)bus_number;
|
||||
(void)device_number;
|
||||
|
||||
name = item_id == 10U ? remote_mobile_name :
|
||||
item_id == 11U ? "trainlog-exercise-body-zones-v1.json" :
|
||||
item_id == 12U ? "trainlog-equipment-associations-v2.json" : NULL;
|
||||
if (name == NULL || local_path == NULL) {
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
}
|
||||
written = snprintf(source, sizeof(source), "%s/%s", remote_root, name);
|
||||
if (written < 0 || (size_t)written >= sizeof(source) ||
|
||||
!copy_file(source, local_path)) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_mtp_send_text_file(
|
||||
unsigned int bus_number,
|
||||
unsigned int device_number,
|
||||
uint32_t storage_id,
|
||||
uint32_t parent_folder_id,
|
||||
const char *local_path,
|
||||
const char *remote_filename,
|
||||
uint32_t *output_item_id
|
||||
)
|
||||
{
|
||||
(void)bus_number;
|
||||
(void)device_number;
|
||||
(void)storage_id;
|
||||
(void)parent_folder_id;
|
||||
{
|
||||
char target[4096];
|
||||
int written = snprintf(target, sizeof(target), "%s/%s", remote_root,
|
||||
remote_filename);
|
||||
if (local_path == NULL || remote_filename == NULL || written < 0 ||
|
||||
(size_t)written >= sizeof(target) || !copy_file(local_path, target)) {
|
||||
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
}
|
||||
}
|
||||
if (output_item_id != NULL) {
|
||||
*output_item_id = 99U;
|
||||
}
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_mtp_delete_object(
|
||||
unsigned int bus_number,
|
||||
unsigned int device_number,
|
||||
uint32_t item_id
|
||||
)
|
||||
{
|
||||
(void)bus_number;
|
||||
(void)device_number;
|
||||
(void)item_id;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static bool write_artifacts(
|
||||
const char *mobile_id,
|
||||
const char *zone_id,
|
||||
bool historical_alias_data
|
||||
)
|
||||
{
|
||||
char path[4096];
|
||||
FILE *file;
|
||||
int written = snprintf(path, sizeof(path),
|
||||
"%s/%s", remote_root, remote_mobile_name);
|
||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||
file = fopen(path, "wb");
|
||||
CHECK(file != NULL);
|
||||
if (historical_alias_data) {
|
||||
CHECK(fprintf(file,
|
||||
"{\"format\":\"trainlog-mobile-export\",\"version\":%u,"
|
||||
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
|
||||
"\"exercises\":["
|
||||
"{\"exercise_id\":\"%s\",\"name\":\"Retired sync wiring exercise\","
|
||||
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0},"
|
||||
"{\"exercise_id\":\"%s\",\"name\":\"%s\","
|
||||
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0}],"
|
||||
"\"sessions\":[{\"session_id\":\"se_alias_sync_fixture\","
|
||||
"\"started_at\":\"2032-01-01T01:00:00+00:00\",\"session_type\":\"training\","
|
||||
"\"exercises\":["
|
||||
"{\"entry_id\":\"sxe_alias_historical_a\",\"position\":0,"
|
||||
"\"exercise_id\":\"%s\",\"name\":\"Retired sync wiring exercise\","
|
||||
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0,"
|
||||
"\"load_mode\":\"none\",\"rest_seconds\":0,\"target\":null,"
|
||||
"\"equipment_id\":null,\"sets\":[{\"reps\":8}]},"
|
||||
"{\"entry_id\":\"sxe_alias_historical_b\",\"position\":1,"
|
||||
"\"exercise_id\":\"%s\",\"name\":\"%s\","
|
||||
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\",\"data_fields\":0,"
|
||||
"\"load_mode\":\"none\",\"rest_seconds\":0,\"target\":null,"
|
||||
"\"equipment_id\":null,\"sets\":[{\"reps\":9}]}]}],"
|
||||
"\"body_observations\":[]}",
|
||||
remote_mobile_version, SOURCE_ID, CANONICAL_ID, EXERCISE_NAME,
|
||||
SOURCE_ID, CANONICAL_ID, EXERCISE_NAME) > 0);
|
||||
} else {
|
||||
CHECK(fprintf(file,
|
||||
"{\"format\":\"trainlog-mobile-export\",\"version\":%u,"
|
||||
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
|
||||
"\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\","
|
||||
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
|
||||
"\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}",
|
||||
remote_mobile_version, mobile_id, EXERCISE_NAME) > 0);
|
||||
}
|
||||
CHECK(fclose(file) == 0);
|
||||
|
||||
written = snprintf(path, sizeof(path),
|
||||
"%s/trainlog-exercise-body-zones-v1.json", remote_root);
|
||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||
file = fopen(path, "wb");
|
||||
CHECK(file != NULL);
|
||||
CHECK(fprintf(file,
|
||||
"{\"format\":\"trainlog-exercise-body-zones\",\"version\":1,"
|
||||
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
|
||||
"\"exercises\":[{\"exercise_id\":\"%s\","
|
||||
"\"primary_zone_id\":\"back\",\"secondary_zone_ids\":[\"arms\"]},"
|
||||
"{\"exercise_id\":\"%s\",\"primary_zone_id\":\"back\","
|
||||
"\"secondary_zone_ids\":[\"arms\"]}]}",
|
||||
zone_id, CANONICAL_ID) > 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
|
||||
written = snprintf(path, sizeof(path),
|
||||
"%s/trainlog-equipment-associations-v2.json", remote_root);
|
||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||
file = fopen(path, "wb");
|
||||
CHECK(file != NULL);
|
||||
CHECK(fprintf(file,
|
||||
"{\"format\":\"trainlog-equipment-associations\",\"version\":2,"
|
||||
"\"generated_at\":\"2032-01-01T00:00:00+00:00\",\"associations\":%s}",
|
||||
historical_alias_data
|
||||
? "[{\"session_id\":\"se_alias_sync_fixture\",\"entry_id\":\"sxe_alias_historical_a\",\"exercise_id\":\"ex_43c7375f-934c-4650-930c-45807d2f2929\",\"state\":\"cleared\"},{\"session_id\":\"se_alias_sync_fixture\",\"entry_id\":\"sxe_alias_historical_b\",\"exercise_id\":\"ex_2d488c08-194c-4051-a3c9-34471646c1d3\",\"state\":\"cleared\"}]"
|
||||
: "[]") > 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool prepare_database(
|
||||
const char *case_root,
|
||||
bool persistent_alias,
|
||||
bool initial_zone,
|
||||
char *database_path,
|
||||
size_t database_path_size
|
||||
)
|
||||
{
|
||||
TrainlogDatabase *database = NULL;
|
||||
const char *no_secondary[1] = {NULL};
|
||||
char data_directory[4096];
|
||||
int written;
|
||||
|
||||
CHECK(mkdir(case_root, 0700) == 0);
|
||||
CHECK(setenv("XDG_DATA_HOME", case_root, 1) == 0);
|
||||
written = snprintf(data_directory, sizeof(data_directory),
|
||||
"%s/trainlog", case_root);
|
||||
CHECK(written >= 0 && (size_t)written < sizeof(data_directory));
|
||||
CHECK(mkdir(data_directory, 0700) == 0);
|
||||
written = snprintf(database_path, database_path_size,
|
||||
"%s/trainlog.db", data_directory);
|
||||
CHECK(written >= 0 && (size_t)written < database_path_size);
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise_profiled(
|
||||
database, CANONICAL_ID, EXERCISE_NAME, "sync wiring exercise",
|
||||
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS,
|
||||
UINT32_C(0)) == TRAINLOG_STATUS_OK);
|
||||
if (initial_zone) {
|
||||
CHECK(trainlog_database_replace_exercise_body_zones(
|
||||
database, CANONICAL_ID, "chest", no_secondary, 0U) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
}
|
||||
if (persistent_alias) {
|
||||
CHECK(trainlog_database_insert_exercise_profiled(
|
||||
database, SOURCE_ID, "Retired sync wiring exercise",
|
||||
"retired sync wiring exercise", TRAINLOG_TRACKING_REPS,
|
||||
TRAINLOG_RECORDING_SETS, UINT32_C(0)) ==
|
||||
TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_merge_exercises(
|
||||
database, SOURCE_ID, CANONICAL_ID) == TRAINLOG_STATUS_OK);
|
||||
}
|
||||
trainlog_database_close(database);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool database_state(
|
||||
const char *database_path,
|
||||
bool source_should_resolve,
|
||||
const char *expected_primary,
|
||||
const char *expected_secondary,
|
||||
size_t expected_zone_count
|
||||
)
|
||||
{
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogExerciseBodyZone zones[4];
|
||||
char resolved[TRAINLOG_ID_MAX + 1U];
|
||||
size_t count = 0U;
|
||||
size_t index;
|
||||
bool found_primary = false;
|
||||
bool found_secondary = expected_secondary == NULL;
|
||||
sqlite3 *raw = NULL;
|
||||
sqlite3_stmt *statement = NULL;
|
||||
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_resolve_exercise_id(
|
||||
database, SOURCE_ID, resolved, sizeof(resolved)) ==
|
||||
(source_should_resolve ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_NOT_FOUND));
|
||||
if (source_should_resolve) {
|
||||
CHECK(strcmp(resolved, CANONICAL_ID) == 0);
|
||||
}
|
||||
CHECK(trainlog_database_list_exercise_body_zones(
|
||||
database, CANONICAL_ID, zones, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == expected_zone_count);
|
||||
for (index = 0U; index < count; ++index) {
|
||||
if (zones[index].role == TRAINLOG_BODY_ZONE_PRIMARY &&
|
||||
strcmp(zones[index].zone_id, expected_primary) == 0) {
|
||||
found_primary = true;
|
||||
}
|
||||
if (expected_secondary != NULL &&
|
||||
zones[index].role == TRAINLOG_BODY_ZONE_SECONDARY &&
|
||||
strcmp(zones[index].zone_id, expected_secondary) == 0) {
|
||||
found_secondary = true;
|
||||
}
|
||||
}
|
||||
CHECK(found_primary && found_secondary);
|
||||
trainlog_database_close(database);
|
||||
|
||||
/* INVARIANT: alias coalescing updates one canonical row; it never
|
||||
* resurrects the retired creator or duplicates mappings/aliases. */
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM exercises", -1,
|
||||
&statement, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK(sqlite3_column_int(statement, 0) == 1);
|
||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
statement = NULL;
|
||||
if (source_should_resolve) {
|
||||
CHECK(sqlite3_prepare_v2(raw,
|
||||
"SELECT COUNT(*) FROM exercise_aliases WHERE source_exercise_id=?1 "
|
||||
"AND canonical_exercise_id=?2", -1, &statement, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_bind_text(statement, 1, SOURCE_ID, -1, SQLITE_STATIC) == SQLITE_OK);
|
||||
CHECK(sqlite3_bind_text(statement, 2, CANONICAL_ID, -1, SQLITE_STATIC) == SQLITE_OK);
|
||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK(sqlite3_column_int(statement, 0) == 1);
|
||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
}
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool successful_journal(const char *case_root, const char *sync_id)
|
||||
{
|
||||
char path[4096];
|
||||
char content[8192];
|
||||
FILE *file;
|
||||
size_t count;
|
||||
int written = snprintf(path, sizeof(path), "%s/trainlog/sync_runs/%s.json",
|
||||
case_root, sync_id);
|
||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||
file = fopen(path, "rb");
|
||||
CHECK(file != NULL);
|
||||
count = fread(content, 1U, sizeof(content) - 1U, file);
|
||||
CHECK(ferror(file) == 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
content[count] = '\0';
|
||||
CHECK(strstr(content, "\"status\":\"success\"") != NULL);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool file_contains(const char *name, const char *needle, bool expected)
|
||||
{
|
||||
char path[4096];
|
||||
char content[65536];
|
||||
FILE *file;
|
||||
size_t count;
|
||||
int written = snprintf(path, sizeof(path), "%s/%s", remote_root, name);
|
||||
CHECK(written >= 0 && (size_t)written < sizeof(path));
|
||||
file = fopen(path, "rb");
|
||||
CHECK(file != NULL);
|
||||
count = fread(content, 1U, sizeof(content) - 1U, file);
|
||||
CHECK(ferror(file) == 0);
|
||||
CHECK(fclose(file) == 0);
|
||||
content[count] = '\0';
|
||||
CHECK((strstr(content, needle) != NULL) == expected);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool canonical_alias_exports(void)
|
||||
{
|
||||
/* CONTRACT: retired identities are published only by the alias companion;
|
||||
* every catalogue, session, zone, and equipment reference uses live B. */
|
||||
static const char *const canonical_outputs[] = {
|
||||
"trainlog-pc-catalog-v1.json",
|
||||
"trainlog-pc-mobile-export-v3.json",
|
||||
"trainlog-exercise-body-zones-v1.json",
|
||||
"trainlog-equipment-associations-v2.json",
|
||||
};
|
||||
size_t index;
|
||||
for (index = 0U;
|
||||
index < sizeof(canonical_outputs) / sizeof(canonical_outputs[0]);
|
||||
++index) {
|
||||
CHECK(file_contains(canonical_outputs[index], SOURCE_ID, false));
|
||||
CHECK(file_contains(canonical_outputs[index], CANONICAL_ID, true));
|
||||
}
|
||||
CHECK(file_contains("trainlog-exercise-aliases-v1.json", SOURCE_ID, true));
|
||||
CHECK(file_contains("trainlog-exercise-aliases-v1.json", CANONICAL_ID, true));
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool run_success_case(
|
||||
const char *case_root,
|
||||
bool persistent_alias
|
||||
)
|
||||
{
|
||||
TrainlogSyncReport report;
|
||||
char database_path[4096];
|
||||
if (persistent_alias) {
|
||||
remote_mobile_name = "trainlog-mobile-export-v3.json";
|
||||
remote_mobile_version = 3U;
|
||||
}
|
||||
CHECK(prepare_database(case_root, persistent_alias, false,
|
||||
database_path, sizeof(database_path)));
|
||||
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias));
|
||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
||||
CHECK(report.success);
|
||||
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
|
||||
CHECK(successful_journal(case_root, report.sync_id));
|
||||
if (persistent_alias) {
|
||||
sqlite3 *raw = NULL;
|
||||
sqlite3_stmt *statement = NULL;
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_prepare_v2(raw,
|
||||
"SELECT COUNT(*) FROM session_exercises se JOIN exercises e "
|
||||
"ON e.id=se.exercise_row_id WHERE se.entry_id IN "
|
||||
"('sxe_alias_historical_a','sxe_alias_historical_b') "
|
||||
"AND e.exercise_id=?1", -1, &statement, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_bind_text(statement, 1, CANONICAL_ID, -1,
|
||||
SQLITE_STATIC) == SQLITE_OK);
|
||||
CHECK(sqlite3_step(statement) == SQLITE_ROW);
|
||||
CHECK(sqlite3_column_int(statement, 0) == 2);
|
||||
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||
CHECK(canonical_alias_exports());
|
||||
}
|
||||
|
||||
/* Replay exercises the same production argv path and sync baseline. */
|
||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
|
||||
CHECK(report.success);
|
||||
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
|
||||
CHECK(successful_journal(case_root, report.sync_id));
|
||||
if (persistent_alias) {
|
||||
CHECK(canonical_alias_exports());
|
||||
remote_mobile_name = "trainlog-mobile-export-v2.json";
|
||||
remote_mobile_version = 2U;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool run_unknown_without_proof_case(const char *case_root)
|
||||
{
|
||||
TrainlogSyncReport report;
|
||||
char database_path[4096];
|
||||
CHECK(prepare_database(case_root, false, true,
|
||||
database_path, sizeof(database_path)));
|
||||
CHECK(write_artifacts(CANONICAL_ID, SOURCE_ID, false));
|
||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(!report.success);
|
||||
CHECK(strstr(report.error, "exercice inconnu") != NULL);
|
||||
CHECK(database_state(database_path, false, "chest", NULL, 1U));
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool run_v3_unknown_with_stale_v2_proof_case(const char *case_root)
|
||||
{
|
||||
TrainlogSyncReport report;
|
||||
char database_path[4096];
|
||||
FILE *stale;
|
||||
|
||||
/* This file deliberately survives from an earlier V2 run. A selected V3
|
||||
* cycle must not discover it as reconciliation evidence for its companion. */
|
||||
stale = fopen("/tmp/trainlog-mobile-export-v2.json", "wb");
|
||||
CHECK(stale != NULL);
|
||||
CHECK(fprintf(stale,
|
||||
"{\"format\":\"trainlog-mobile-export\",\"version\":2,"
|
||||
"\"generated_at\":\"2032-01-01T00:00:00+00:00\","
|
||||
"\"exercises\":[{\"exercise_id\":\"%s\",\"name\":\"%s\","
|
||||
"\"recording_mode\":\"sets\",\"tracking_mode\":\"reps\","
|
||||
"\"data_fields\":0}],\"sessions\":[],\"body_observations\":[]}",
|
||||
SOURCE_ID, EXERCISE_NAME) > 0);
|
||||
CHECK(fclose(stale) == 0);
|
||||
remote_mobile_name = "trainlog-mobile-export-v3.json";
|
||||
remote_mobile_version = 3U;
|
||||
CHECK(prepare_database(case_root, false, true,
|
||||
database_path, sizeof(database_path)));
|
||||
CHECK(write_artifacts(CANONICAL_ID, SOURCE_ID, false));
|
||||
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
|
||||
TRAINLOG_SYNC_ANDROID_TO_PC, &report) ==
|
||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
CHECK(!report.success);
|
||||
CHECK(strstr(report.error, "exercice inconnu") != NULL);
|
||||
CHECK(database_state(database_path, false, "chest", NULL, 1U));
|
||||
CHECK(remove("/tmp/trainlog-mobile-export-v2.json") == 0);
|
||||
remote_mobile_name = "trainlog-mobile-export-v2.json";
|
||||
remote_mobile_version = 2U;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool run_all(void)
|
||||
{
|
||||
char temporary[] = "/tmp/trainlog-sync-body-zone-wiring-XXXXXX";
|
||||
char case_a[4096];
|
||||
char case_b[4096];
|
||||
char case_c[4096];
|
||||
char case_d[4096];
|
||||
char *root = mkdtemp(temporary);
|
||||
|
||||
CHECK(root != NULL);
|
||||
CHECK(snprintf(remote_root, sizeof(remote_root), "%s/remote", root) > 0);
|
||||
CHECK(mkdir(remote_root, 0700) == 0);
|
||||
CHECK(snprintf(case_a, sizeof(case_a), "%s/case-a", root) > 0);
|
||||
CHECK(snprintf(case_b, sizeof(case_b), "%s/case-b", root) > 0);
|
||||
CHECK(snprintf(case_c, sizeof(case_c), "%s/case-c", root) > 0);
|
||||
CHECK(snprintf(case_d, sizeof(case_d), "%s/case-d", root) > 0);
|
||||
|
||||
CHECK(run_success_case(case_a, false));
|
||||
CHECK(run_success_case(case_b, true));
|
||||
CHECK(run_unknown_without_proof_case(case_c));
|
||||
CHECK(run_v3_unknown_with_stale_v2_proof_case(case_d));
|
||||
(void)puts("PASS production sync body-zone V2 proof wiring");
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
return run_all() ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -91,6 +91,15 @@ int main(void)
|
|||
max_occurrence.load_mode=TRAINLOG_LOAD_NONE;
|
||||
CHECK(insert_session(database,"session_max","2026-09-09T10:00:00+02:00",TRAINLOG_SESSION_MAX_TEST,&max_occurrence,1U));
|
||||
|
||||
CHECK(trainlog_database_latest_explicit_max_equipment_context(database,
|
||||
exercise_id, "leg_press", &context.latest_max) == TRAINLOG_STATUS_OK);
|
||||
CHECK(context.latest_max.found && context.latest_max.max_weight_kg == 120.0);
|
||||
CHECK(trainlog_database_latest_explicit_max_equipment_context(database,
|
||||
exercise_id, "plate_loaded_leg_press", &context.latest_max) == TRAINLOG_STATUS_OK);
|
||||
CHECK(!context.latest_max.found);
|
||||
CHECK(trainlog_database_latest_explicit_max_equipment_context(database,
|
||||
exercise_id, "", &context.latest_max) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
|
||||
CHECK(trainlog_training_exercise_context_load(database,exercise_id,4U,1U,&context)==TRAINLOG_STATUS_OK);
|
||||
CHECK(strcmp(context.exercise.name,"Leg press renamed")==0);
|
||||
CHECK(context.knowledge!=NULL && context.knowledge->interpretation!=NULL);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue