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
|
The Android UI is driven by exercise metadata, never by exercise-name
|
||||||
heuristics.
|
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,
|
Every meaningful draft/form mutation is persisted by the repository. Back,
|
||||||
backgrounding and process death never delete the draft. Home offers explicit
|
backgrounding and process death never delete the draft. Home offers explicit
|
||||||
resume; whole-draft discard requires confirmation. Final completed-session
|
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
|
Android schema v11 additionally stores optional planning metadata separately
|
||||||
from actual occurrence data after the additive v10 -> v11 migration. Existing
|
from actual occurrence data after the additive v10 -> v11 migration. Existing
|
||||||
rows retain `load_mode=none`, zero rest and NULL targets. Desktop remains schema
|
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
|
||||||
v11. The active completed-session exchange is the separate strict
|
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`
|
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
|
||||||
remains unchanged.
|
remains unchanged.
|
||||||
|
|
||||||
|
|
@ -227,8 +230,9 @@ Desktop SQLite schema is versioned with:
|
||||||
PRAGMA user_version;
|
PRAGMA user_version;
|
||||||
```
|
```
|
||||||
|
|
||||||
The current desktop schema is v9. Schema v9 adds an occurrence-owned explicit
|
The current desktop schema is v12. Schema v9 added an occurrence-owned explicit
|
||||||
maximum result without changing `TRAINLOG_FORMAT_V1`.
|
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
|
Every incompatible schema evolution requires an explicit migration and
|
||||||
regression coverage.
|
regression coverage.
|
||||||
|
|
|
||||||
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -1,5 +1,14 @@
|
||||||
# Changelog
|
# 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.
|
All notable changes to Trainlog are documented here.
|
||||||
|
|
||||||
Detailed implementation chronology remains available in Git history and
|
Detailed implementation chronology remains available in Git history and
|
||||||
|
|
@ -9,6 +18,22 @@ Detailed implementation chronology remains available in Git history and
|
||||||
|
|
||||||
### Added
|
### 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
|
- `SESSION_GENERATOR_V1=PASS`: one frozen shared policy; deterministic bounded previews for
|
||||||
11 BODY ZONES and four goals; explicit incomplete coverage and recorded-dose
|
11 BODY ZONES and four goals; explicit incomplete coverage and recorded-dose
|
||||||
recency; observed exact-equipment 28-day load anchors without MAX-derived
|
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,
|
- Android `EXERCISE_EDIT_V1`: visible catalog editing, stable-ID name rename,
|
||||||
explicit invalid/conflict/profile/database results, and profile locking once
|
explicit invalid/conflict/profile/database results, and profile locking once
|
||||||
completed history or an active draft references the exercise;
|
completed history or an active draft references the exercise;
|
||||||
- `ANDROID_BANNER_PARITY_V1`: one Android `◆ TRAINLOG ◆` header component
|
- `ANDROID_BANNER_PARITY_V1`: the earlier Android `◆ TRAINLOG ◆` header
|
||||||
matching the compact Notcurses banner's accent and muted context rhythm;
|
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,
|
- one durable Android active-session draft with Home resume, raw form restore,
|
||||||
confirmed discard and draft-only exercise removal;
|
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_DESKTOP_REAL_MIGRATION=PASS
|
||||||
BODY_ZONES_ANDROID_DEVICE_VALIDATION=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
|
ANDROID_BUILD=PASS
|
||||||
|
|
||||||
TRAINING_KNOWLEDGE_V1=PASS
|
TRAINING_KNOWLEDGE_V1=PASS
|
||||||
SESSION_GENERATOR_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
|
`TRAINING_KNOWLEDGE_V1` has passed its bounded scientific review, independent
|
||||||
temporal delta review, final engineering audit, repair verification, and final
|
temporal delta review, final engineering audit, repair verification, and final
|
||||||
executable validation. Its C and
|
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
|
Schema migrations are additive and preserve existing capture data. See
|
||||||
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
[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
|
equipment catalogue, per-occurrence equipment links, durable occurrence
|
||||||
identities, custom-equipment definition support, explicit MAX results and
|
identities, custom-equipment definition support, explicit MAX results and
|
||||||
stable-source Test max resumption, then direct primary/secondary body-zone
|
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
|
Exercises can be renamed in place from Android. The `ex_<uuid-v4>` identity is
|
||||||
unchanged; completed history, an active draft, and synchronization therefore
|
unchanged; completed history, an active draft, and synchronization therefore
|
||||||
|
|
@ -222,14 +234,14 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk \
|
||||||
|
|
||||||
## Android-triggered synchronization
|
## Android-triggered synchronization
|
||||||
|
|
||||||
Launch the desktop TUI from a built checkout with:
|
Launch the desktop TUI from this built checkout with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
trainlog
|
cd /home/fy59/Documents/trainlog
|
||||||
|
./build/tui/trainlog
|
||||||
```
|
```
|
||||||
|
|
||||||
The usual user command resolves to `build/tui/trainlog` in this checkout. The
|
The desktop database is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
||||||
desktop database is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
|
||||||
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
|
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
|
||||||
|
|
||||||
Build the desktop first, then install the user service:
|
Build the desktop first, then install the user service:
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ dependencies {
|
||||||
|
|
||||||
implementation(composeBom)
|
implementation(composeBom)
|
||||||
androidTestImplementation(composeBom)
|
androidTestImplementation(composeBom)
|
||||||
|
testImplementation(composeBom)
|
||||||
|
|
||||||
implementation(
|
implementation(
|
||||||
"androidx.activity:activity-compose:1.13.0"
|
"androidx.activity:activity-compose:1.13.0"
|
||||||
|
|
@ -68,6 +69,8 @@ dependencies {
|
||||||
"androidx.compose.foundation:foundation"
|
"androidx.compose.foundation:foundation"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
|
||||||
implementation(
|
implementation(
|
||||||
"androidx.compose.ui:ui"
|
"androidx.compose.ui:ui"
|
||||||
)
|
)
|
||||||
|
|
@ -81,10 +84,12 @@ dependencies {
|
||||||
debugImplementation(
|
debugImplementation(
|
||||||
"androidx.compose.ui:ui-tooling"
|
"androidx.compose.ui:ui-tooling"
|
||||||
)
|
)
|
||||||
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("androidx.test:core:1.7.0")
|
testImplementation("androidx.test:core:1.7.0")
|
||||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
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.ext:junit:1.3.0")
|
||||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ class DraftUiTestActivity : ComponentActivity() {
|
||||||
?: (loaded as?
|
?: (loaded as?
|
||||||
ActiveDraftLoadResult.Loaded)
|
ActiveDraftLoadResult.Loaded)
|
||||||
?.warning,
|
?.warning,
|
||||||
|
latestSession = null,
|
||||||
|
latestMaximum = null,
|
||||||
onSession = {
|
onSession = {
|
||||||
when (
|
when (
|
||||||
val result =
|
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 = {},
|
onBody = {},
|
||||||
onHistory = {},
|
onOpenLatestSession = {},
|
||||||
onSync = {},
|
onSync = {},
|
||||||
onGenerateSession = {},
|
onGenerateSession = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,17 @@ package com.labfytools.trainlog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.viewModels
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.data.SyncExporter
|
import com.labfytools.trainlog.data.SyncExporter
|
||||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||||
import com.labfytools.trainlog.ui.TrainlogApp
|
import com.labfytools.trainlog.ui.TrainlogApp
|
||||||
|
import com.labfytools.trainlog.ui.TrainlogAppState
|
||||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val appState by viewModels<TrainlogAppState>()
|
||||||
override fun onCreate(
|
override fun onCreate(
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
) {
|
) {
|
||||||
|
|
@ -46,6 +49,7 @@ class MainActivity : ComponentActivity() {
|
||||||
inbox = inbox,
|
inbox = inbox,
|
||||||
requestOutbox =
|
requestOutbox =
|
||||||
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)
|
val definitionsError = importPcEquipmentDefinitions(directory)
|
||||||
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
|
if (definitionsError != null) return CatalogInboxResult.Error(definitionsError)
|
||||||
|
val aliasesError = importExerciseAliases(directory)
|
||||||
|
if (aliasesError != null) return CatalogInboxResult.Error(aliasesError)
|
||||||
|
|
||||||
when (
|
when (
|
||||||
val result =
|
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? {
|
private fun importPcEquipmentDefinitions(directory: DocumentFile): String? {
|
||||||
val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null
|
val file = directory.findFile("trainlog-pc-equipment-definitions-v1.json") ?: return null
|
||||||
return try {
|
return try {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ class SyncExporter(
|
||||||
val mobileJson: String
|
val mobileJson: String
|
||||||
val associationsJson: String
|
val associationsJson: String
|
||||||
val bodyZonesJson: String
|
val bodyZonesJson: String
|
||||||
|
val aliasesJson: String
|
||||||
try {
|
try {
|
||||||
definitionsJson = repository.buildEquipmentDefinitionsJson()
|
definitionsJson = repository.buildEquipmentDefinitionsJson()
|
||||||
/* V3 is the authoritative mobile session exchange. V1/V2 remain
|
/* V3 is the authoritative mobile session exchange. V1/V2 remain
|
||||||
|
|
@ -54,6 +55,7 @@ class SyncExporter(
|
||||||
mobileJson = repository.buildMobileExportV3Json()
|
mobileJson = repository.buildMobileExportV3Json()
|
||||||
associationsJson = repository.buildEquipmentAssociationsJson()
|
associationsJson = repository.buildEquipmentAssociationsJson()
|
||||||
bodyZonesJson = repository.buildExerciseBodyZonesJson()
|
bodyZonesJson = repository.buildExerciseBodyZonesJson()
|
||||||
|
aliasesJson = repository.buildExerciseAliasesJson()
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
return SyncExportResult.Error(
|
return SyncExportResult.Error(
|
||||||
error.message ?: "Préparation de l'export impossible.",
|
error.message ?: "Préparation de l'export impossible.",
|
||||||
|
|
@ -192,6 +194,12 @@ class SyncExporter(
|
||||||
|
|
||||||
val bodyZonesError = writeBodyZones(bodyZonesJson)
|
val bodyZonesError = writeBodyZones(bodyZonesJson)
|
||||||
if (bodyZonesError != null) return SyncExportResult.Error(bodyZonesError)
|
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
|
/* CONTRACT: publication, not JSON construction, establishes the
|
||||||
* common sync ancestor. Applying the exact local snapshot can only
|
* common sync ancestor. Applying the exact local snapshot can only
|
||||||
* record equal baselines; the strict reconciler never unions zones. */
|
* 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. */
|
/** One directional-neutral companion is used by both peers. */
|
||||||
private fun writeBodyZones(json: String): String? {
|
private fun writeBodyZones(json: String): String? {
|
||||||
val resolver = appContext.contentResolver
|
val resolver = appContext.contentResolver
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,13 @@ sealed interface ExerciseBodyZoneImportResult {
|
||||||
data object DatabaseError : 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 {
|
sealed interface MobileSessionImportResult {
|
||||||
/** CONTRACT: counters describe persistent mutations, not artifact size. */
|
/** CONTRACT: counters describe persistent mutations, not artifact size. */
|
||||||
data class Applied(
|
data class Applied(
|
||||||
|
|
@ -252,6 +259,15 @@ data class TrainingExerciseContext(
|
||||||
val recentPerformance: ExerciseOccurrencePage,
|
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(
|
class TrainlogRepository(
|
||||||
context: Context,
|
context: Context,
|
||||||
databaseName: String =
|
databaseName: String =
|
||||||
|
|
@ -737,11 +753,12 @@ class TrainlogRepository(
|
||||||
return try {
|
return try {
|
||||||
db.beginTransaction()
|
db.beginTransaction()
|
||||||
incoming.forEach { item ->
|
incoming.forEach { item ->
|
||||||
val rowId = lookupExerciseRowIdOrNull(db, item.exerciseId)
|
val canonicalExerciseId = resolveExerciseId(db, item.exerciseId)
|
||||||
|
val rowId = lookupExerciseRowIdOrNull(db, canonicalExerciseId)
|
||||||
?: return ExerciseBodyZoneImportResult.Invalid(
|
?: return ExerciseBodyZoneImportResult.Invalid(
|
||||||
"Exercice de relation inconnu : ${item.exerciseId}",
|
"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 localState = bodyZoneSyncState(local.first, local.second)
|
||||||
val incomingState = bodyZoneSyncState(item.primary, item.secondary)
|
val incomingState = bodyZoneSyncState(item.primary, item.secondary)
|
||||||
val baseline = db.rawQuery(
|
val baseline = db.rawQuery(
|
||||||
|
|
@ -754,13 +771,13 @@ class TrainlogRepository(
|
||||||
skipped += 1
|
skipped += 1
|
||||||
}
|
}
|
||||||
baseline != null && localState == baseline -> {
|
baseline != null && localState == baseline -> {
|
||||||
replaceExerciseBodyZones(db, item.exerciseId, item.primary, item.secondary)
|
replaceExerciseBodyZones(db, canonicalExerciseId, item.primary, item.secondary)
|
||||||
writeBodyZoneSyncBaseline(db, rowId, incomingState)
|
writeBodyZoneSyncBaseline(db, rowId, incomingState)
|
||||||
updated += 1
|
updated += 1
|
||||||
}
|
}
|
||||||
baseline != null && incomingState == baseline -> keptLocal += 1
|
baseline != null && incomingState == baseline -> keptLocal += 1
|
||||||
baseline == null && localState == "|" -> {
|
baseline == null && localState == "|" -> {
|
||||||
replaceExerciseBodyZones(db, item.exerciseId, item.primary, item.secondary)
|
replaceExerciseBodyZones(db, canonicalExerciseId, item.primary, item.secondary)
|
||||||
writeBodyZoneSyncBaseline(db, rowId, incomingState)
|
writeBodyZoneSyncBaseline(db, rowId, incomingState)
|
||||||
updated += 1
|
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 =
|
private fun bodyZoneSyncState(primary: String?, secondary: List<String>): String =
|
||||||
(primary ?: "") + "|" + secondary.sorted().joinToString(",")
|
(primary ?: "") + "|" + secondary.sorted().joinToString(",")
|
||||||
|
|
||||||
|
|
@ -1390,10 +1527,12 @@ class TrainlogRepository(
|
||||||
index
|
index
|
||||||
)
|
)
|
||||||
|
|
||||||
val exerciseId =
|
val suppliedExerciseId = item.getString("exercise_id")
|
||||||
item.getString(
|
/* CONTRACT: a peer which has not yet consumed the companion
|
||||||
"exercise_id"
|
* 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 =
|
val name =
|
||||||
item.getString(
|
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
|
/* CONTRACT: a catalog name is mutable metadata. Identity
|
||||||
* reconciliation always prefers exercise_id, so an update
|
* reconciliation always prefers exercise_id, so an update
|
||||||
* retains the row used by completed sessions and drafts. */
|
* retains the row used by completed sessions and drafts. */
|
||||||
|
|
@ -1817,12 +1984,69 @@ class TrainlogRepository(
|
||||||
retired.exerciseId,
|
retired.exerciseId,
|
||||||
canonical.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(
|
db.execSQL(
|
||||||
"DELETE FROM exercises WHERE id=?;",
|
"DELETE FROM exercises WHERE id=?;",
|
||||||
arrayOf(retired.rowId),
|
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 =
|
private fun sqliteTableExists(db: SQLiteDatabase, table: String): Boolean =
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?;",
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?;",
|
||||||
|
|
@ -1839,7 +2063,7 @@ class TrainlogRepository(
|
||||||
val retired = readExerciseBodyZones(db, retiredExerciseId)
|
val retired = readExerciseBodyZones(db, retiredExerciseId)
|
||||||
val canonicalEmpty = canonical.first == null && canonical.second.isEmpty()
|
val canonicalEmpty = canonical.first == null && canonical.second.isEmpty()
|
||||||
val retiredEmpty = retired.first == null && retired.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(
|
private fun mergeExerciseBodyZoneRows(
|
||||||
|
|
@ -1852,7 +2076,8 @@ class TrainlogRepository(
|
||||||
val retiredState = readExerciseBodyZones(db, retired.exerciseId)
|
val retiredState = readExerciseBodyZones(db, retired.exerciseId)
|
||||||
val canonicalEmpty = canonicalState.first == null && canonicalState.second.isEmpty()
|
val canonicalEmpty = canonicalState.first == null && canonicalState.second.isEmpty()
|
||||||
val retiredEmpty = retiredState.first == null && retiredState.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é."
|
"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
|
* INVARIANT: baselines are cleared because an identity merge is not a
|
||||||
* synchronization acknowledgement; the next companion must establish
|
* synchronization acknowledgement; the next companion must establish
|
||||||
* a fresh common ancestor before accepting a one-sided change. */
|
* a fresh common ancestor before accepting a one-sided change. */
|
||||||
if (canonicalEmpty && !retiredEmpty) {
|
val primary = canonicalState.first ?: retiredState.first
|
||||||
db.execSQL(
|
if (primary != null) {
|
||||||
"UPDATE exercise_body_zones SET exercise_row_id=? WHERE exercise_row_id=?;",
|
db.execSQL("DELETE FROM exercise_body_zones WHERE exercise_row_id=? AND zone_id=?;",
|
||||||
arrayOf(canonical.rowId, retired.rowId),
|
arrayOf<Any>(canonical.rowId, primary))
|
||||||
)
|
db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,'primary');",
|
||||||
} else {
|
arrayOf<Any>(canonical.rowId, primary))
|
||||||
db.execSQL(
|
|
||||||
"DELETE FROM exercise_body_zones WHERE exercise_row_id=?;",
|
|
||||||
arrayOf(retired.rowId),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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")) {
|
if (sqliteTableExists(db, "exercise_body_zone_sync")) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);",
|
"DELETE FROM exercise_body_zone_sync WHERE exercise_row_id IN(?,?);",
|
||||||
|
|
@ -1943,8 +2172,8 @@ class TrainlogRepository(
|
||||||
private fun lookupExerciseRowIdOrNull(
|
private fun lookupExerciseRowIdOrNull(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
exerciseId: String,
|
exerciseId: String,
|
||||||
): Long? =
|
): Long? {
|
||||||
db.query(
|
val direct = db.query(
|
||||||
"exercises",
|
"exercises",
|
||||||
arrayOf("id"),
|
arrayOf("id"),
|
||||||
"exercise_id = ?",
|
"exercise_id = ?",
|
||||||
|
|
@ -1955,6 +2184,19 @@ class TrainlogRepository(
|
||||||
).use { cursor ->
|
).use { cursor ->
|
||||||
if (cursor.moveToFirst()) cursor.getLong(0) else null
|
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(
|
private fun lookupEquipmentRowIdOrNull(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -2245,7 +2487,11 @@ class TrainlogRepository(
|
||||||
for (index in 0 until entries.length()) {
|
for (index in 0 until entries.length()) {
|
||||||
val entry = entries.getJSONObject(index)
|
val entry = entries.getJSONObject(index)
|
||||||
val exerciseId = entry.optString("exercise_id")
|
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")
|
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
|
||||||
if (
|
if (
|
||||||
entry.optString("recording_mode") != exerciseRow.recordingMode.wireValue ||
|
entry.optString("recording_mode") != exerciseRow.recordingMode.wireValue ||
|
||||||
|
|
@ -2289,7 +2535,11 @@ class TrainlogRepository(
|
||||||
if (entryId.isBlank() || !seen.add(entryId) || exerciseId.isBlank() || position < 0) {
|
if (entryId.isBlank() || !seen.add(entryId) || exerciseId.isBlank() || position < 0) {
|
||||||
return MobileSessionImportResult.Invalid("Identité d'entrée V2 invalide.")
|
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")
|
?: return MobileSessionImportResult.Invalid("Exercice V2 inconnu : $exerciseId")
|
||||||
val recording = entry.optString("recording_mode")
|
val recording = entry.optString("recording_mode")
|
||||||
val tracking = entry.optString("tracking_mode")
|
val tracking = entry.optString("tracking_mode")
|
||||||
|
|
@ -2658,7 +2908,8 @@ class TrainlogRepository(
|
||||||
if (rows.size != incoming.length()) return false
|
if (rows.size != incoming.length()) return false
|
||||||
for (index in rows.indices) {
|
for (index in rows.indices) {
|
||||||
val item = incoming.getJSONObject(index)
|
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),
|
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 (item.isNull("equipment_id")) null else item.optString("equipment_id"))
|
||||||
if (version == 3) {
|
if (version == 3) {
|
||||||
|
|
@ -2730,7 +2981,7 @@ class TrainlogRepository(
|
||||||
current[index] == Triple(
|
current[index] == Triple(
|
||||||
item.optString("entry_id"),
|
item.optString("entry_id"),
|
||||||
item.optInt("position", -1),
|
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 item = items.getJSONObject(index)
|
||||||
val sessionId = item.optString("session_id")
|
val sessionId = item.optString("session_id")
|
||||||
val exerciseId = item.optString("exercise_id")
|
val exerciseId = item.optString("exercise_id")
|
||||||
|
val canonicalExerciseId = resolveExerciseId(db, exerciseId)
|
||||||
val entryId = if (version == 2) item.optString("entry_id") else null
|
val entryId = if (version == 2) item.optString("entry_id") else null
|
||||||
val state = item.optString("state")
|
val state = item.optString("state")
|
||||||
val identity = "$sessionId\u0000${entryId ?: exerciseId}"
|
val identity = "$sessionId\u0000${entryId ?: exerciseId}"
|
||||||
|
|
@ -2948,19 +3200,19 @@ class TrainlogRepository(
|
||||||
} else {
|
} 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=?;"
|
"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 ->
|
).use { cursor ->
|
||||||
val first = if (cursor.moveToFirst()) {
|
val first = if (cursor.moveToFirst()) {
|
||||||
if (version == 2) {
|
if (version == 2) {
|
||||||
Triple(cursor.getLong(0), cursor.getString(1), if (cursor.isNull(2)) null else cursor.getString(2))
|
Triple(cursor.getLong(0), cursor.getString(1), if (cursor.isNull(2)) null else cursor.getString(2))
|
||||||
} else {
|
} 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
|
} else null
|
||||||
if (version == 1 && first != null && cursor.moveToNext()) null else first
|
if (version == 1 && first != null && cursor.moveToNext()) null else first
|
||||||
}
|
}
|
||||||
?: return EquipmentAssociationImportResult.Invalid("Entrée de séance inconnue : $sessionId/$exerciseId")
|
?: 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}")
|
return EquipmentAssociationImportResult.Invalid("Conflit exercice association : $sessionId/${entryId ?: exerciseId}")
|
||||||
}
|
}
|
||||||
if (row.third != equipmentId) {
|
if (row.third != equipmentId) {
|
||||||
|
|
@ -3587,12 +3839,16 @@ class TrainlogRepository(
|
||||||
targetRepetitions: Int,
|
targetRepetitions: Int,
|
||||||
restSeconds: Int,
|
restSeconds: Int,
|
||||||
manualWeightKg: Double?,
|
manualWeightKg: Double?,
|
||||||
|
loadChoice: GeneratorLoadChoice = GeneratorLoadChoice.AUTOMATIC,
|
||||||
|
maxPercent: Int? = null,
|
||||||
): SessionGenerationResult {
|
): SessionGenerationResult {
|
||||||
val current = preview.exercises.getOrNull(index)
|
val current = preview.exercises.getOrNull(index)
|
||||||
?: return SessionGenerationResult.Invalid("Exercice de proposition introuvable.")
|
?: return SessionGenerationResult.Invalid("Exercice de proposition introuvable.")
|
||||||
if (targetSets !in 1..MAX_PLAN_SETS || targetRepetitions !in 1..MAX_PLAN_REPS ||
|
if (targetSets !in 1..MAX_PLAN_SETS || targetRepetitions !in 1..MAX_PLAN_REPS ||
|
||||||
restSeconds !in 0..MAX_PLAN_REST_SECONDS ||
|
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.")
|
return SessionGenerationResult.Invalid("Dose cible invalide.")
|
||||||
val db = database.readableDatabase
|
val db = database.readableDatabase
|
||||||
val ownsTransaction = !db.inTransaction()
|
val ownsTransaction = !db.inTransaction()
|
||||||
|
|
@ -3604,7 +3860,20 @@ class TrainlogRepository(
|
||||||
candidate, preview.request.referenceTime, targetSets, targetRepetitions, restSeconds,
|
candidate, preview.request.referenceTime, targetSets, targetRepetitions, restSeconds,
|
||||||
) { visitor -> streamGenerationHistory(db, visitor) }
|
) { visitor -> streamGenerationHistory(db, visitor) }
|
||||||
val semantics = candidate.equipmentLoadSemantics
|
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,
|
SessionExercisePlan(qualified.targetSets, reps = qualified.targetRepetitions,
|
||||||
weightKg = qualified.targetWeightKg, loadMode = qualified.plannedLoadMode,
|
weightKg = qualified.targetWeightKg, loadMode = qualified.plannedLoadMode,
|
||||||
restSeconds = qualified.restSeconds)
|
restSeconds = qualified.restSeconds)
|
||||||
|
|
@ -3621,11 +3890,16 @@ class TrainlogRepository(
|
||||||
plan = plan,
|
plan = plan,
|
||||||
estimatedSeconds = sessionGenerationEngine.estimateExerciseSeconds(
|
estimatedSeconds = sessionGenerationEngine.estimateExerciseSeconds(
|
||||||
targetSets, targetRepetitions, restSeconds),
|
targetSets, targetRepetitions, restSeconds),
|
||||||
rationaleCodes = if (manualWeightKg == null) listOf(qualified.rationaleCode)
|
rationaleCodes = when (loadChoice) {
|
||||||
else listOf("manual_target_load"),
|
GeneratorLoadChoice.PERCENT_MAX -> listOf(if (percentageTarget != null)
|
||||||
loadSourceSessionId = if (manualWeightKg == null) qualified.sourceSessionId else null,
|
"user_selected_max_percentage" else "compatible_max_unavailable")
|
||||||
loadSourceOccurrenceId = if (manualWeightKg == null) qualified.sourceOccurrenceId else null,
|
GeneratorLoadChoice.NONE -> listOf("numeric_load_absent")
|
||||||
loadSourceStartedAt = if (manualWeightKg == null) qualified.sourceStartedAt else null,
|
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 exercises = preview.exercises.toMutableList().also { it[index] = changed }
|
||||||
val total = Math.addExact(sessionGenerationPolicy.preparationSeconds,
|
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. */
|
/** Deterministic keyset page over current data; pages do not hold a cross-call snapshot. */
|
||||||
fun listExerciseOccurrences(
|
fun listExerciseOccurrences(
|
||||||
exerciseId: String,
|
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(
|
private fun readExerciseOccurrencePage(
|
||||||
db: SQLiteDatabase, exerciseId: String, limit: Int, after: ExerciseOccurrenceCursor?, setPreviewLimit: Int,
|
db: SQLiteDatabase, exerciseId: String, limit: Int, after: ExerciseOccurrenceCursor?, setPreviewLimit: Int,
|
||||||
): ExerciseOccurrencePage {
|
): ExerciseOccurrencePage {
|
||||||
|
|
@ -4016,44 +4371,45 @@ class TrainlogRepository(
|
||||||
* presentation context only and never participates in max identity.
|
* presentation context only and never participates in max identity.
|
||||||
*/
|
*/
|
||||||
fun listLatestExerciseMaxima(): List<LatestExerciseMax> {
|
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(
|
database.readableDatabase.rawQuery(
|
||||||
"""
|
"""
|
||||||
SELECT e.exercise_id, e.name, mr.max_weight_kg, s.started_at,
|
SELECT se.id, s.session_id, se.entry_id, s.started_at,
|
||||||
eq.display_name
|
e.exercise_id, e.name, mr.max_weight_kg, eq.display_name
|
||||||
FROM max_results AS mr
|
FROM max_results AS mr
|
||||||
JOIN session_exercises AS se ON se.id = mr.session_exercise_row_id
|
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 sessions AS s ON s.id = se.session_row_id
|
||||||
JOIN exercises AS e ON e.id = se.exercise_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
|
LEFT JOIN equipment AS eq ON eq.id = se.equipment_row_id
|
||||||
WHERE s.session_type = 'max_test'
|
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;
|
ORDER BY e.name COLLATE NOCASE, e.exercise_id;
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
null,
|
null,
|
||||||
).use { cursor ->
|
).use { cursor ->
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
output += LatestExerciseMax(
|
val temporal = candidate(cursor)
|
||||||
exerciseId = cursor.getString(0),
|
val exerciseId = cursor.requiredText(4, "exercise_id")
|
||||||
exerciseName = cursor.getString(1),
|
val value = AggregateCandidate(
|
||||||
maxWeightKg = cursor.getDouble(2),
|
temporal = temporal,
|
||||||
startedAt = cursor.getString(3),
|
result = LatestExerciseMax(
|
||||||
equipmentDisplayName = if (cursor.isNull(4)) null else cursor.getString(4),
|
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(
|
fun setCompletedSessionEquipment(
|
||||||
|
|
@ -5005,6 +5361,38 @@ private fun JSONObject.optDoubleOrNull(key: String): Double? =
|
||||||
private fun JSONObject.optIntOrNull(key: String): Int? =
|
private fun JSONObject.optIntOrNull(key: String): Int? =
|
||||||
if (has(key) && !isNull(key)) getInt(key) else null
|
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 =
|
private fun equipmentAliasNormalize(value: String): String =
|
||||||
Normalizer.normalize(value, Normalizer.Form.NFD)
|
Normalizer.normalize(value, Normalizer.Form.NFD)
|
||||||
.replace("\\p{M}+".toRegex(), "")
|
.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_REPS = 10000
|
||||||
private const val MAX_PLAN_DURATION_SECONDS = 86400
|
private const val MAX_PLAN_DURATION_SECONDS = 86400
|
||||||
private const val MAX_PLAN_REST_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 =
|
private const val ANDROID_LEG_PRESS_LEGACY_ID =
|
||||||
"ex_d68a1af1-7247-4fb3-a48b-da8516906a29"
|
"ex_d68a1af1-7247-4fb3-a48b-da8516906a29"
|
||||||
private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
|
private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
|
||||||
|
|
@ -5034,7 +5424,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
11,
|
12,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -5062,6 +5452,7 @@ private class TrainlogDatabaseHelper(
|
||||||
createActiveDraftTables(db)
|
createActiveDraftTables(db)
|
||||||
createEquipmentTables(db)
|
createEquipmentTables(db)
|
||||||
createBodyZoneTables(db)
|
createBodyZoneTables(db)
|
||||||
|
createExerciseAliasTable(db)
|
||||||
seedEquipment(db)
|
seedEquipment(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5156,6 +5547,11 @@ private class TrainlogDatabaseHelper(
|
||||||
version = 11
|
version = 11
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (version < 12 && newVersion >= 12) {
|
||||||
|
createExerciseAliasTable(db)
|
||||||
|
version = 12
|
||||||
|
}
|
||||||
|
|
||||||
if (version != newVersion) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
"Unsupported Android DB upgrade " +
|
"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) {
|
private fun seedInitialBodyZones(db: SQLiteDatabase) {
|
||||||
val catalog = BodyZoneCatalog.load(appContext)
|
val catalog = BodyZoneCatalog.load(appContext)
|
||||||
catalog.initialMappings.forEach { mapping ->
|
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.model.BodyObservationDraft
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
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
|
@Composable
|
||||||
fun BodyScreen(
|
fun BodyScreen(
|
||||||
repository: TrainlogRepository,
|
repository: TrainlogRepository,
|
||||||
|
state: BodyScreenState,
|
||||||
onBodySaved: () -> Unit,
|
onBodySaved: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -36,75 +43,13 @@ fun BodyScreen(
|
||||||
val keyboardController =
|
val keyboardController =
|
||||||
LocalSoftwareKeyboardController.current
|
LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
var bodyWeight by
|
var bodyWeight by state.values[0]; var neck by state.values[1]
|
||||||
remember {
|
var shoulders by state.values[2]; var chest by state.values[3]
|
||||||
mutableStateOf("")
|
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 neck by
|
var leftThigh by state.values[10]; var rightThigh by state.values[11]
|
||||||
remember {
|
var leftCalf by state.values[12]; var rightCalf by state.values[13]
|
||||||
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 message by
|
var message by
|
||||||
remember {
|
remember {
|
||||||
|
|
@ -127,35 +72,14 @@ fun BodyScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearForm() {
|
fun clearForm() {
|
||||||
bodyWeight = ""
|
state.abandonEdits()
|
||||||
neck = ""
|
|
||||||
shoulders = ""
|
|
||||||
chest = ""
|
|
||||||
waist = ""
|
|
||||||
hips = ""
|
|
||||||
leftArm = ""
|
|
||||||
rightArm = ""
|
|
||||||
leftForearm = ""
|
|
||||||
rightForearm = ""
|
|
||||||
leftThigh = ""
|
|
||||||
rightThigh = ""
|
|
||||||
leftCalf = ""
|
|
||||||
rightCalf = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogScreen(
|
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(
|
TrainlogFrame(
|
||||||
title = "GENERAL"
|
title = "Général"
|
||||||
) {
|
) {
|
||||||
BodyMetricField(
|
BodyMetricField(
|
||||||
label = "Poids",
|
label = "Poids",
|
||||||
|
|
@ -219,7 +143,7 @@ fun BodyScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "MEMBRES"
|
title = "Membres"
|
||||||
) {
|
) {
|
||||||
BodyMetricField(
|
BodyMetricField(
|
||||||
label = "Bras gauche",
|
label = "Bras gauche",
|
||||||
|
|
@ -303,7 +227,7 @@ fun BodyScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "ENREGISTREMENT"
|
title = "Enregistrement"
|
||||||
) {
|
) {
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label =
|
label =
|
||||||
|
|
@ -447,7 +371,7 @@ fun BodyScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "DERNIERS RELEVES",
|
title = "Derniers relevés",
|
||||||
active =
|
active =
|
||||||
recent.isNotEmpty(),
|
recent.isNotEmpty(),
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,18 @@ import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
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.LocalTrainlogColors
|
||||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
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
|
@Composable
|
||||||
fun ExerciseScreen(
|
fun ExerciseScreen(
|
||||||
repository: TrainlogRepository,
|
repository: TrainlogRepository,
|
||||||
|
state: ExerciseScreenState,
|
||||||
inline: Boolean,
|
inline: Boolean,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSaved: () -> Unit,
|
onSaved: () -> Unit,
|
||||||
|
onOpenMaxima: () -> Unit,
|
||||||
|
catalogueVisible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
|
||||||
var name by
|
var name by state.name
|
||||||
remember {
|
var recordingMode by state.recordingMode
|
||||||
mutableStateOf("")
|
var trackingMode by state.trackingMode
|
||||||
}
|
var speed by state.speed
|
||||||
|
var distance by state.distance
|
||||||
var recordingMode by
|
var primaryZoneId by state.primaryZoneId
|
||||||
remember {
|
var secondaryZoneIds by state.secondaryZoneIds
|
||||||
mutableStateOf(
|
var searchQuery by state.searchQuery
|
||||||
RecordingMode.SETS
|
var filterZoneId by state.filterZoneId
|
||||||
)
|
var unclassifiedFilter by state.unclassifiedFilter
|
||||||
}
|
var expandedKnowledgeIds by state.expandedKnowledgeIds
|
||||||
|
|
||||||
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>()) }
|
|
||||||
val zones = repository.listBodyZones()
|
val zones = repository.listBodyZones()
|
||||||
|
|
||||||
var message by
|
var message by state.message
|
||||||
remember {
|
var editedExerciseId by state.editedExerciseId
|
||||||
mutableStateOf<String?>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var editedExercise by
|
|
||||||
remember {
|
|
||||||
mutableStateOf<ExerciseProfile?>(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
val profileLocked =
|
val profileLocked =
|
||||||
editedExercise?.let {
|
editedExerciseId?.let {
|
||||||
!repository.canEditExerciseProfile(it.exerciseId)
|
!repository.canEditExerciseProfile(it)
|
||||||
} ?: false
|
} ?: false
|
||||||
|
|
||||||
fun startEditing(exercise: ExerciseProfile) {
|
fun startEditing(exercise: ExerciseProfile) {
|
||||||
/* WHY: edit state copies catalog metadata for presentation only. The
|
/* WHY: edit state copies catalog metadata for presentation only. The
|
||||||
* repository remains the sole owner of stable identity and SQLite. */
|
* repository remains the sole owner of stable identity and SQLite. */
|
||||||
editedExercise = exercise
|
editedExerciseId = exercise.exerciseId
|
||||||
name = exercise.name
|
name = exercise.name
|
||||||
recordingMode = exercise.recordingMode
|
recordingMode = exercise.recordingMode
|
||||||
trackingMode = exercise.trackingMode
|
trackingMode = exercise.trackingMode
|
||||||
|
|
@ -108,34 +128,18 @@ fun ExerciseScreen(
|
||||||
primaryZoneId = exercise.primaryZoneId
|
primaryZoneId = exercise.primaryZoneId
|
||||||
secondaryZoneIds = exercise.secondaryZoneIds.toSet()
|
secondaryZoneIds = exercise.secondaryZoneIds.toSet()
|
||||||
message = null
|
message = null
|
||||||
|
state.markClean()
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogScreen(
|
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(
|
TrainlogFrame(
|
||||||
title =
|
title =
|
||||||
if (editedExercise == null) {
|
if (editedExerciseId == null) {
|
||||||
"NOUVEL EXERCICE"
|
"Nouvel exercice"
|
||||||
} else {
|
} else {
|
||||||
"MODIFIER L'EXERCICE"
|
"Modifier l'exercice"
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
TrainlogField(
|
TrainlogField(
|
||||||
|
|
@ -350,9 +354,9 @@ fun ExerciseScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogAction(
|
TrainlogPrimaryAction(
|
||||||
label =
|
label =
|
||||||
if (editedExercise != null) {
|
if (editedExerciseId != null) {
|
||||||
"Enregistrer les modifications"
|
"Enregistrer les modifications"
|
||||||
} else if (inline) {
|
} else if (inline) {
|
||||||
"Créer et revenir à la séance"
|
"Créer et revenir à la séance"
|
||||||
|
|
@ -360,20 +364,18 @@ fun ExerciseScreen(
|
||||||
"Enregistrer l'exercice"
|
"Enregistrer l'exercice"
|
||||||
},
|
},
|
||||||
description =
|
description =
|
||||||
if (editedExercise == null) {
|
if (editedExerciseId == null) {
|
||||||
"Ajouter ce profil au catalogue local."
|
"Ajouter ce profil au catalogue local."
|
||||||
} else {
|
} else {
|
||||||
"Conserver l'identité et mettre à jour le catalogue."
|
"Conserver l'identité et mettre à jour le catalogue."
|
||||||
},
|
},
|
||||||
accent =
|
|
||||||
colors.success,
|
|
||||||
onClick = save@{
|
onClick = save@{
|
||||||
if (editedExercise == null &&
|
if (editedExerciseId == null &&
|
||||||
recordingMode == RecordingMode.SETS && primaryZoneId == null) {
|
recordingMode == RecordingMode.SETS && primaryZoneId == null) {
|
||||||
message = "Une zone principale est requise pour un nouvel exercice musculaire."
|
message = "Une zone principale est requise pour un nouvel exercice musculaire."
|
||||||
return@save
|
return@save
|
||||||
}
|
}
|
||||||
val current = editedExercise
|
val current = editedExerciseId
|
||||||
val result =
|
val result =
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
repository.createExercise(
|
repository.createExercise(
|
||||||
|
|
@ -392,7 +394,7 @@ fun ExerciseScreen(
|
||||||
} else {
|
} else {
|
||||||
repository.editExercise(
|
repository.editExercise(
|
||||||
ExerciseEditInput(
|
ExerciseEditInput(
|
||||||
exerciseId = current.exerciseId,
|
exerciseId = current,
|
||||||
name = name,
|
name = name,
|
||||||
recordingMode = recordingMode,
|
recordingMode = recordingMode,
|
||||||
trackingMode = trackingMode,
|
trackingMode = trackingMode,
|
||||||
|
|
@ -405,6 +407,7 @@ fun ExerciseScreen(
|
||||||
when (result) {
|
when (result) {
|
||||||
is CreateExerciseResult.Created -> {
|
is CreateExerciseResult.Created -> {
|
||||||
message = null
|
message = null
|
||||||
|
state.abandonEdits()
|
||||||
onSaved()
|
onSaved()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,7 +427,8 @@ fun ExerciseScreen(
|
||||||
|
|
||||||
is EditExerciseResult.Saved -> {
|
is EditExerciseResult.Saved -> {
|
||||||
message = null
|
message = null
|
||||||
editedExercise = null
|
editedExerciseId = null
|
||||||
|
state.abandonEdits()
|
||||||
onSaved()
|
onSaved()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -448,13 +452,13 @@ fun ExerciseScreen(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (editedExercise != null) {
|
if (editedExerciseId != null) {
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label = "Annuler",
|
label = "Annuler",
|
||||||
description = "Revenir au catalogue sans modification.",
|
description = "Revenir au catalogue sans modification.",
|
||||||
accent = colors.muted,
|
accent = colors.muted,
|
||||||
onClick = {
|
onClick = {
|
||||||
editedExercise = null
|
editedExerciseId = null
|
||||||
name = ""
|
name = ""
|
||||||
recordingMode = RecordingMode.SETS
|
recordingMode = RecordingMode.SETS
|
||||||
trackingMode = TrackingMode.REPS
|
trackingMode = TrackingMode.REPS
|
||||||
|
|
@ -476,7 +480,7 @@ fun ExerciseScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogFrame(title = "EXERCICES EXISTANTS", active = false) {
|
if (catalogueVisible) TrainlogFrame(title = "Exercices existants", active = false) {
|
||||||
TrainlogInputField(
|
TrainlogInputField(
|
||||||
label = "Recherche par préfixe",
|
label = "Recherche par préfixe",
|
||||||
value = searchQuery,
|
value = searchQuery,
|
||||||
|
|
@ -533,12 +537,18 @@ fun ExerciseScreen(
|
||||||
)
|
)
|
||||||
if (expanded) KnowledgePanel(repository, knowledge)
|
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(
|
TrainlogFrame(
|
||||||
title = "CONTRAT",
|
title = "Contrat",
|
||||||
active = false,
|
active = false,
|
||||||
) {
|
) {
|
||||||
TrainlogInfo(
|
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
|
@Composable
|
||||||
private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKnowledge) {
|
private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKnowledge) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
|
|
@ -564,31 +597,115 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
|
||||||
}
|
}
|
||||||
if (interpretation == null) {
|
if (interpretation == null) {
|
||||||
TrainlogInfo("Classification scientifique non résolue.", colors.warning)
|
TrainlogInfo("Classification scientifique non résolue.", colors.warning)
|
||||||
return
|
TrainlogInfo("Confiance : ${confidenceLabel(knowledge.confidence)}", colors.muted)
|
||||||
}
|
} else if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) {
|
||||||
if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) {
|
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
"Interprétation conditionnelle · à confirmer : ${interpretation.requiredConfirmation.orEmpty()}",
|
"Interprétation conditionnelle · à confirmer : ${interpretation.requiredConfirmation.orEmpty()}",
|
||||||
colors.warning,
|
colors.warning,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val patterns = interpretation.patternIds.mapNotNull(repository::getMovementPatternKnowledge)
|
interpretation?.let { resolved ->
|
||||||
val primary = interpretation.primaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
val patterns = resolved.patternIds.mapNotNull(repository::getMovementPatternKnowledge)
|
||||||
val secondary = interpretation.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
val primary = resolved.primaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||||
val primaryZone = repository.bodyZone(interpretation.primaryZoneId)?.displayName
|
val secondary = resolved.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||||
?: interpretation.primaryZoneId
|
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.displayName ?: resolved.primaryZoneId
|
||||||
val secondaryZones = interpretation.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
|
val secondaryZones = resolved.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
|
||||||
val runtimeEquipment = repository.listEquipment().associateBy { it.equipmentId }
|
val runtimeEquipment = repository.listEquipment().associateBy { it.equipmentId }
|
||||||
val equipment = knowledge.equipmentIds.map { runtimeEquipment[it]?.displayName ?: it }
|
val equipment = knowledge.equipmentIds.map { runtimeEquipment[it]?.displayName ?: it }
|
||||||
TrainlogInfo("Mouvement : ${patterns.joinToString { it.displayNameFr }.ifEmpty { "Non classé" }}")
|
TrainlogInfo("Mouvement : ${patterns.joinToString { it.displayNameFr }.ifEmpty { "Non classé" }}")
|
||||||
TrainlogInfo("Muscles principaux : ${primary.joinToString { it.displayNameFr }.ifEmpty { "Non classés" }}")
|
TrainlogInfo("Muscles principaux : ${primary.joinToString { it.displayNameFr }.ifEmpty { "Non classés" }}")
|
||||||
TrainlogInfo("Muscles secondaires : ${secondary.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
|
TrainlogInfo("Muscles secondaires : ${secondary.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
|
||||||
TrainlogInfo(
|
TrainlogInfo("Zones scientifiques : $primaryZone" + if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}")
|
||||||
"Zones scientifiques : $primaryZone" +
|
TrainlogInfo("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}")
|
||||||
if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}",
|
TrainlogInfo("Confiance : ${confidenceLabel(resolved.confidence)}", colors.muted)
|
||||||
)
|
}
|
||||||
TrainlogInfo("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}")
|
knowledge.limitations.forEach { TrainlogInfo("Limite : $it", colors.muted) }
|
||||||
TrainlogInfo("Confiance : ${confidenceLabel(interpretation.confidence)}", 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) {
|
private fun knowledgeSummary(knowledge: ExerciseKnowledge): String = when (knowledge.resolutionStatus) {
|
||||||
|
|
@ -695,6 +812,7 @@ private fun TrainlogChoice(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 2.dp)
|
.padding(vertical = 2.dp)
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
.background(
|
.background(
|
||||||
if (selected) {
|
if (selected) {
|
||||||
colors.surfaceAlt
|
colors.surfaceAlt
|
||||||
|
|
@ -702,6 +820,7 @@ private fun TrainlogChoice(
|
||||||
colors.surface
|
colors.surface
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.semantics { this.selected = selected }
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|
@ -722,7 +841,7 @@ private fun TrainlogChoice(
|
||||||
TrainlogTypography.normal.copy(
|
TrainlogTypography.normal.copy(
|
||||||
color =
|
color =
|
||||||
if (selected) {
|
if (selected) {
|
||||||
colors.warning
|
colors.accent
|
||||||
} else if (!enabled) {
|
} else if (!enabled) {
|
||||||
colors.muted
|
colors.muted
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -25,18 +25,10 @@ fun HistoryScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogScreen(
|
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(
|
TrainlogFrame(
|
||||||
title = "SEANCES",
|
title = "Séances",
|
||||||
active =
|
active =
|
||||||
sessions.isNotEmpty(),
|
sessions.isNotEmpty(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -79,7 +71,7 @@ fun HistoryScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "DERNIERS MAX",
|
title = "Derniers MAX",
|
||||||
active = latestMaxima.isNotEmpty(),
|
active = latestMaxima.isNotEmpty(),
|
||||||
) {
|
) {
|
||||||
if (latestMaxima.isEmpty()) {
|
if (latestMaxima.isEmpty()) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
package com.labfytools.trainlog.ui
|
package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
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.ActiveSessionDraft
|
||||||
|
import com.labfytools.trainlog.model.LatestExerciseMax
|
||||||
|
import com.labfytools.trainlog.model.SessionSummary
|
||||||
import com.labfytools.trainlog.model.SessionType
|
import com.labfytools.trainlog.model.SessionType
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
|
|
||||||
|
|
@ -13,162 +11,39 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
activeDraft: ActiveSessionDraft?,
|
activeDraft: ActiveSessionDraft?,
|
||||||
draftError: String?,
|
draftError: String?,
|
||||||
|
latestSession: SessionSummary?,
|
||||||
|
latestMaximum: LatestExerciseMax?,
|
||||||
onSession: () -> Unit,
|
onSession: () -> Unit,
|
||||||
onGenerateSession: () -> Unit,
|
onGenerateSession: () -> Unit,
|
||||||
onDiscardDraft: () -> Unit,
|
|
||||||
onExercise: () -> Unit,
|
|
||||||
onBody: () -> Unit,
|
onBody: () -> Unit,
|
||||||
onHistory: () -> Unit,
|
onOpenLatestSession: (String) -> Unit,
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
var confirmingDiscard by
|
TrainlogScreen("Accueil", scrollKey = "home") {
|
||||||
remember(activeDraft != null) {
|
draftError?.let { TrainlogInfo(it, colors.error) }
|
||||||
mutableStateOf(false)
|
TrainlogFrame("Séance") {
|
||||||
}
|
if (activeDraft != null) {
|
||||||
|
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement"
|
||||||
TrainlogScreen(
|
TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession)
|
||||||
subtitle = "A C C U E I L"
|
TrainlogAction("Programmer une séance", "La proposition sera conservée séparément.", onGenerateSession)
|
||||||
) {
|
} else {
|
||||||
if (activeDraft != null) {
|
TrainlogPrimaryAction("Programmer une séance", "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.", onGenerateSession)
|
||||||
TrainlogFrame(
|
TrainlogAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
|
||||||
title = "SÉANCE EN COURS"
|
|
||||||
) {
|
|
||||||
TrainlogAction(
|
|
||||||
label =
|
|
||||||
"Reprendre la séance en cours",
|
|
||||||
description =
|
|
||||||
(if (
|
|
||||||
activeDraft.sessionType ==
|
|
||||||
SessionType.MAX_TEST
|
|
||||||
) {
|
|
||||||
"Test max"
|
|
||||||
} else {
|
|
||||||
"Entraînement"
|
|
||||||
}) +
|
|
||||||
" · ${activeDraft.exercises.size} exercice(s)",
|
|
||||||
accent = colors.success,
|
|
||||||
onClick = onSession,
|
|
||||||
)
|
|
||||||
|
|
||||||
TrainlogAction(
|
|
||||||
label =
|
|
||||||
"Supprimer la séance en cours",
|
|
||||||
description =
|
|
||||||
"Supprimer le brouillon, sans modifier l'historique.",
|
|
||||||
accent = colors.error,
|
|
||||||
onClick = {
|
|
||||||
confirmingDiscard = true
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (confirmingDiscard) {
|
|
||||||
TrainlogAction(
|
|
||||||
label =
|
|
||||||
"Confirmer la suppression",
|
|
||||||
description =
|
|
||||||
"Abandonner définitivement cette séance en cours.",
|
|
||||||
accent = colors.error,
|
|
||||||
onClick = {
|
|
||||||
confirmingDiscard = false
|
|
||||||
onDiscardDraft()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
TrainlogAction(
|
|
||||||
label = "Annuler",
|
|
||||||
description =
|
|
||||||
"Conserver la séance en cours.",
|
|
||||||
accent = colors.muted,
|
|
||||||
onClick = {
|
|
||||||
confirmingDiscard = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
latestSession?.let { session ->
|
||||||
if (draftError != null) {
|
TrainlogFrame("Dernière séance") {
|
||||||
TrainlogFrame(
|
TrainlogAction(formatStartedAt(session.startedAt), "${sessionTypeLabel(session.sessionType)} · ${session.exerciseCount} exercice(s)", { onOpenLatestSession(session.sessionId) })
|
||||||
title = "BROUILLON"
|
|
||||||
) {
|
|
||||||
TrainlogInfo(
|
|
||||||
text = draftError,
|
|
||||||
color = colors.error,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
latestMaximum?.let { maximum ->
|
||||||
TrainlogFrame(
|
val weight = "%.2f".format(java.util.Locale.FRANCE, maximum.maxWeightKg).trimEnd('0').trimEnd(',')
|
||||||
title = "ENREGISTREMENT"
|
TrainlogInfo("Dernier MAX · ${maximum.exerciseName} · $weight kg · ${maximum.startedAt.take(10)}", colors.warning)
|
||||||
) {
|
|
||||||
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("Accès rapides", active = false) {
|
||||||
TrainlogFrame(
|
TrainlogAction("Mensurations", "Ajouter ou consulter les relevés locaux.", onBody)
|
||||||
title = "CONSULTATION"
|
TrainlogAction("Synchronisation", "Consulter l'état connu et lancer une action explicite.", onSync)
|
||||||
) {
|
|
||||||
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."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
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) {
|
if (detail == null) {
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "ERREUR"
|
title = "Erreur"
|
||||||
) {
|
) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
text =
|
text =
|
||||||
|
|
@ -71,7 +62,7 @@ fun SessionDetailScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "SEANCE"
|
title = "Séance"
|
||||||
) {
|
) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
formatStartedAt(
|
formatStartedAt(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import com.labfytools.trainlog.data.AcceptGeneratedSessionResult
|
import com.labfytools.trainlog.data.AcceptGeneratedSessionResult
|
||||||
import com.labfytools.trainlog.data.GenerationWarningLevel
|
import com.labfytools.trainlog.data.GenerationWarningLevel
|
||||||
|
import com.labfytools.trainlog.data.GeneratorLoadChoice
|
||||||
import com.labfytools.trainlog.data.SessionGenerationPreview
|
import com.labfytools.trainlog.data.SessionGenerationPreview
|
||||||
import com.labfytools.trainlog.data.SessionGenerationRequest
|
import com.labfytools.trainlog.data.SessionGenerationRequest
|
||||||
import com.labfytools.trainlog.data.SessionGenerationResult
|
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
|
@Composable
|
||||||
fun SessionGeneratorScreen(
|
fun SessionGeneratorScreen(
|
||||||
repository: TrainlogRepository,
|
repository: TrainlogRepository,
|
||||||
|
state: SessionGeneratorUiState,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAccepted: () -> Unit,
|
onAccepted: () -> Unit,
|
||||||
onExistingDraft: () -> Unit,
|
onExistingDraft: () -> Unit,
|
||||||
|
|
@ -66,32 +111,38 @@ fun SessionGeneratorScreen(
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val options = remember { repository.sessionGenerationFormOptions() }
|
val options = remember { repository.sessionGenerationFormOptions() }
|
||||||
val zones = remember { repository.listBodyZones().filter { it.zoneId in options.zoneIds } }
|
val zones = remember { repository.listBodyZones().filter { it.zoneId in options.zoneIds } }
|
||||||
var zoneId by remember { mutableStateOf("full_body") }
|
var zoneId by state.zoneId
|
||||||
var goalId by remember { mutableStateOf("general") }
|
var goalId by state.goalId
|
||||||
var durationText by remember { mutableStateOf("30") }
|
var durationText by state.durationText
|
||||||
var preview by remember { mutableStateOf<SessionGenerationPreview?>(null) }
|
var preview by state.preview
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
var message by state.message
|
||||||
var warningAcknowledged by remember { mutableStateOf(false) }
|
var warningAcknowledged by state.warningAcknowledged
|
||||||
var editingIndex by remember { mutableStateOf<Int?>(null) }
|
var editingIndex by state.editingIndex
|
||||||
var setsText by remember { mutableStateOf("") }
|
var setsText by state.setsText
|
||||||
var repsText by remember { mutableStateOf("") }
|
var repsText by state.repsText
|
||||||
var restText by remember { mutableStateOf("") }
|
var restText by state.restText
|
||||||
var loadText by remember { mutableStateOf("") }
|
var loadText by state.loadText
|
||||||
var busy by remember { mutableStateOf(false) }
|
var loadChoice by state.loadChoice
|
||||||
|
var maxPercentText by state.maxPercentText
|
||||||
|
var busy by state.busy
|
||||||
|
|
||||||
fun generate(request: SessionGenerationRequest) {
|
fun generate(request: SessionGenerationRequest) {
|
||||||
if (busy) return
|
if (busy) return
|
||||||
|
val requestIdentity = ++state.requestIdentity
|
||||||
scope.launch {
|
scope.launch {
|
||||||
busy = true
|
busy = true
|
||||||
try {
|
try {
|
||||||
when (val result = withContext(Dispatchers.IO) { repository.generateSessionPreview(request) }) {
|
when (val result = withContext(Dispatchers.IO) { repository.generateSessionPreview(request) }) {
|
||||||
is SessionGenerationResult.Generated -> {
|
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
|
preview = result.preview
|
||||||
warningAcknowledged = result.preview.exposure.warningLevel == GenerationWarningLevel.NONE
|
warningAcknowledged = result.preview.exposure.warningLevel == GenerationWarningLevel.NONE
|
||||||
message = null
|
message = null
|
||||||
}
|
}
|
||||||
is SessionGenerationResult.Invalid -> message = result.message
|
is SessionGenerationResult.Invalid -> if (requestIdentity == state.requestIdentity) message = result.message
|
||||||
is SessionGenerationResult.DatabaseError -> message = result.message
|
is SessionGenerationResult.DatabaseError -> if (requestIdentity == state.requestIdentity) message = result.message
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = false
|
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") {
|
TrainlogScreen(subtitle = "Programmer une séance") {
|
||||||
TrainlogAction("< Retour", "Annuler sans créer de brouillon ni modifier l'historique.", onClick = {
|
|
||||||
if (!busy) onBack()
|
|
||||||
}, accent = colors.muted)
|
|
||||||
val current = preview
|
val current = preview
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
TrainlogFrame("ZONE CORPORELLE") {
|
TrainlogFrame("Zone corporelle") {
|
||||||
zones.forEach { zone ->
|
TrainlogChoiceChips(zones.map { it.zoneId to it.displayName }, zoneId) { zoneId = it }
|
||||||
TrainlogAction(zone.displayName, zone.zoneId,
|
|
||||||
onClick = { zoneId = zone.zoneId },
|
|
||||||
accent = if (zone.zoneId == zoneId) colors.success else colors.muted)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
TrainlogFrame("OBJECTIF") {
|
TrainlogFrame("Objectif") {
|
||||||
SessionGeneratorFormController.goals.filter { it.first in options.goalIds }.forEach { (id, label) ->
|
TrainlogChoiceChips(SessionGeneratorFormController.goals.filter { it.first in options.goalIds }, goalId) { goalId = it }
|
||||||
TrainlogAction(label, id, onClick = { goalId = id },
|
|
||||||
accent = if (goalId == id) colors.success else colors.muted)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
TrainlogFrame("DURÉE") {
|
TrainlogFrame("Durée") {
|
||||||
options.durationPresets.forEach { minutes ->
|
TrainlogChoiceChips(options.durationPresets.map { it.toString() to "$it min" }, durationText) { durationText = it }
|
||||||
TrainlogAction("$minutes min", "Durée disponible.", onClick = { durationText = minutes.toString() },
|
|
||||||
accent = if (durationText == minutes.toString()) colors.success else colors.muted)
|
|
||||||
}
|
|
||||||
TrainlogInputField(
|
TrainlogInputField(
|
||||||
"Durée personnalisée (${options.customMinutes.first} à ${options.customMinutes.last} min)",
|
"Durée personnalisée (${options.customMinutes.first} à ${options.customMinutes.last} min)",
|
||||||
durationText,
|
durationText,
|
||||||
|
|
@ -135,8 +173,11 @@ fun SessionGeneratorScreen(
|
||||||
else generate(SessionGenerationRequest(zoneId, goalId, minutes, OffsetDateTime.now().toString()))
|
else generate(SessionGenerationRequest(zoneId, goalId, minutes, OffsetDateTime.now().toString()))
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
TrainlogFrame("PROPOSITION") {
|
TrainlogFrame("Proposition") {
|
||||||
TrainlogInfo("Durée estimée : ${current.estimatedDurationSeconds / 60} min")
|
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(
|
if (current.insufficientResolvedCandidates) TrainlogInfo(
|
||||||
"La couverture est incomplète faute de contextes résolus disponibles" +
|
"La couverture est incomplète faute de contextes résolus disponibles" +
|
||||||
current.shortageCodes.takeIf { it.isNotEmpty() }
|
current.shortageCodes.takeIf { it.isNotEmpty() }
|
||||||
|
|
@ -166,8 +207,7 @@ fun SessionGeneratorScreen(
|
||||||
TrainlogInfo("Historique récent similaire détecté ; information uniquement.", colors.warning)
|
TrainlogInfo("Historique récent similaire détecté ; information uniquement.", colors.warning)
|
||||||
item.loadSourceStartedAt?.let {
|
item.loadSourceStartedAt?.let {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
"Charge issue d'une dose réellement observée le $it " +
|
"Charge issue d'une dose réellement observée le $it ; " +
|
||||||
"(séance ${item.loadSourceSessionId}, passage ${item.loadSourceOccurrenceId}) ; " +
|
|
||||||
"son applicabilité aujourd'hui reste incertaine.",
|
"son applicabilité aujourd'hui reste incertaine.",
|
||||||
colors.muted,
|
colors.muted,
|
||||||
)
|
)
|
||||||
|
|
@ -180,13 +220,27 @@ fun SessionGeneratorScreen(
|
||||||
TrainlogInputField("Séries", setsText, onValueChange = { setsText = it })
|
TrainlogInputField("Séries", setsText, onValueChange = { setsText = it })
|
||||||
TrainlogInputField("Répétitions", repsText, onValueChange = { repsText = it })
|
TrainlogInputField("Répétitions", repsText, onValueChange = { repsText = it })
|
||||||
TrainlogInputField("Repos (secondes)", restText, onValueChange = { restText = it })
|
TrainlogInputField("Repos (secondes)", restText, onValueChange = { restText = it })
|
||||||
TrainlogInputField(
|
TrainlogInfo("Charge : choix utilisateur, jamais une recommandation.", colors.muted)
|
||||||
"Charge cible manuelle (vide = réévaluer)",
|
TrainlogChoiceChips(
|
||||||
loadText,
|
listOf("AUTOMATIC" to "Automatique", "PERCENT_MAX" to "% MAX", "NONE" to "Aucune"),
|
||||||
onValueChange = { loadText = it },
|
loadChoice.name,
|
||||||
)
|
) { selected ->
|
||||||
|
loadChoice = GeneratorLoadChoice.valueOf(selected)
|
||||||
|
if (loadChoice != GeneratorLoadChoice.AUTOMATIC) loadText = ""
|
||||||
|
}
|
||||||
|
if (loadChoice == GeneratorLoadChoice.PERCENT_MAX)
|
||||||
|
TrainlogInputField("Pourcentage du MAX (1 à 100)", maxPercentText,
|
||||||
|
onValueChange = { maxPercentText = it })
|
||||||
|
if (loadChoice == GeneratorLoadChoice.AUTOMATIC)
|
||||||
|
TrainlogInputField(
|
||||||
|
"Charge cible manuelle (vide = automatique)",
|
||||||
|
loadText,
|
||||||
|
onValueChange = { loadText = it },
|
||||||
|
)
|
||||||
TrainlogAction("Appliquer", "Réestimer la durée et requalifier la charge observée.", accent = colors.success, onClick = {
|
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) {
|
if (parsedWeight.isFailure) {
|
||||||
message = parsedWeight.exceptionOrNull()?.message
|
message = parsedWeight.exceptionOrNull()?.message
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -197,7 +251,8 @@ fun SessionGeneratorScreen(
|
||||||
when (val result = withContext(Dispatchers.IO) {
|
when (val result = withContext(Dispatchers.IO) {
|
||||||
repository.editGeneratedDose(current, index,
|
repository.editGeneratedDose(current, index,
|
||||||
setsText.toIntOrNull() ?: -1, repsText.toIntOrNull() ?: -1,
|
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.Generated -> { preview = result.preview; editingIndex = null; message = null }
|
||||||
is SessionGenerationResult.Invalid -> message = result.message
|
is SessionGenerationResult.Invalid -> message = result.message
|
||||||
|
|
@ -216,6 +271,12 @@ fun SessionGeneratorScreen(
|
||||||
// Preserve an explicit user value across later edits. An
|
// Preserve an explicit user value across later edits. An
|
||||||
// automatic observed value stays display-only: empty asks
|
// automatic observed value stays display-only: empty asks
|
||||||
// the engine to qualify it again for the changed dose.
|
// 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)
|
loadText = if ("manual_target_load" in item.rationaleCodes)
|
||||||
item.plan.weightKg?.toString().orEmpty() else ""
|
item.plan.weightKg?.toString().orEmpty() else ""
|
||||||
}
|
}
|
||||||
|
|
@ -252,14 +313,16 @@ fun SessionGeneratorScreen(
|
||||||
if (!busy) onBack()
|
if (!busy) onBack()
|
||||||
}, accent = colors.muted)
|
}, accent = colors.muted)
|
||||||
}
|
}
|
||||||
if (busy) TrainlogFrame("TRAITEMENT") { TrainlogInfo("Analyse en cours…", colors.muted) }
|
if (busy) TrainlogFrame("Traitement") { TrainlogInfo("Analyse en cours…", colors.muted) }
|
||||||
message?.let { TrainlogFrame("MESSAGE") { TrainlogInfo(it, colors.error) } }
|
message?.let { TrainlogFrame("Message") { TrainlogInfo(it, colors.error) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generationReasonLabel(code: String): String = when (code) {
|
private fun generationReasonLabel(code: String): String = when (code) {
|
||||||
"observed_repeated_dose_anchor" -> "dose répétée observée"
|
"observed_repeated_dose_anchor" -> "dose répétée observée"
|
||||||
"explicit_max_present_no_numeric_prescription" -> "maximum observé sans prescription numérique"
|
"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"
|
"assistance_numeric_load_omitted" -> "charge d'assistance omise"
|
||||||
"numeric_load_absent" -> "charge numérique absente"
|
"numeric_load_absent" -> "charge numérique absente"
|
||||||
"manual_target_load" -> "charge saisie manuellement"
|
"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.CreateEquipmentResult
|
||||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||||
|
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
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.RecordingMode
|
||||||
import com.labfytools.trainlog.model.SessionDraftForm
|
import com.labfytools.trainlog.model.SessionDraftForm
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
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.SessionSetDraft
|
||||||
import com.labfytools.trainlog.model.SessionType
|
import com.labfytools.trainlog.model.SessionType
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
|
|
@ -124,20 +127,11 @@ fun SessionScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogScreen(
|
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) {
|
if (activeDraft == null) {
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "ERREUR"
|
title = "Erreur"
|
||||||
) {
|
) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
text = message.orEmpty(),
|
text = message.orEmpty(),
|
||||||
|
|
@ -169,7 +163,7 @@ fun SessionScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "TYPE DE SEANCE"
|
title = "Type de séance"
|
||||||
) {
|
) {
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label =
|
label =
|
||||||
|
|
@ -252,7 +246,7 @@ fun SessionScreen(
|
||||||
)
|
)
|
||||||
|
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "SEANCE EN COURS"
|
title = "Séance en cours"
|
||||||
) {
|
) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
text =
|
text =
|
||||||
|
|
@ -367,6 +361,9 @@ fun SessionScreen(
|
||||||
sessionType = currentDraft.sessionType,
|
sessionType = currentDraft.sessionType,
|
||||||
initialForm =
|
initialForm =
|
||||||
currentDraft.form,
|
currentDraft.form,
|
||||||
|
initialPlan = currentDraft.form.editingExerciseIndex?.let {
|
||||||
|
currentDraft.exercises.getOrNull(it)?.plan
|
||||||
|
},
|
||||||
onFormChanged = {
|
onFormChanged = {
|
||||||
form ->
|
form ->
|
||||||
persistDraft(
|
persistDraft(
|
||||||
|
|
@ -397,9 +394,7 @@ fun SessionScreen(
|
||||||
currentDraft.exercises.mapIndexed { index, existing ->
|
currentDraft.exercises.mapIndexed { index, existing ->
|
||||||
/* INVARIANT: normal performed-value edits
|
/* INVARIANT: normal performed-value edits
|
||||||
* preserve generator planning metadata. */
|
* preserve generator planning metadata. */
|
||||||
if (index == replacingIndex) {
|
if (index == replacingIndex) draft else existing
|
||||||
draft.copy(plan = existing.plan)
|
|
||||||
} else existing
|
|
||||||
}
|
}
|
||||||
} ?: (currentDraft.exercises + draft),
|
} ?: (currentDraft.exercises + draft),
|
||||||
form =
|
form =
|
||||||
|
|
@ -412,7 +407,7 @@ fun SessionScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "EXERCICES"
|
title = "Exercices"
|
||||||
) {
|
) {
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label =
|
label =
|
||||||
|
|
@ -427,7 +422,7 @@ fun SessionScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "ENREGISTREMENT",
|
title = "Enregistrement",
|
||||||
active =
|
active =
|
||||||
currentDraft.exercises.isNotEmpty(),
|
currentDraft.exercises.isNotEmpty(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -554,6 +549,7 @@ private fun CatalogChoice(
|
||||||
.then(modifier)
|
.then(modifier)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 2.dp)
|
.padding(vertical = 2.dp)
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
.background(
|
.background(
|
||||||
if (selected) {
|
if (selected) {
|
||||||
colors.surfaceAlt
|
colors.surfaceAlt
|
||||||
|
|
@ -621,7 +617,7 @@ private fun ExercisePicker(
|
||||||
var query by remember(selectedExercise?.exerciseId) { mutableStateOf("") }
|
var query by remember(selectedExercise?.exerciseId) { mutableStateOf("") }
|
||||||
val results = remember(exercises, query) { exercisePrefixMatches(exercises, query) }
|
val results = remember(exercises, query) { exercisePrefixMatches(exercises, query) }
|
||||||
|
|
||||||
TrainlogFrame(title = "EXERCICE", active = exercises.isNotEmpty()) {
|
TrainlogFrame(title = "Exercice", active = exercises.isNotEmpty()) {
|
||||||
if (exercises.isEmpty()) {
|
if (exercises.isEmpty()) {
|
||||||
TrainlogInfo("Aucun exercice.")
|
TrainlogInfo("Aucun exercice.")
|
||||||
return@TrainlogFrame
|
return@TrainlogFrame
|
||||||
|
|
@ -704,6 +700,65 @@ private fun normalizeExerciseSearchText(value: String): String =
|
||||||
.trim()
|
.trim()
|
||||||
.replace("\\s+".toRegex(), " ")
|
.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
|
@Composable
|
||||||
private fun SessionExerciseForm(
|
private fun SessionExerciseForm(
|
||||||
key: String,
|
key: String,
|
||||||
|
|
@ -711,6 +766,7 @@ private fun SessionExerciseForm(
|
||||||
exercise: ExerciseProfile,
|
exercise: ExerciseProfile,
|
||||||
sessionType: SessionType,
|
sessionType: SessionType,
|
||||||
initialForm: SessionDraftForm,
|
initialForm: SessionDraftForm,
|
||||||
|
initialPlan: SessionExercisePlan?,
|
||||||
onFormChanged: (SessionDraftForm) -> Unit,
|
onFormChanged: (SessionDraftForm) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onAdd:
|
onAdd:
|
||||||
|
|
@ -790,6 +846,14 @@ private fun SessionExerciseForm(
|
||||||
var selectedEquipmentId by remember(key) {
|
var selectedEquipmentId by remember(key) {
|
||||||
mutableStateOf(initialForm.selectedEquipmentId)
|
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
|
var error by
|
||||||
remember(key) {
|
remember(key) {
|
||||||
|
|
@ -836,6 +900,17 @@ private fun SessionExerciseForm(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
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) {
|
if (selectedEquipment != null) {
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label = "✓ ${selectedEquipment.displayName}",
|
label = "✓ ${selectedEquipment.displayName}",
|
||||||
|
|
@ -883,6 +958,45 @@ private fun SessionExerciseForm(
|
||||||
exercise.recordingMode ==
|
exercise.recordingMode ==
|
||||||
RecordingMode.SETS
|
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 (
|
if (
|
||||||
exercise.trackingMode ==
|
exercise.trackingMode ==
|
||||||
TrackingMode.REPS
|
TrackingMode.REPS
|
||||||
|
|
@ -1153,8 +1267,18 @@ private fun SessionExerciseForm(
|
||||||
} else {
|
} else {
|
||||||
"Valeurs invalides."
|
"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 {
|
} 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(
|
LaunchedEffect(
|
||||||
pendingRequestId
|
pendingRequestId
|
||||||
) {
|
) {
|
||||||
|
|
@ -265,18 +234,10 @@ fun SyncScreen(
|
||||||
|
|
||||||
TrainlogScreen(
|
TrainlogScreen(
|
||||||
subtitle =
|
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(
|
TrainlogFrame(
|
||||||
title = "SYNCHRONISER"
|
title = "Synchroniser"
|
||||||
) {
|
) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
text =
|
text =
|
||||||
|
|
@ -430,7 +391,7 @@ fun SyncScreen(
|
||||||
|
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
TrainlogFrame(
|
TrainlogFrame(
|
||||||
title = "ETAT",
|
title = "État",
|
||||||
active = false,
|
active = false,
|
||||||
) {
|
) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
|
|
|
||||||
|
|
@ -2,329 +2,134 @@ package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
/* TRAINLOG_PC_CATALOG_AUTO_APPLY */
|
/* 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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
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.CatalogInboxResult
|
||||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||||
|
import com.labfytools.trainlog.data.SyncExporter
|
||||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||||
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
private enum class TrainlogScreenId {
|
|
||||||
HOME,
|
|
||||||
SESSION,
|
|
||||||
SESSION_GENERATOR,
|
|
||||||
EXERCISE,
|
|
||||||
BODY,
|
|
||||||
HISTORY,
|
|
||||||
SESSION_DETAIL,
|
|
||||||
SYNC,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TrainlogApp(
|
fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) {
|
||||||
repository: TrainlogRepository,
|
val navigation = appState.navigation
|
||||||
exporter: SyncExporter,
|
val generatorState = appState.generator
|
||||||
inbox: SyncCatalogInbox,
|
val equipmentState = appState.equipment
|
||||||
requestOutbox: SyncRequestOutbox,
|
val exerciseState = appState.exercise
|
||||||
) {
|
val bodyState = appState.body
|
||||||
var screen by
|
var catalogRevision by remember { mutableIntStateOf(0) }
|
||||||
remember {
|
var draftRevision by remember { mutableIntStateOf(0) }
|
||||||
mutableStateOf(
|
var draftMessage by remember { mutableStateOf<String?>(null) }
|
||||||
TrainlogScreenId.HOME
|
val navigationController = appState.navigationController
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* CONTRACT: automatic exchange work belongs to the application lifecycle.
|
||||||
|
* Re-entering a route must never trigger a second import or export. */
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
when (
|
when (inbox.importPcCatalog()) {
|
||||||
inbox.importPcCatalog()
|
is CatalogInboxResult.Imported -> catalogRevision++
|
||||||
) {
|
CatalogInboxResult.FolderNotAuthorized, CatalogInboxResult.FileNotFound,
|
||||||
is CatalogInboxResult.Imported -> {
|
is CatalogInboxResult.Error -> Unit
|
||||||
catalogRevision += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
CatalogInboxResult.FolderNotAuthorized,
|
|
||||||
CatalogInboxResult.FileNotFound,
|
|
||||||
is CatalogInboxResult.Error -> {
|
|
||||||
/* Nothing to import yet. */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
exporter.exportMobileBundle()
|
exporter.exportMobileBundle()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
val draftLoad = remember(draftRevision, catalogRevision) { repository.loadActiveSessionDraft() }
|
||||||
screen !=
|
val activeDraft = (draftLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
||||||
TrainlogScreenId.HOME
|
|
||||||
) {
|
|
||||||
BackHandler {
|
|
||||||
screen =
|
|
||||||
when (screen) {
|
|
||||||
TrainlogScreenId.EXERCISE ->
|
|
||||||
exerciseReturnTarget
|
|
||||||
|
|
||||||
TrainlogScreenId.SESSION_DETAIL ->
|
fun open(route: AppRoute) { navigationController.open(route) }
|
||||||
TrainlogScreenId.HISTORY
|
fun openSection(section: AppSection) { navigationController.openSection(section) }
|
||||||
|
fun back(): Boolean = navigationController.back()
|
||||||
else ->
|
fun openManualSession() {
|
||||||
TrainlogScreenId.HOME
|
when (draftLoad) {
|
||||||
}
|
is ActiveDraftLoadResult.Loaded -> open(AppRoute.SessionEditor)
|
||||||
|
ActiveDraftLoadResult.None -> when (val result = repository.startActiveSessionDraft()) {
|
||||||
|
ActiveDraftMutationResult.Saved -> { draftRevision++; open(AppRoute.SessionEditor) }
|
||||||
|
is ActiveDraftMutationResult.Error -> draftMessage = result.message
|
||||||
|
}
|
||||||
|
is ActiveDraftLoadResult.Error -> draftMessage = draftLoad.message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (screen) {
|
val routeStateHolder = rememberSaveableStateHolder()
|
||||||
TrainlogScreenId.HOME -> {
|
val retainedRouteKeys = remember { ArrayDeque<String>() }
|
||||||
val draftLoad =
|
val routeKey = navigation.route.stateKey()
|
||||||
remember(
|
LaunchedEffect(routeKey) {
|
||||||
draftRevision,
|
retainedRouteKeys.remove(routeKey)
|
||||||
catalogRevision,
|
retainedRouteKeys.addLast(routeKey)
|
||||||
) {
|
while (retainedRouteKeys.size > 16) routeStateHolder.removeState(retainedRouteKeys.removeFirst())
|
||||||
repository.loadActiveSessionDraft()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
HomeScreen(
|
AndroidAppShell(navigation.route, ::openSection, ::back) {
|
||||||
activeDraft =
|
routeStateHolder.SaveableStateProvider(routeKey) {
|
||||||
(draftLoad as?
|
when (val route = navigation.route) {
|
||||||
ActiveDraftLoadResult.Loaded)
|
AppRoute.Home -> HomeScreen(
|
||||||
?.draft,
|
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
|
||||||
draftError =
|
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
|
||||||
draftMessage
|
::openManualSession, { open(AppRoute.SessionGenerator) },
|
||||||
?: (draftLoad as?
|
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||||
ActiveDraftLoadResult.Error)
|
|
||||||
?.message
|
|
||||||
?: (draftLoad as?
|
|
||||||
ActiveDraftLoadResult.Loaded)
|
|
||||||
?.warning,
|
|
||||||
onSession = {
|
|
||||||
when (draftLoad) {
|
|
||||||
is ActiveDraftLoadResult.Loaded -> {
|
|
||||||
draftMessage = null
|
|
||||||
screen = TrainlogScreenId.SESSION
|
|
||||||
}
|
|
||||||
|
|
||||||
ActiveDraftLoadResult.None -> {
|
|
||||||
when (
|
|
||||||
val result =
|
|
||||||
repository
|
|
||||||
.startActiveSessionDraft()
|
|
||||||
) {
|
|
||||||
ActiveDraftMutationResult.Saved -> {
|
|
||||||
draftMessage = null
|
|
||||||
draftRevision += 1
|
|
||||||
screen =
|
|
||||||
TrainlogScreenId.SESSION
|
|
||||||
}
|
|
||||||
|
|
||||||
is ActiveDraftMutationResult.Error -> {
|
|
||||||
draftMessage = result.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ActiveDraftLoadResult.Error -> {
|
|
||||||
draftMessage = draftLoad.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onGenerateSession = {
|
|
||||||
draftMessage = null
|
|
||||||
screen = TrainlogScreenId.SESSION_GENERATOR
|
|
||||||
},
|
|
||||||
onDiscardDraft = {
|
|
||||||
when (
|
|
||||||
val result =
|
|
||||||
repository
|
|
||||||
.discardActiveSessionDraft()
|
|
||||||
) {
|
|
||||||
ActiveDraftMutationResult.Saved -> {
|
|
||||||
draftMessage = null
|
|
||||||
draftRevision += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
is ActiveDraftMutationResult.Error -> {
|
|
||||||
draftMessage = result.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onExercise = {
|
|
||||||
exerciseReturnTarget =
|
|
||||||
TrainlogScreenId.HOME
|
|
||||||
|
|
||||||
screen =
|
|
||||||
TrainlogScreenId.EXERCISE
|
|
||||||
},
|
|
||||||
onBody = {
|
|
||||||
screen =
|
|
||||||
TrainlogScreenId.BODY
|
|
||||||
},
|
|
||||||
onHistory = {
|
|
||||||
screen =
|
|
||||||
TrainlogScreenId.HISTORY
|
|
||||||
},
|
|
||||||
onSync = {
|
|
||||||
screen =
|
|
||||||
TrainlogScreenId.SYNC
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
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 ->
|
if (navigationController.hasPendingNavigation) {
|
||||||
SessionScreen(
|
val guardedRoute = navigationController.pendingRoute
|
||||||
repository = repository,
|
val generator = guardedRoute == AppRoute.SessionGenerator
|
||||||
catalogRevision =
|
AlertDialog(
|
||||||
catalogRevision,
|
onDismissRequest = navigationController::cancelPending,
|
||||||
onBack = {
|
title = { Text(if (generator) "Proposition non acceptée" else "Modifications non enregistrées") },
|
||||||
/* WHY: Back changes routing only; the repository remains
|
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.") },
|
||||||
* the canonical owner of the in-progress workout. */
|
confirmButton = { TextButton(onClick = navigationController::keepAndNavigate) { Text("Conserver et quitter") } },
|
||||||
draftRevision += 1
|
dismissButton = {
|
||||||
screen =
|
TextButton(onClick = {
|
||||||
TrainlogScreenId.HOME
|
navigationController.discardAndNavigate()
|
||||||
},
|
}) { Text(if (generator) "Abandonner la proposition" else "Abandonner les modifications") }
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
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.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TrainlogScreen(
|
fun TrainlogScreen(
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
|
scrollKey: String = subtitle,
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
|
|
@ -49,8 +55,6 @@ fun TrainlogScreen(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.background(colors.background)
|
.background(colors.background)
|
||||||
.statusBarsPadding()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.verticalScroll(
|
.verticalScroll(
|
||||||
rememberScrollState()
|
rememberScrollState()
|
||||||
|
|
@ -62,51 +66,16 @@ fun TrainlogScreen(
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
TrainlogBanner(
|
BasicText(
|
||||||
subtitle = subtitle
|
text = subtitle.lowercase().replaceFirstChar { it.titlecase() },
|
||||||
|
modifier = Modifier.padding(bottom = 18.dp),
|
||||||
|
style = TrainlogTypography.title.copy(color = colors.text),
|
||||||
)
|
)
|
||||||
|
|
||||||
content()
|
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
|
@Composable
|
||||||
fun TrainlogFrame(
|
fun TrainlogFrame(
|
||||||
title: String,
|
title: String,
|
||||||
|
|
@ -119,7 +88,7 @@ fun TrainlogFrame(
|
||||||
|
|
||||||
val accent =
|
val accent =
|
||||||
if (active) {
|
if (active) {
|
||||||
colors.warning
|
colors.accent
|
||||||
} else {
|
} else {
|
||||||
colors.muted
|
colors.muted
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +100,7 @@ fun TrainlogFrame(
|
||||||
.padding(bottom = 16.dp)
|
.padding(bottom = 16.dp)
|
||||||
) {
|
) {
|
||||||
BasicText(
|
BasicText(
|
||||||
text = title.uppercase(),
|
text = title,
|
||||||
style =
|
style =
|
||||||
TrainlogTypography.small.copy(
|
TrainlogTypography.small.copy(
|
||||||
color = accent,
|
color = accent,
|
||||||
|
|
@ -149,7 +118,7 @@ fun TrainlogFrame(
|
||||||
bottom = 8.dp,
|
bottom = 8.dp,
|
||||||
)
|
)
|
||||||
.height(1.dp)
|
.height(1.dp)
|
||||||
.background(accent)
|
.background(colors.surfaceAlt)
|
||||||
)
|
)
|
||||||
|
|
||||||
content()
|
content()
|
||||||
|
|
@ -175,18 +144,11 @@ fun TrainlogAction(
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 3.dp)
|
.padding(vertical = 3.dp)
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
.height(IntrinsicSize.Min)
|
.height(IntrinsicSize.Min)
|
||||||
.background(colors.surface)
|
.background(Color.Transparent)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
) {
|
) {
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.width(3.dp)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.background(actualAccent)
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.padding(
|
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
|
@Composable
|
||||||
fun TrainlogInputField(
|
fun TrainlogInputField(
|
||||||
label: String,
|
label: String,
|
||||||
|
|
@ -269,12 +270,18 @@ fun TrainlogInputField(
|
||||||
cursorBrush =
|
cursorBrush =
|
||||||
SolidColor(colors.accent),
|
SolidColor(colors.accent),
|
||||||
textStyle =
|
textStyle =
|
||||||
TrainlogTypography.normal.copy(
|
(if (keyboardOptions.keyboardType == KeyboardType.Number ||
|
||||||
|
keyboardOptions.keyboardType == KeyboardType.Decimal) {
|
||||||
|
TrainlogTypography.numeric
|
||||||
|
} else {
|
||||||
|
TrainlogTypography.normal
|
||||||
|
}).copy(
|
||||||
color = colors.text,
|
color = colors.text,
|
||||||
),
|
),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
focused = it.isFocused
|
focused = it.isFocused
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@ package com.labfytools.trainlog.ui.theme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
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.graphics.Color
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
data class TrainlogColors(
|
data class TrainlogColors(
|
||||||
|
|
@ -19,20 +22,30 @@ data class TrainlogColors(
|
||||||
val error: Color,
|
val error: Color,
|
||||||
val muted: Color,
|
val muted: Color,
|
||||||
val graph: Color,
|
val graph: Color,
|
||||||
|
val crust: Color,
|
||||||
|
val mantle: Color,
|
||||||
|
val lavender: Color,
|
||||||
|
val info: Color,
|
||||||
|
val notice: Color,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val TrainlogDarkColors =
|
private val TrainlogDarkColors =
|
||||||
TrainlogColors(
|
TrainlogColors(
|
||||||
background = Color(0xFF1E1E2E),
|
background = Color(0xFF1E1E2E),
|
||||||
surface = Color(0xFF181825),
|
surface = Color(0xFF313244),
|
||||||
surfaceAlt = Color(0xFF313244),
|
surfaceAlt = Color(0xFF45475A),
|
||||||
text = Color(0xFFCDD6F4),
|
text = Color(0xFFCDD6F4),
|
||||||
accent = Color(0xFF94E2D5),
|
accent = Color(0xFFB4BEFE),
|
||||||
success = Color(0xFFA6E3A1),
|
success = Color(0xFFA6E3A1),
|
||||||
warning = Color(0xFFF9E2AF),
|
warning = Color(0xFFF9E2AF),
|
||||||
error = Color(0xFFF38BA8),
|
error = Color(0xFFF38BA8),
|
||||||
muted = Color(0xFF89B4FA),
|
muted = Color(0xFFBAC2DE),
|
||||||
graph = Color(0xFFF5C2E7),
|
graph = Color(0xFFF5C2E7),
|
||||||
|
crust = Color(0xFF11111B),
|
||||||
|
mantle = Color(0xFF181825),
|
||||||
|
lavender = Color(0xFFB4BEFE),
|
||||||
|
info = Color(0xFF89B4FA),
|
||||||
|
notice = Color(0xFFFAB387),
|
||||||
)
|
)
|
||||||
|
|
||||||
val LocalTrainlogColors =
|
val LocalTrainlogColors =
|
||||||
|
|
@ -43,36 +56,55 @@ val LocalTrainlogColors =
|
||||||
object TrainlogTypography {
|
object TrainlogTypography {
|
||||||
val normal =
|
val normal =
|
||||||
TextStyle(
|
TextStyle(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Default,
|
||||||
fontSize = 15.sp,
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
val small =
|
val small =
|
||||||
TextStyle(
|
TextStyle(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Default,
|
||||||
fontSize = 12.sp,
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
val title =
|
val title =
|
||||||
TextStyle(
|
TextStyle(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Default,
|
||||||
fontSize = 19.sp,
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
|
|
||||||
val banner =
|
val banner =
|
||||||
TextStyle(
|
TextStyle(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Default,
|
||||||
fontSize = 22.sp,
|
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
|
@Composable
|
||||||
fun TrainlogTheme(
|
fun TrainlogTheme(
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
val scheme = darkColorScheme(
|
||||||
LocalTrainlogColors provides
|
primary = TrainlogDarkColors.lavender,
|
||||||
TrainlogDarkColors,
|
onPrimary = TrainlogDarkColors.crust,
|
||||||
content = content,
|
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() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
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")) {
|
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(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"))
|
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.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
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 ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
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(
|
SQLiteDatabase.openDatabase(
|
||||||
migratedPath.path, null, SQLiteDatabase.OPEN_READWRITE,
|
migratedPath.path, null, SQLiteDatabase.OPEN_READWRITE,
|
||||||
).use { migrated ->
|
).use { migrated ->
|
||||||
assertEquals(11, scalarInt(migrated, "PRAGMA user_version;"))
|
assertEquals(12, scalarInt(migrated, "PRAGMA user_version;"))
|
||||||
assertEquals("ok", scalarString(migrated, "PRAGMA integrity_check;"))
|
assertEquals("ok", scalarString(migrated, "PRAGMA integrity_check;"))
|
||||||
migrated.rawQuery("PRAGMA foreign_key_check;", null).use {
|
migrated.rawQuery("PRAGMA foreign_key_check;", null).use {
|
||||||
assertFalse(it.moveToFirst())
|
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
|
@Test
|
||||||
fun generationStreamsHistoryBeyondLegacyOccurrenceAndSetPreviewLimits() {
|
fun generationStreamsHistoryBeyondLegacyOccurrenceAndSetPreviewLimits() {
|
||||||
val repo = openRepository()
|
val repo = openRepository()
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,109 @@ class TrainlogRepositoryDraftTest {
|
||||||
context.deleteDatabase(databaseName)
|
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
|
@Test
|
||||||
fun durableDraftRestoresEveryExerciseShapeAndRawForm() {
|
fun durableDraftRestoresEveryExerciseShapeAndRawForm() {
|
||||||
val first = openRepository()
|
val first = openRepository()
|
||||||
|
|
@ -1505,7 +1608,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(11, cursor.getInt(0))
|
assertEquals(12, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -1632,7 +1735,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
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 ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
@ -1788,6 +1891,54 @@ class TrainlogRepositoryDraftTest {
|
||||||
assertEquals(sessionId, loadDraft(reopened).sourceSessionId)
|
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
|
@Test
|
||||||
fun versionNineConvertsOnlyUnambiguousLegacyMaxEntries() {
|
fun versionNineConvertsOnlyUnambiguousLegacyMaxEntries() {
|
||||||
val initial = openRepository()
|
val initial = openRepository()
|
||||||
|
|
@ -2110,6 +2261,41 @@ class TrainlogRepositoryDraftTest {
|
||||||
assertEquals(3, exported.getJSONArray("sets").length())
|
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 {
|
private fun openRepository(): TrainlogRepository {
|
||||||
return TrainlogRepository(context, databaseName).also { repository = it }
|
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.RecordingMode
|
||||||
import com.labfytools.trainlog.model.SessionDraftForm
|
import com.labfytools.trainlog.model.SessionDraftForm
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
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.SessionSetDraft
|
||||||
|
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||||
|
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
|
@ -125,6 +129,44 @@ class SessionSetRowEditorTest {
|
||||||
assertEquals("sxe_test", form.editingEntryId)
|
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 =
|
private val repsExercise =
|
||||||
ExerciseProfile(
|
ExerciseProfile(
|
||||||
exerciseId = "ex_00000000-0000-4000-8000-000000000001",
|
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
|
```text
|
||||||
Accueil
|
Accueil
|
||||||
├── Reprendre la séance en cours (si un brouillon existe)
|
Séances
|
||||||
├── Enregistrer une séance
|
Exercices
|
||||||
├── Enregistrer un exercice
|
Équipements
|
||||||
├── Enregistrer des mensurations
|
Statistiques
|
||||||
├── Historique des séances
|
Synchronisation
|
||||||
└── Synchroniser avec le PC
|
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
|
## 3. Local persistence
|
||||||
|
|
||||||
Android local database version:
|
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`,
|
Schema v4 introduced `active_session_draft`, `draft_session_exercises`,
|
||||||
`draft_performed_sets` and `draft_continuous_activity`. The implemented
|
`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,
|
observations and the draft while adding the shared equipment catalogue,
|
||||||
occurrence-level equipment links and stable completed/draft `entry_id` values.
|
occurrence-level equipment links and stable completed/draft `entry_id` values.
|
||||||
Exactly one active draft is supported; it is separate from completed history.
|
Exactly one active draft is supported; it is separate from completed history.
|
||||||
Schema v9 adds explicit completed/draft MAX results. Schema v10 additively
|
Schema v9 adds explicit completed/draft MAX results. Schema v10 additively
|
||||||
stores direct primary/secondary body-zone relations and their private sync
|
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
|
## 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
|
nonempty partial results may be accepted and edited normally. Final completion
|
||||||
continues to require actual captured work.
|
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
|
## 4. Exercise catalog
|
||||||
|
|
||||||
Exercise creation records:
|
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
|
profile result rather than silently reinterpreting work or creating another
|
||||||
exercise. Renaming remains available independently.
|
exercise. Renaming remains available independently.
|
||||||
|
|
||||||
The shared Compose `TrainlogScreen` header is used by Accueil, Séance,
|
The shared Compose shell supplies navigation context and the page header. Its
|
||||||
Exercice, Mensurations, Historique, Détail séance and Sync. Its compact
|
content host provides one scrollable destination area; screens do not redraw a
|
||||||
`◆ TRAINLOG ◆` accent plaque and muted subtitle intentionally mirror the
|
global banner or own a competing navigation control.
|
||||||
Notcurses TUI identity in a flat mobile layout.
|
|
||||||
|
|
||||||
## 5. Session recording
|
## 5. Session recording
|
||||||
|
|
||||||
|
|
@ -364,8 +433,14 @@ result.
|
||||||
|
|
||||||
Before applying the PC catalog or its V2 artifacts, Android applies
|
Before applying the PC catalog or its V2 artifacts, Android applies
|
||||||
`trainlog-pc-equipment-definitions-v1.json`. Thus custom definitions are known
|
`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`.
|
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
|
After catalog/session/equipment reconciliation, Android applies the same
|
||||||
body-zone companion so custom exercises receive their classifications.
|
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.
|
`local.properties` is local machine configuration and must not be committed.
|
||||||
|
|
||||||
The current JVM host regression suite has 44 passing tests when the retained
|
The APP_SHELL_V1 Android validation ran the JVM command above successfully:
|
||||||
real v9 fixture is enabled. The prior device
|
84 tests ran, with 83 passing and one skipped historical-fixture test. The skip
|
||||||
instrumentation suite had 5 tests (2 repository, 3 production-screen UI tests
|
is `RealAndroidV9BodyZonesMigrationTest.realVersionNineCopyMigratesWithoutChangingExistingTables`,
|
||||||
using an isolated database and no shared export). That device matrix exercised
|
whose external `TRAINLOG_ANDROID_V9_FIXTURE` was unavailable. The build
|
||||||
production `MainActivity`,
|
produced `app/build/outputs/apk/debug/app-debug.apk` (12,649,975 bytes;
|
||||||
including verified process exit with `am kill`, force-stop, configuration
|
SHA-256 `ddb1221d25db5be60eb2261d4b1dcf0fb7446e2a780c67aae862f37c15b7396d`).
|
||||||
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 host regressions include the production Compose callback for an existing
|
||||||
The schema-v8 definition change is recorded as targeted JVM validation, not a
|
active draft: it verifies that the generator's route, preview and raw input
|
||||||
blanket device-validation claim. See [tests](tests.md) for commands and the
|
remain intact until the explicit keep/discard decision, and that keeping does
|
||||||
precise validation boundary.
|
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
|
## 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
|
collapse and case folding without accent removal. Existing Marche/Leg press
|
||||||
data is unaffected, but changing this safely requires an explicit Android
|
data is unaffected, but changing this safely requires an explicit Android
|
||||||
schema migration that recomputes every normalized key and handles newly exposed
|
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,
|
The bundled exercise/equipment relationship metadata is seeded and preserved,
|
||||||
including during exercise-identity reconciliation, but the current equipment
|
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
|
Cursors order current data chronologically by original timestamp, session ID
|
||||||
and occurrence ID, and do not preserve a snapshot across calls.
|
and occurrence ID, and do not preserve a snapshot across calls.
|
||||||
|
|
||||||
This read-only feature makes no Android schema change (the runtime schema
|
This read-only feature makes no further Android schema change (the runtime
|
||||||
remains v10), does not seed rows, and does not export/synchronize new data. It
|
schema remains v11), does not seed rows, and does not export/synchronize new
|
||||||
does not implement recommendations, planned weights, set counts or fatigue
|
training-knowledge data. It does not implement recommendations or fatigue
|
||||||
scores. Its occurrence and latest-MAX readers use the same explicit temporal
|
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.
|
grammar, exact fractional comparison and bytewise ID tie breakers as C.
|
||||||
The Android writer's omitted-seconds form is admitted, and emitted cursors
|
The Android writer's omitted-seconds form is admitted, and emitted cursors
|
||||||
retain the original source text. Production pagination/MAX parity tests pass.
|
retain the original source text. Production pagination/MAX parity tests pass.
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,34 @@ It consumes core services for:
|
||||||
- manual synchronization;
|
- manual synchronization;
|
||||||
- synchronization log/detail display.
|
- 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`
|
||||||
|
|
||||||
`trainlog-syncd` is a small user-session agent.
|
`trainlog-syncd` is a small user-session agent.
|
||||||
|
|
@ -140,7 +168,7 @@ silently rendered as unclassified.
|
||||||
|
|
||||||
### Desktop
|
### 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
|
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
|
||||||
be finite `>= 0`; the column already existed and targets/max results retain
|
be finite `>= 0`; the column already existed and targets/max results retain
|
||||||
their strictly-positive contracts. `session_exercises`
|
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
|
whose decision evidence is recorded in the manifest. It never rewrites an
|
||||||
exercise, occurrence or history row.
|
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:
|
Main tables:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -169,7 +203,7 @@ exercise_body_zone_sync
|
||||||
|
|
||||||
### Android
|
### 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;
|
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
|
resuming a completed Test max records its stable source session in the one
|
||||||
durable draft.
|
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
|
sides; the richer mask is retained and every occurrence/draft reference moves
|
||||||
transactionally. Otherwise synchronization reports a conflict.
|
transactionally. Otherwise synchronization reports a conflict.
|
||||||
|
|
||||||
Compose presentation has one `TrainlogScreen` header component for every page.
|
Compose presentation has one fixed `AndroidAppShell` Material 3 `Scaffold`
|
||||||
It uses the TUI's compact accent `◆ TRAINLOG ◆` plaque and muted context line;
|
with a `TopAppBar` above its page-content host. Root routes show `TRAINLOG`;
|
||||||
screen navigation and data ownership remain independent from the header.
|
non-root routes show their route title. Screen navigation and data ownership
|
||||||
|
remain independent from this shell.
|
||||||
|
|
||||||
## 5. Compatibility boundaries
|
## 5. Compatibility boundaries
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,9 @@ BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
|
||||||
|
|
||||||
TRAINING_KNOWLEDGE_V1=PASS
|
TRAINING_KNOWLEDGE_V1=PASS
|
||||||
SESSION_GENERATOR_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
|
ANDROID_BUILD=PASS
|
||||||
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
|
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
|
||||||
```
|
```
|
||||||
|
|
@ -75,10 +76,12 @@ HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
|
||||||
|
|
||||||
Implemented:
|
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
|
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
|
||||||
minimum terminal;
|
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
|
occurrence-level equipment identity, and desktop-local custom-equipment
|
||||||
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
|
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
|
||||||
rebuilds only `performed_sets` to permit explicit zero actual loads while
|
rebuilds only `performed_sets` to permit explicit zero actual loads while
|
||||||
|
|
@ -105,18 +108,21 @@ Implemented:
|
||||||
- manual Android -> PC, PC -> Android, and bidirectional synchronization;
|
- manual Android -> PC, PC -> Android, and bidirectional synchronization;
|
||||||
- structured synchronization history and detail.
|
- structured synchronization history and detail.
|
||||||
|
|
||||||
Primary navigation:
|
Primary navigation is now shared with Android:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
0 Accueil
|
Accueil | Séances | Exercices | Équipements | Statistiques | Synchronisation | Paramètres
|
||||||
1 Séance
|
|
||||||
2 Historique
|
|
||||||
3 Exercices
|
|
||||||
4 Équipements
|
|
||||||
5 Corps
|
|
||||||
6 Sync
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`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
|
## Android
|
||||||
|
|
||||||
Implemented:
|
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
|
the existing desktop identity is canonical on desktop. The richer bit-mask
|
||||||
union is retained without rewriting historical occurrence snapshots.
|
union is retained without rewriting historical occurrence snapshots.
|
||||||
|
|
||||||
All Android screens use the shared compact `◆ TRAINLOG ◆` header: the
|
All Android screens are hosted by the fixed `AndroidAppShell` Material 3
|
||||||
Notcurses accent, muted context line, and flat touch layout reproduce the TUI
|
`Scaffold` and `TopAppBar`: root routes show `TRAINLOG`, non-root routes show
|
||||||
plaque without literal terminal box drawing.
|
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
|
## 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
|
catalogs as the sole authored scientific source, generates the immutable C
|
||||||
catalog representation, and loads the same assets on Android. It has no
|
catalog representation, and loads the same assets on Android. It has no
|
||||||
database migration, no auto-seeding, and no synchronization artifact. The
|
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
|
Desktop `training_knowledge.h` and Android `TrainingKnowledgeCatalog` expose
|
||||||
source-linked science lookups and resolved-candidate filters. Desktop
|
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
|
passed. Generated C is byte-identical with SHA-256
|
||||||
`e8c099f67eb111d61621b5d76592c049823af5508d43e73ec646f22e4c377fca`; all six
|
`e8c099f67eb111d61621b5d76592c049823af5508d43e73ec646f22e4c377fca`; all six
|
||||||
Android assets are byte-identical. Preservation before and after repair confirms
|
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`,
|
database logical SHA-256 `26139cafeffbde3ec08f6ef23c5069e75afb9cd69ffffb40be5c099006fedc4d`,
|
||||||
counts, integrity, and foreign keys. `TRAINING_KNOWLEDGE_V1=PASS`. The
|
counts, integrity, and foreign keys. `TRAINING_KNOWLEDGE_V1=PASS`. The
|
||||||
[temporal contract](reviews/training_knowledge_v1_temporal_contract.md)
|
[temporal contract](reviews/training_knowledge_v1_temporal_contract.md)
|
||||||
|
|
@ -327,10 +334,10 @@ upgrade/install or hardware MTP exercise is claimed.
|
||||||
Desktop:
|
Desktop:
|
||||||
|
|
||||||
```text
|
```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
|
JSON valid/invalid checks PASS
|
||||||
import-contract validator 6/6 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
|
standalone public-header C17 syntax PASS
|
||||||
real Notcurses binary zone workflows PASS in Kitty
|
real Notcurses binary zone workflows PASS in Kitty
|
||||||
git diff --check PASS
|
git diff --check PASS
|
||||||
|
|
@ -430,7 +437,7 @@ ASSISTANCE_DIRECTION_AWARE=PASS
|
||||||
ANDROID_MAX_TEST_SESSION=PASS
|
ANDROID_MAX_TEST_SESSION=PASS
|
||||||
EXPLICIT_MAX_RESULTS_V1=PASS
|
EXPLICIT_MAX_RESULTS_V1=PASS
|
||||||
MAX_TEST_RESUME_STABLE_ID=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`
|
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
|
External-load working percentages are pure calculations from the current
|
||||||
measured load; they are not persisted and no estimated 1RM is introduced.
|
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
|
## Body analytics v1
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -460,7 +476,7 @@ BODY_COMPOSITION_ESTIMATE=PASS
|
||||||
BODY_PROPORTION_RATIOS=PASS
|
BODY_PROPORTION_RATIOS=PASS
|
||||||
BODY_SYMMETRY_ANALYTICS=PASS
|
BODY_SYMMETRY_ANALYTICS=PASS
|
||||||
NO_ESTIMATE_PERSISTENCE=PASS
|
NO_ESTIMATE_PERSISTENCE=PASS
|
||||||
DESKTOP_TESTS=39/39 PASS
|
DESKTOP_TESTS=47/47 PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
Android remains capture-only for this feature.
|
Android remains capture-only for this feature.
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
## 1. Status
|
## 1. Status
|
||||||
|
|
||||||
```text
|
```text
|
||||||
TRAINLOG_DATABASE_SCHEMA_VERSION=11
|
TRAINLOG_DATABASE_SCHEMA_VERSION=12
|
||||||
DATABASE_SCHEMA_V11=PASS
|
DATABASE_SCHEMA_V12=PASS
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ PRAGMA user_version;
|
||||||
Current value:
|
Current value:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
11
|
12
|
||||||
```
|
```
|
||||||
|
|
||||||
The independent actual-set loads documented in the current desktop, Android
|
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
|
or body-observation rewrite. The migration is one transaction and uncertain
|
||||||
historical exercises remain valid with no relation.
|
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
|
A schema fixture must represent the real historical structure. Rewriting only
|
||||||
`user_version` is not an acceptable migration test.
|
`user_version` is not an acceptable migration test.
|
||||||
|
|
||||||
|
|
@ -103,6 +109,17 @@ Rules include:
|
||||||
- unknown supplemental field bits are rejected;
|
- unknown supplemental field bits are rejected;
|
||||||
- normalized names remain unique.
|
- 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`
|
### `exercise_body_zones`
|
||||||
|
|
||||||
Direct exercise-to-zone relations:
|
Direct exercise-to-zone relations:
|
||||||
|
|
@ -431,7 +448,7 @@ Do not synchronize SQLite database files.
|
||||||
|
|
||||||
## 10. Session-generation planning metadata
|
## 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`,
|
v10 -> v11 migration, `load_mode`, `rest_seconds`, `target_sets`,
|
||||||
`target_reps`, `target_duration_seconds`, and `target_weight_kg` to both normal
|
`target_reps`, `target_duration_seconds`, and `target_weight_kg` to both normal
|
||||||
completed and durable-draft occurrences. Existing rows receive mode `none`,
|
completed and durable-draft occurrences. Existing rows receive mode `none`,
|
||||||
|
|
@ -465,7 +482,19 @@ max_results
|
||||||
max_sync
|
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
|
## 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;
|
sync-baseline tables. Both seed exact manifest mappings by stable exercise ID;
|
||||||
neither migration changes the occurrence/equipment/MAX graph described above.
|
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
|
## 13. Body analytics persistence rule
|
||||||
|
|
||||||
Body analytics still require no dedicated schema change; schemas v9 through
|
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
|
Canonical persistence continues to contain only measurements actually entered
|
||||||
by the user.
|
by the user.
|
||||||
|
|
@ -566,7 +600,7 @@ history.
|
||||||
## 14. Training knowledge read boundary
|
## 14. Training knowledge read boundary
|
||||||
|
|
||||||
Training Knowledge V1 adds no table, migration, seed data or synchronization
|
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,
|
joins an exact existing exercise with its persisted BODY ZONE relations,
|
||||||
occurrence history, raw sets, actual equipment and latest explicit MAX, then
|
occurrence history, raw sets, actual equipment and latest explicit MAX, then
|
||||||
optionally attaches immutable catalog knowledge. Missing catalog knowledge is
|
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 may be displayed as historical context with
|
||||||
`explicit_max_present_no_numeric_prescription`, but supplies no numeric target.
|
`explicit_max_present_no_numeric_prescription`, but supplies no numeric target.
|
||||||
3. Do not substitute zero, an invented average, another exercise's result,
|
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.
|
Observed-load reuse has confidence **`uncertain`** for today's prescription.
|
||||||
"Successful" or "working" here means only that a qualifying repeated dose was
|
"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
|
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.
|
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.
|
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
|
The original proposal's 50% fallback was rejected by the architecture review
|
||||||
because it contradicts this existing canonical boundary. The same absent-load
|
because it contradicts this existing canonical boundary. The same absent-load
|
||||||
fallback applies to every goal. The 28-day actual-anchor cutoff remains a
|
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_ZONE_SYNC_V1=PASS
|
||||||
BODY_ZONES_ANDROID_DEVICE_VALIDATION=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
|
TUI_NOTCURSES_V1=PASS
|
||||||
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
||||||
NOTCURSES_TRUECOLOR_THEME=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;
|
or a multi-session program. The one deep final audit found repairable gaps;
|
||||||
its bounded repairs, review, and final validation matrix passed.
|
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
|
## Session templates v1
|
||||||
|
|
||||||
Templates are built from the real exercise catalog and planning model.
|
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.
|
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
|
## Permanent constraints
|
||||||
|
|
||||||
Do not regress to:
|
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-mobile-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 |
|
||||||
| Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
| 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-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-catalog-v1.json` | `trainlog-pc-catalog` v1 |
|
||||||
| PC -> Android | `trainlog-pc-equipment-definitions-v1.json` | `trainlog-equipment-definitions` 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-pc-mobile-export-v3.json` | `trainlog-mobile-export` v3 (active) |
|
||||||
| PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
| 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-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 |
|
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
|
||||||
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
|
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
|
||||||
|
|
||||||
No SQLite file is transferred.
|
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
|
Android scoped storage can preserve a prior MTP-created object and create a
|
||||||
new artifact with the provider collision suffix, for example
|
new artifact with the provider collision suffix, for example
|
||||||
`trainlog-mobile-export-v3 (N).json` or
|
`trainlog-mobile-export-v3 (N).json` or
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,10 @@ database
|
||||||
catalog
|
catalog
|
||||||
equipment_catalog
|
equipment_catalog
|
||||||
body_zones
|
body_zones
|
||||||
|
training_knowledge
|
||||||
|
session_generation
|
||||||
|
session_generation_policy_validation
|
||||||
|
training_context
|
||||||
custom_equipment
|
custom_equipment
|
||||||
session_detail
|
session_detail
|
||||||
duration
|
duration
|
||||||
|
|
@ -89,8 +93,10 @@ variable_sets
|
||||||
schema_v5_migration
|
schema_v5_migration
|
||||||
schema_v9_migration
|
schema_v9_migration
|
||||||
schema_v7_migration
|
schema_v7_migration
|
||||||
|
timestamp_validation
|
||||||
mobile_import_variable_sets
|
mobile_import_variable_sets
|
||||||
mobile_import_multi_occurrence
|
mobile_import_multi_occurrence
|
||||||
|
session_exchange_v3
|
||||||
equipment_associations_exchange
|
equipment_associations_exchange
|
||||||
equipment_definitions_exchange
|
equipment_definitions_exchange
|
||||||
exercise_reconciliation
|
exercise_reconciliation
|
||||||
|
|
@ -105,9 +111,12 @@ max_sync
|
||||||
body_analytics
|
body_analytics
|
||||||
terminal_input_event_type_policy
|
terminal_input_event_type_policy
|
||||||
tui_workflows
|
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
|
```text
|
||||||
45/45 Meson tests PASS
|
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.
|
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
|
## 6. Android build
|
||||||
|
|
||||||
When Android code changes:
|
When Android code changes:
|
||||||
|
|
@ -370,8 +407,16 @@ Coverage proves:
|
||||||
- no-load max tests compare actual reps/duration;
|
- no-load max tests compare actual reps/duration;
|
||||||
- external working loads round to the configured increment;
|
- external working loads round to the configured increment;
|
||||||
- working-load percentages reject assistance.
|
- 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
|
```text
|
||||||
39/39 Meson tests PASS
|
39/39 Meson tests PASS
|
||||||
|
|
@ -396,7 +441,7 @@ Coverage includes:
|
||||||
- missing required circumference handling;
|
- missing required circumference handling;
|
||||||
- invalid estimation-profile rejection.
|
- invalid estimation-profile rejection.
|
||||||
|
|
||||||
Validated current normal suite:
|
Historical validation checkpoint (the current desktop suite is 47/47):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
39/39 Meson tests PASS
|
39/39 Meson tests PASS
|
||||||
|
|
@ -405,10 +450,14 @@ Validated current normal suite:
|
||||||
## 13. Android session draft v1
|
## 13. Android session draft v1
|
||||||
|
|
||||||
Android schema v4 introduced one durable active draft; the current additive
|
Android schema v4 introduced one durable active draft; the current additive
|
||||||
chain reaches schema v10 without clearing completed history or the draft. The
|
chain reaches schema v12 without clearing completed history or the draft. The
|
||||||
current `testDebugUnitTest` suite and `assembleDebug` pass. Host coverage
|
explicit v10 -> v11 migration adds optional planning metadata while preserving
|
||||||
includes exercise
|
existing rows with `load_mode=none`, zero rest and NULL targets. The
|
||||||
shapes and raw partial text, fresh repository restore, remove/discard, atomic
|
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,
|
finalization and repeated-finalize rejection, rollback, catalog reconciliation,
|
||||||
missing-selection recovery, explicit DB-open failure, historical migration,
|
missing-selection recovery, explicit DB-open failure, historical migration,
|
||||||
equipment selection and occurrence identity.
|
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
|
Executed Body Zones V1 evidence is recorded after each closeout run: Android
|
||||||
`testDebugUnitTest` 44/44 with the retained real v9 fixture enabled and
|
`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
|
import-contract checks, and the ASan/UBSan Meson suite. Device installation and
|
||||||
installed Android-store migration remain explicit hardware steps and are never
|
installed Android-store migration remain explicit hardware steps and are never
|
||||||
inferred from host tests.
|
inferred from host tests.
|
||||||
|
|
|
||||||
115
docs/tui.md
115
docs/tui.md
|
|
@ -8,33 +8,66 @@ synchronization.
|
||||||
|
|
||||||
Desktop SQLite is the canonical long-term history.
|
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
|
```text
|
||||||
0 Accueil
|
Accueil | Séances | Exercices | Équipements | Statistiques | Synchronisation | Paramètres
|
||||||
1 Séance
|
|
||||||
2 Historique
|
|
||||||
3 Exercices
|
|
||||||
4 Équipements
|
|
||||||
5 Corps
|
|
||||||
6 Sync
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
Common controls:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
↑ ↓ list navigation
|
↑ ↓ list navigation
|
||||||
Enter open/activate
|
Enter open/activate
|
||||||
Tab change focus on multi-zone pages
|
Tab / Shift+Tab cycle rendered focus targets
|
||||||
Esc / b return or cancel
|
F6 open Navigation
|
||||||
0 / Home dashboard
|
F7 open the route's action registry
|
||||||
q quit from the application shell
|
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:
|
Minimum terminal size:
|
||||||
|
|
||||||
```text
|
```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
|
warning, error, muted, and graph roles. Unicode frames and visible selection
|
||||||
markers enhance presentation without becoming application semantics.
|
markers enhance presentation without becoming application semantics.
|
||||||
|
|
||||||
The backend owns one standard plane for a run, translates terminal input into
|
The backend owns run-scoped planes, translates terminal input into
|
||||||
Trainlog-owned keys, accepts complete UTF-8 code points in prompts, and
|
Trainlog-owned keys, and accepts complete UTF-8 code points in bounded shell
|
||||||
re-queries dimensions while rendering so the 72x20 minimum/fallback recovers
|
editors without a runtime parser or `ncreader`. Search/form storage is capped
|
||||||
after a resize.
|
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,
|
Input lifecycle is handled at that boundary: legacy/unknown terminal events,
|
||||||
Notcurses PRESS events, and deliberate auto-REPEAT events become one logical
|
Notcurses PRESS events, and deliberate auto-REPEAT events become one logical
|
||||||
|
|
@ -76,8 +112,9 @@ muted
|
||||||
graph series
|
graph series
|
||||||
```
|
```
|
||||||
|
|
||||||
Focused frames use the warning role for border/title without recoloring all
|
Focused frames use the focus semantic role without recoloring all content.
|
||||||
content.
|
The palette uses synchronized semantic RGB role tokens; an optional Nerd Font
|
||||||
|
may improve symbols, while text fallbacks are mandatory.
|
||||||
|
|
||||||
## 4. Exercise catalog
|
## 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
|
with zero actual rows and enters the existing editor. The existing completion
|
||||||
guard still requires actual rows for every SETS exercise.
|
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
|
## 6. Session history and editing
|
||||||
|
|
||||||
History is keyboard navigable.
|
History is keyboard navigable.
|
||||||
|
|
@ -201,8 +255,8 @@ catalog.
|
||||||
|
|
||||||
## 7. Equipment
|
## 7. Equipment
|
||||||
|
|
||||||
`4 Équipements / F4` provides supplied-equipment browsing, search, detail, and
|
`Équipements` (direct alias `4/F4`) provides supplied-equipment browsing,
|
||||||
custom-equipment creation and selection. Supplied definitions are generated
|
search, detail, and custom-equipment creation and selection. Supplied definitions are generated
|
||||||
from `catalog/equipment-v1.json`; user-created definitions persist in desktop
|
from `catalog/equipment-v1.json`; user-created definitions persist in desktop
|
||||||
SQLite and synchronize separately through definitions V1.
|
SQLite and synchronize separately through definitions V1.
|
||||||
|
|
||||||
|
|
@ -216,7 +270,7 @@ visibly distinct; `i` opens the resolved equipment detail.
|
||||||
|
|
||||||
## 8. Body tracking
|
## 8. Body tracking
|
||||||
|
|
||||||
`5 Corps / F5` provides:
|
`Statistiques → Mensurations` (direct alias `5/F5`) provides:
|
||||||
|
|
||||||
- newest-first body observations;
|
- newest-first body observations;
|
||||||
- detail and correction;
|
- detail and correction;
|
||||||
|
|
@ -227,9 +281,10 @@ visibly distinct; `i` opens the resolved equipment detail.
|
||||||
|
|
||||||
Editing preserves observation identity, timestamp, and optional session link.
|
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:
|
Rules include:
|
||||||
|
|
||||||
|
|
@ -238,9 +293,9 @@ Rules include:
|
||||||
- no zero fill;
|
- no zero fill;
|
||||||
- no interpolation;
|
- no interpolation;
|
||||||
- when multiple observations exist in one month, the last visible monthly value
|
- 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
|
## 10. Exercise performance
|
||||||
|
|
||||||
|
|
@ -443,7 +498,7 @@ performance.
|
||||||
|
|
||||||
## 17. Body analytics
|
## 17. Body analytics
|
||||||
|
|
||||||
`5 Corps` adds:
|
`Statistiques → Mensurations` adds:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
v analyse corporelle
|
v analyse corporelle
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,51 @@ def mapping(db):
|
||||||
return rows, baseline
|
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():
|
def main():
|
||||||
with tempfile.TemporaryDirectory(prefix="trainlog-body-zone-sync-") as temporary:
|
with tempfile.TemporaryDirectory(prefix="trainlog-body-zone-sync-") as temporary:
|
||||||
root = Path(temporary)
|
root = Path(temporary)
|
||||||
|
|
@ -164,13 +209,7 @@ def main():
|
||||||
remote_id = "ex_33333333-3333-4333-8333-333333333333"
|
remote_id = "ex_33333333-3333-4333-8333-333333333333"
|
||||||
database(alias_db, local_id, with_mapping=False)
|
database(alias_db, local_id, with_mapping=False)
|
||||||
alias_artifact = root / "alias-zones.json"
|
alias_artifact = root / "alias-zones.json"
|
||||||
alias_artifact.write_text(json.dumps({
|
write_companion(alias_artifact, [entry(remote_id)])
|
||||||
"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")
|
|
||||||
proof = root / "proof.json"
|
proof = root / "proof.json"
|
||||||
proof.write_text(json.dumps({
|
proof.write_text(json.dumps({
|
||||||
"format": "trainlog-mobile-export", "version": 2,
|
"format": "trainlog-mobile-export", "version": 2,
|
||||||
|
|
@ -179,12 +218,74 @@ def main():
|
||||||
"recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0,
|
"recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0,
|
||||||
}], "sessions": [], "body_observations": [],
|
}], "sessions": [], "body_observations": [],
|
||||||
}), encoding="utf-8")
|
}), 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(
|
alias_import = run(
|
||||||
IMPORT, alias_artifact, alias_db, "--mobile-export", proof,
|
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")
|
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
|
# A custom exercise starts with no baseline on its creator. Publishing
|
||||||
# the exact snapshot acknowledges it locally; a later peer-only edit
|
# the exact snapshot acknowledges it locally; a later peer-only edit
|
||||||
# must then flow back instead of becoming a false simultaneous conflict.
|
# must then flow back instead of becoming a false simultaneous conflict.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
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):
|
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 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'
|
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
|
# The importer must validate the entire artifact before applying its
|
||||||
# first otherwise-valid association. v2 cannot transport custom IDs.
|
# first otherwise-valid association. v2 cannot transport custom IDs.
|
||||||
prevalidation_artifact = directory / 'prevalidation-rejected.json'
|
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()
|
args = parser.parse_args()
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12):
|
||||||
raise ValueError("schema desktop v8 à v11 requis")
|
raise ValueError("schema desktop v8 à v12 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ def main():
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12):
|
||||||
raise ValueError("schema desktop v8 à v11 requis")
|
raise ValueError("schema desktop v8 à v12 requis")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
|
||||||
"FROM custom_equipment ORDER BY equipment_id")]
|
"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 = sqlite3.connect(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] != 11:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12):
|
||||||
raise ValueError("schema desktop v11 requis")
|
raise ValueError("schema desktop v11/v12 requis")
|
||||||
exercises = []
|
exercises = []
|
||||||
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
|
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:
|
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
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# 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(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,8 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if schema_version != 11 and not (args.version == 2 and schema_version == 10):
|
if schema_version not in (11, 12) and not (args.version == 2 and schema_version == 10):
|
||||||
raise ValueError("schema desktop v11 requis (v10 accepté pour export V2 explicite)")
|
raise ValueError("schema desktop v11/v12 requis (v10 accepté pour export V2 explicite)")
|
||||||
known_equipment = supplied_equipment_ids()
|
known_equipment = supplied_equipment_ids()
|
||||||
known_equipment.update(row[0] for row in con.execute(
|
known_equipment.update(row[0] for row in con.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,28 @@ def load_mobile_occurrences(path):
|
||||||
return occurrences
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("artifact", type=Path)
|
parser.add_argument("artifact", type=Path)
|
||||||
|
|
@ -113,8 +135,8 @@ def main():
|
||||||
fail("clés extension équipement invalides")
|
fail("clés extension équipement invalides")
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11):
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12):
|
||||||
fail("schema desktop v8 à v11 requis")
|
fail("schema desktop v8 à v12 requis")
|
||||||
known = load_catalog(args.catalog)
|
known = load_catalog(args.catalog)
|
||||||
known.update(row[0] for row in connection.execute(
|
known.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
@ -137,7 +159,12 @@ def main():
|
||||||
(session_id, entry_id)).fetchone()
|
(session_id, entry_id)).fetchone()
|
||||||
if exists is None:
|
if exists is None:
|
||||||
fail(f"entrée séance inconnue: {session_id}/{entry_id}")
|
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))
|
proof = mobile_occurrences.get((session_id, entry_id))
|
||||||
incoming_still_exists = connection.execute(
|
incoming_still_exists = connection.execute(
|
||||||
"SELECT 1 FROM exercises WHERE exercise_id=?;",
|
"SELECT 1 FROM exercises WHERE exercise_id=?;",
|
||||||
|
|
@ -145,10 +172,13 @@ def main():
|
||||||
).fetchone() is not None
|
).fetchone() is not None
|
||||||
if proof != exercise_id or incoming_still_exists:
|
if proof != exercise_id or incoming_still_exists:
|
||||||
fail(f"conflit exercice association: {session_id}/{entry_id}")
|
fail(f"conflit exercice association: {session_id}/{entry_id}")
|
||||||
# WHY: import_mobile_export may have replaced a safe duplicate
|
# CONTRACT: this fallback is only for schemas/runs without a
|
||||||
# creator ID with the canonical desktop ID. entry_id is the V2
|
# persistent alias. import_mobile_export may have removed a
|
||||||
# occurrence identity; the just-validated source snapshot proves
|
# profile-compatible duplicate; the selected V2 snapshot then
|
||||||
# that this stale exercise_id belongs to that same occurrence.
|
# 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:
|
if exists[1] != equipment_id:
|
||||||
fail(f"conflit association équipement: {session_id}/{entry_id}")
|
fail(f"conflit association équipement: {session_id}/{entry_id}")
|
||||||
print("EQUIPMENT_ASSOCIATIONS_IMPORT=PASS")
|
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))
|
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11):
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12):
|
||||||
fail("schema desktop v8 à v11 requis")
|
fail("schema desktop v8 à v12 requis")
|
||||||
imported = skipped = 0
|
imported = skipped = 0
|
||||||
# Validate every same-ID row before inserting any definition.
|
# Validate every same-ID row before inserting any definition.
|
||||||
for definition in definitions:
|
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()
|
).fetchone()
|
||||||
if row is not None:
|
if row is not None:
|
||||||
return row[0]
|
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)
|
proof = mobile_proof.get(exercise_id)
|
||||||
if proof is None:
|
if proof is None:
|
||||||
raise ImportFailure(f"exercice inconnu: {exercise_id}")
|
raise ImportFailure(f"exercice inconnu: {exercise_id}")
|
||||||
|
|
@ -183,26 +195,41 @@ def main():
|
||||||
seen.add(exercise_id)
|
seen.add(exercise_id)
|
||||||
parsed.append((exercise_id, primary, sorted(secondary)))
|
parsed.append((exercise_id, primary, sorted(secondary)))
|
||||||
|
|
||||||
proof_path = args.mobile_export
|
# A V2 snapshot is reconciliation evidence only when the caller selected
|
||||||
if proof_path is None:
|
# and supplied that exact snapshot. Never discover a sibling implicitly:
|
||||||
candidate = args.input.with_name("trainlog-mobile-export-v2.json")
|
# a stale V2 file must not prove IDs for a separately selected V3/V1 run.
|
||||||
if candidate.exists():
|
mobile_proof = (load_mobile_exercise_proof(args.mobile_export)
|
||||||
proof_path = candidate
|
if args.mobile_export is not None else {})
|
||||||
mobile_proof = load_mobile_exercise_proof(proof_path) if proof_path is not None else {}
|
|
||||||
|
|
||||||
connection = sqlite3.connect(args.database)
|
connection = sqlite3.connect(args.database)
|
||||||
updated = skipped = kept_local = 0
|
updated = skipped = kept_local = 0
|
||||||
resolved_rows = set()
|
|
||||||
try:
|
try:
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] != 11:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12):
|
||||||
raise ImportFailure("schema desktop v11 requis")
|
raise ImportFailure("schema desktop v11/v12 requis")
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
grouped = {}
|
||||||
for exercise_id, primary, secondary in parsed:
|
for exercise_id, primary, secondary in parsed:
|
||||||
row_id = resolve_exercise_row(connection, exercise_id, mobile_proof)
|
row_id = resolve_exercise_row(connection, exercise_id, mobile_proof)
|
||||||
if row_id in resolved_rows:
|
incoming = (primary, secondary)
|
||||||
raise ImportFailure(f"deux identités entrantes résolvent le même exercice: {exercise_id}")
|
group = grouped.get(row_id)
|
||||||
resolved_rows.add(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_primary, local_secondary = current(connection, row_id)
|
||||||
local_state = state(local_primary, local_secondary)
|
local_state = state(local_primary, local_secondary)
|
||||||
incoming_state = state(primary, secondary)
|
incoming_state = state(primary, secondary)
|
||||||
|
|
@ -211,20 +238,32 @@ def main():
|
||||||
).fetchone()
|
).fetchone()
|
||||||
baseline = None if baseline_row is None else baseline_row[0]
|
baseline = None if baseline_row is None else baseline_row[0]
|
||||||
if local_state == incoming_state:
|
if local_state == incoming_state:
|
||||||
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state))
|
planned.append(("skip", row_id, primary, secondary, incoming_state))
|
||||||
skipped += 1
|
|
||||||
elif baseline is not None and local_state == baseline:
|
elif baseline is not None and local_state == baseline:
|
||||||
replace(connection, row_id, primary, secondary)
|
planned.append(("update", row_id, primary, secondary, incoming_state))
|
||||||
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state))
|
|
||||||
updated += 1
|
|
||||||
elif baseline is not None and incoming_state == baseline:
|
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 == "|":
|
elif baseline is None and local_state == "|":
|
||||||
replace(connection, row_id, primary, secondary)
|
planned.append(("update", row_id, primary, secondary, incoming_state))
|
||||||
connection.execute("INSERT OR REPLACE INTO exercise_body_zone_sync VALUES(?,?)", (row_id, incoming_state))
|
|
||||||
updated += 1
|
|
||||||
else:
|
else:
|
||||||
raise ImportFailure(f"conflit zones simultané: {exercise_id}")
|
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()
|
connection.commit()
|
||||||
print("EXERCISE_BODY_ZONES_IMPORT=PASS")
|
print("EXERCISE_BODY_ZONES_IMPORT=PASS")
|
||||||
print(f"zones_updated={updated}")
|
print(f"zones_updated={updated}")
|
||||||
|
|
|
||||||
|
|
@ -819,9 +819,9 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# 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(
|
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,
|
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 by_id is not None:
|
||||||
if not profiles_are_reconcilable(
|
if not profiles_are_reconcilable(
|
||||||
by_id,
|
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/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 11
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 12
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
@ -30,6 +30,8 @@ typedef struct TrainlogCustomEquipment {
|
||||||
char load_semantics[32];
|
char load_semantics[32];
|
||||||
} TrainlogCustomEquipment;
|
} TrainlogCustomEquipment;
|
||||||
|
|
||||||
|
#define TRAINLOG_CUSTOM_EQUIPMENT_PAGE_MAX 128U
|
||||||
|
|
||||||
typedef enum TrainlogEquipmentOrigin {
|
typedef enum TrainlogEquipmentOrigin {
|
||||||
TRAINLOG_EQUIPMENT_SUPPLIED = 0,
|
TRAINLOG_EQUIPMENT_SUPPLIED = 0,
|
||||||
TRAINLOG_EQUIPMENT_CUSTOM,
|
TRAINLOG_EQUIPMENT_CUSTOM,
|
||||||
|
|
@ -55,6 +57,25 @@ TrainlogStatus trainlog_database_list_custom_equipment(
|
||||||
size_t capacity,
|
size_t capacity,
|
||||||
size_t *output_count
|
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
|
/* CONTRACT: an occurrence ID always resolves to a visible value. Unknown IDs
|
||||||
* are returned verbatim with TRAINLOG_EQUIPMENT_UNKNOWN, never hidden. */
|
* are returned verbatim with TRAINLOG_EQUIPMENT_UNKNOWN, never hidden. */
|
||||||
TrainlogStatus trainlog_database_resolve_equipment(
|
TrainlogStatus trainlog_database_resolve_equipment(
|
||||||
|
|
@ -143,6 +164,53 @@ TrainlogStatus trainlog_database_list_exercises(
|
||||||
size_t *output_count
|
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.
|
* @brief Atomically replace one exercise's direct body-zone relations.
|
||||||
*
|
*
|
||||||
|
|
@ -500,6 +568,15 @@ TrainlogStatus trainlog_database_latest_explicit_max_context(
|
||||||
TrainlogLatestExplicitMax *output
|
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 */
|
/* TRAINLOG_SESSION_EDIT_API */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,4 +57,20 @@ TrainlogStatus trainlog_measured_max_working_load(
|
||||||
double *output_kg
|
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
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
|
|
||||||
typedef struct TrainlogTerminal TrainlogTerminal;
|
typedef struct TrainlogTerminal TrainlogTerminal;
|
||||||
typedef struct TrainlogPanel TrainlogPanel;
|
typedef struct TrainlogPanel TrainlogPanel;
|
||||||
|
typedef struct TrainlogSurface TrainlogSurface;
|
||||||
|
|
||||||
typedef enum TrainlogKey {
|
typedef enum TrainlogKey {
|
||||||
TRAINLOG_KEY_NONE = -1,
|
TRAINLOG_KEY_NONE = -1,
|
||||||
|
|
@ -36,6 +37,8 @@ typedef enum TrainlogKey {
|
||||||
TRAINLOG_KEY_F3,
|
TRAINLOG_KEY_F3,
|
||||||
TRAINLOG_KEY_F4,
|
TRAINLOG_KEY_F4,
|
||||||
TRAINLOG_KEY_F5,
|
TRAINLOG_KEY_F5,
|
||||||
|
TRAINLOG_KEY_F6,
|
||||||
|
TRAINLOG_KEY_F7,
|
||||||
TRAINLOG_KEY_SHIFT_TAB
|
TRAINLOG_KEY_SHIFT_TAB
|
||||||
} TrainlogKey;
|
} TrainlogKey;
|
||||||
|
|
||||||
|
|
@ -77,6 +80,32 @@ int trainlog_terminal_get_key(TrainlogTerminal *terminal);
|
||||||
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint,
|
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint,
|
||||||
char utf8[5]);
|
char utf8[5]);
|
||||||
bool trainlog_terminal_push_key(TrainlogTerminal *terminal, int key);
|
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
|
/* Private screen-port helpers. Panels are lightweight coordinate views over
|
||||||
* the one standard plane, not independently owned terminal surfaces. */
|
* the one standard plane, not independently owned terminal surfaces. */
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ typedef enum TrainlogColorRole {
|
||||||
TRAINLOG_COLOR_WARNING = 3,
|
TRAINLOG_COLOR_WARNING = 3,
|
||||||
TRAINLOG_COLOR_ERROR = 4,
|
TRAINLOG_COLOR_ERROR = 4,
|
||||||
TRAINLOG_COLOR_MUTED = 5,
|
TRAINLOG_COLOR_MUTED = 5,
|
||||||
TRAINLOG_COLOR_GRAPH = 6
|
TRAINLOG_COLOR_GRAPH = 6,
|
||||||
|
TRAINLOG_COLOR_INFO = 7,
|
||||||
|
TRAINLOG_COLOR_NOTICE = 8
|
||||||
} TrainlogColorRole;
|
} TrainlogColorRole;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -28,6 +30,21 @@ typedef uint32_t TrainlogTextStyle;
|
||||||
#define TRAINLOG_TEXT_BOLD ((TrainlogTextStyle)0x0001U)
|
#define TRAINLOG_TEXT_BOLD ((TrainlogTextStyle)0x0001U)
|
||||||
#define TRAINLOG_TEXT_REVERSE ((TrainlogTextStyle)0x0002U)
|
#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);
|
TrainlogTextStyle trainlog_theme_style(TrainlogColorRole role);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ trainlog_core_dep = declare_dependency(
|
||||||
|
|
||||||
trainlog_tui_sources = files(
|
trainlog_tui_sources = files(
|
||||||
'src/main.c',
|
'src/main.c',
|
||||||
|
'src/app_shell.c',
|
||||||
'src/sync_screen_action.c',
|
'src/sync_screen_action.c',
|
||||||
'src/terminal.c',
|
'src/terminal.c',
|
||||||
'src/theme.c',
|
'src/theme.c',
|
||||||
|
|
@ -542,6 +543,12 @@ test(
|
||||||
args: [meson.project_source_root() / 'tests/test_body_zone_catalog.py'],
|
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 = executable(
|
||||||
'test_sync_direction',
|
'test_sync_direction',
|
||||||
'tests/test_sync_direction.c',
|
'tests/test_sync_direction.c',
|
||||||
|
|
@ -551,6 +558,15 @@ test_sync_direction = executable(
|
||||||
|
|
||||||
test('sync_direction', test_sync_direction)
|
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 = executable(
|
||||||
'test_sync_history',
|
'test_sync_history',
|
||||||
'tests/test_sync_history.c',
|
'tests/test_sync_history.c',
|
||||||
|
|
@ -606,7 +622,7 @@ test_terminal_input = executable(
|
||||||
'tests/test_terminal_input.c',
|
'tests/test_terminal_input.c',
|
||||||
'src/terminal.c',
|
'src/terminal.c',
|
||||||
include_directories: trainlog_include,
|
include_directories: trainlog_include,
|
||||||
dependencies: notcurses_dep,
|
dependencies: [notcurses_dep, utf8proc_dep],
|
||||||
c_args: strict_c_args,
|
c_args: strict_c_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -618,6 +634,7 @@ test(
|
||||||
test_tui_workflows = executable(
|
test_tui_workflows = executable(
|
||||||
'test_tui_workflows',
|
'test_tui_workflows',
|
||||||
'tests/test_tui_workflows.c',
|
'tests/test_tui_workflows.c',
|
||||||
|
'src/app_shell.c',
|
||||||
'src/theme.c',
|
'src/theme.c',
|
||||||
'src/sync_screen_action.c',
|
'src/sync_screen_action.c',
|
||||||
dependencies: trainlog_core_dep,
|
dependencies: trainlog_core_dep,
|
||||||
|
|
@ -625,3 +642,13 @@ test_tui_workflows = executable(
|
||||||
)
|
)
|
||||||
|
|
||||||
test('tui_workflows', test_tui_workflows)
|
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"
|
"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 =
|
static const char *const MIGRATE_V1_TO_V3_SQL =
|
||||||
"BEGIN IMMEDIATE;"
|
"BEGIN IMMEDIATE;"
|
||||||
"ALTER TABLE sessions "
|
"ALTER TABLE sessions "
|
||||||
|
|
@ -1015,6 +1032,8 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
|
||||||
} else if (version == 10) {
|
} else if (version == 10) {
|
||||||
status = TRAINLOG_STATUS_OK;
|
status = TRAINLOG_STATUS_OK;
|
||||||
|
} else if (version == 11) {
|
||||||
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
status =
|
status =
|
||||||
|
|
@ -1148,7 +1167,10 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
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 (
|
if (
|
||||||
|
|
@ -1158,7 +1180,7 @@ static TrainlogStatus initialize_or_validate_schema(
|
||||||
set_open_diagnostic(
|
set_open_diagnostic(
|
||||||
output_diagnostic,
|
output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
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,
|
database->connection,
|
||||||
SQLITE_ERROR
|
SQLITE_ERROR
|
||||||
);
|
);
|
||||||
|
|
@ -1393,6 +1415,84 @@ TrainlogStatus trainlog_database_list_custom_equipment(
|
||||||
return rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
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(
|
TrainlogStatus trainlog_database_resolve_equipment(
|
||||||
TrainlogDatabase *database, const char *equipment_id,
|
TrainlogDatabase *database, const char *equipment_id,
|
||||||
TrainlogResolvedEquipment *output
|
TrainlogResolvedEquipment *output
|
||||||
|
|
@ -1996,6 +2096,249 @@ TrainlogStatus trainlog_database_list_exercises(
|
||||||
return TRAINLOG_STATUS_OK;
|
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(
|
static TrainlogStatus lookup_exercise_row_id(
|
||||||
TrainlogDatabase *database,
|
TrainlogDatabase *database,
|
||||||
const char *exercise_id,
|
const char *exercise_id,
|
||||||
|
|
@ -6664,14 +7007,16 @@ TrainlogStatus trainlog_database_list_occurrence_sets_page(
|
||||||
*output_count=count; return TRAINLOG_STATUS_OK;
|
*output_count=count; return TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogStatus trainlog_database_latest_explicit_max_context(
|
static TrainlogStatus database_latest_explicit_max_context(
|
||||||
TrainlogDatabase *database, const char *exercise_id, TrainlogLatestExplicitMax *output)
|
TrainlogDatabase *database, const char *exercise_id, const char *equipment_id,
|
||||||
|
TrainlogLatestExplicitMax *output)
|
||||||
{
|
{
|
||||||
static const char *const SCAN_SQL =
|
static const char *const SCAN_SQL =
|
||||||
"SELECT se.id,s.session_id,se.entry_id,s.started_at FROM max_results mr "
|
"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 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 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 =
|
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 "
|
"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 "
|
"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;
|
if(status!=TRAINLOG_STATUS_OK)goto done;
|
||||||
rc=sqlite3_prepare_v2(database->connection,SCAN_SQL,-1,&statement,NULL);
|
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=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;}
|
if(rc!=SQLITE_OK){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
|
||||||
while((rc=sqlite3_step(statement))==SQLITE_ROW){
|
while((rc=sqlite3_step(statement))==SQLITE_ROW){
|
||||||
KnowledgeTemporalCandidate candidate;
|
KnowledgeTemporalCandidate candidate;
|
||||||
|
|
@ -6733,3 +7080,18 @@ done:
|
||||||
}
|
}
|
||||||
return status;
|
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;
|
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 =
|
static const char *const EXERCISE_BODY_ZONES_NAME =
|
||||||
"trainlog-exercise-body-zones-v1.json";
|
"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 =
|
static const char *const SYNC_REQUEST_NAME =
|
||||||
"trainlog-sync-request-v1.json";
|
"trainlog-sync-request-v1.json";
|
||||||
|
|
||||||
|
|
@ -85,9 +88,15 @@ static const char *const EQUIPMENT_DEFINITIONS_RESULT =
|
||||||
static const char *const EXERCISE_BODY_ZONES_LOCAL =
|
static const char *const EXERCISE_BODY_ZONES_LOCAL =
|
||||||
"/tmp/trainlog-exercise-body-zones-v1.json";
|
"/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 =
|
static const char *const EXERCISE_BODY_ZONES_RESULT =
|
||||||
"/tmp/trainlog-exercise-body-zones-result.txt";
|
"/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 =
|
static const char *const SYNC_REQUEST_LOCAL =
|
||||||
"/tmp/trainlog-sync-request-v1.json";
|
"/tmp/trainlog-sync-request-v1.json";
|
||||||
|
|
||||||
|
|
@ -1222,6 +1231,7 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
const char *tool_name,
|
const char *tool_name,
|
||||||
const char *argument,
|
const char *argument,
|
||||||
const char *database_path,
|
const char *database_path,
|
||||||
|
const char *mobile_export_path,
|
||||||
const char *result_path,
|
const char *result_path,
|
||||||
char *output,
|
char *output,
|
||||||
size_t output_size
|
size_t output_size
|
||||||
|
|
@ -1240,7 +1250,8 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
argument == NULL ||
|
argument == NULL ||
|
||||||
result_path == NULL ||
|
result_path == NULL ||
|
||||||
output == NULL ||
|
output == NULL ||
|
||||||
output_size < 2U
|
output_size < 2U ||
|
||||||
|
(mobile_export_path != NULL && database_path == NULL)
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
TRAINLOG_STATUS_INVALID_ARGUMENT;
|
TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
|
@ -1303,7 +1314,22 @@ static TrainlogStatus sync_run_python_tool(
|
||||||
result_fd
|
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
|
/* CONTRACT: helpers which mutate the desktop store receive its
|
||||||
* explicit XDG-resolved path. They must never infer a different
|
* explicit XDG-resolved path. They must never infer a different
|
||||||
* user's database from Python's process environment. */
|
* user's database from Python's process environment. */
|
||||||
|
|
@ -2618,6 +2644,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
bool run_started = false;
|
bool run_started = false;
|
||||||
bool receipt_published = false;
|
bool receipt_published = false;
|
||||||
bool mobile_export_has_companions = false;
|
bool mobile_export_has_companions = false;
|
||||||
|
bool retained_mobile_export_is_v2 = false;
|
||||||
|
|
||||||
if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC ||
|
if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC ||
|
||||||
direction > TRAINLOG_SYNC_BIDIRECTIONAL ||
|
direction > TRAINLOG_SYNC_BIDIRECTIONAL ||
|
||||||
|
|
@ -2843,6 +2870,31 @@ TrainlogStatus trainlog_sync_run(
|
||||||
goto outbound;
|
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
|
/* Definitions must reconcile before either V3/V2 reference artifact. A
|
||||||
* missing file is accepted only for historic snapshots with no custom ID. */
|
* missing file is accepted only for historic snapshots with no custom ID. */
|
||||||
status = sync_receive_current_android_artifact(
|
status = sync_receive_current_android_artifact(
|
||||||
|
|
@ -2856,6 +2908,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
status = sync_run_python_tool("import_equipment_definitions.py",
|
status = sync_run_python_tool("import_equipment_definitions.py",
|
||||||
MOBILE_EQUIPMENT_DEFINITIONS_LOCAL,
|
MOBILE_EQUIPMENT_DEFINITIONS_LOCAL,
|
||||||
database_path,
|
database_path,
|
||||||
|
NULL,
|
||||||
EQUIPMENT_DEFINITIONS_RESULT,
|
EQUIPMENT_DEFINITIONS_RESULT,
|
||||||
tool_output, sizeof(tool_output));
|
tool_output, sizeof(tool_output));
|
||||||
if (status != TRAINLOG_STATUS_OK ||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
|
@ -2895,6 +2948,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
status = sync_receive_current_android_artifact(
|
status = sync_receive_current_android_artifact(
|
||||||
&device, folder_id, MOBILE_EXPORT_V2_NAME,
|
&device, folder_id, MOBILE_EXPORT_V2_NAME,
|
||||||
MOBILE_EXPORT_LOCAL, &ignored_size);
|
MOBILE_EXPORT_LOCAL, &ignored_size);
|
||||||
|
retained_mobile_export_is_v2 = status == TRAINLOG_STATUS_OK;
|
||||||
}
|
}
|
||||||
mobile_export_has_companions = status == TRAINLOG_STATUS_OK;
|
mobile_export_has_companions = status == TRAINLOG_STATUS_OK;
|
||||||
|
|
||||||
|
|
@ -2919,6 +2973,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
"import_mobile_export.py",
|
"import_mobile_export.py",
|
||||||
MOBILE_EXPORT_LOCAL,
|
MOBILE_EXPORT_LOCAL,
|
||||||
database_path,
|
database_path,
|
||||||
|
NULL,
|
||||||
MOBILE_IMPORT_RESULT,
|
MOBILE_IMPORT_RESULT,
|
||||||
tool_output,
|
tool_output,
|
||||||
sizeof(tool_output)
|
sizeof(tool_output)
|
||||||
|
|
@ -2972,7 +3027,9 @@ TrainlogStatus trainlog_sync_run(
|
||||||
: TRAINLOG_STATUS_NOT_FOUND;
|
: TRAINLOG_STATUS_NOT_FOUND;
|
||||||
if (status == TRAINLOG_STATUS_OK) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = sync_run_python_tool("import_exercise_body_zones.py",
|
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));
|
tool_output, sizeof(tool_output));
|
||||||
if (status != TRAINLOG_STATUS_OK ||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) {
|
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",
|
status = sync_run_python_tool("import_equipment_associations.py",
|
||||||
EQUIPMENT_ASSOCIATIONS_LOCAL,
|
EQUIPMENT_ASSOCIATIONS_LOCAL,
|
||||||
database_path,
|
database_path,
|
||||||
|
NULL,
|
||||||
EQUIPMENT_ASSOCIATIONS_RESULT,
|
EQUIPMENT_ASSOCIATIONS_RESULT,
|
||||||
tool_output, sizeof(tool_output));
|
tool_output, sizeof(tool_output));
|
||||||
if (status != TRAINLOG_STATUS_OK ||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
|
@ -3043,9 +3101,29 @@ TrainlogStatus trainlog_sync_run(
|
||||||
}
|
}
|
||||||
|
|
||||||
outbound:
|
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",
|
status = sync_run_python_tool("export_equipment_definitions.py",
|
||||||
PC_EQUIPMENT_DEFINITIONS_LOCAL,
|
PC_EQUIPMENT_DEFINITIONS_LOCAL,
|
||||||
database_path,
|
database_path,
|
||||||
|
NULL,
|
||||||
EQUIPMENT_DEFINITIONS_RESULT,
|
EQUIPMENT_DEFINITIONS_RESULT,
|
||||||
tool_output, sizeof(tool_output));
|
tool_output, sizeof(tool_output));
|
||||||
if (status != TRAINLOG_STATUS_OK ||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
|
@ -3070,6 +3148,7 @@ outbound:
|
||||||
"export_pc_catalog.py",
|
"export_pc_catalog.py",
|
||||||
PC_CATALOG_LOCAL,
|
PC_CATALOG_LOCAL,
|
||||||
database_path,
|
database_path,
|
||||||
|
NULL,
|
||||||
PC_CATALOG_RESULT,
|
PC_CATALOG_RESULT,
|
||||||
tool_output,
|
tool_output,
|
||||||
sizeof(tool_output)
|
sizeof(tool_output)
|
||||||
|
|
@ -3122,7 +3201,8 @@ outbound:
|
||||||
}
|
}
|
||||||
|
|
||||||
status = sync_run_python_tool("export_exercise_body_zones.py",
|
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));
|
tool_output, sizeof(tool_output));
|
||||||
if (status != TRAINLOG_STATUS_OK ||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
strstr(tool_output, "EXERCISE_BODY_ZONES_EXPORT=PASS") == NULL) {
|
strstr(tool_output, "EXERCISE_BODY_ZONES_EXPORT=PASS") == NULL) {
|
||||||
|
|
@ -3144,7 +3224,8 @@ outbound:
|
||||||
* acknowledge that snapshot as its baseline. Reusing the strict importer
|
* acknowledge that snapshot as its baseline. Reusing the strict importer
|
||||||
* records equal state and can never union secondary zones. */
|
* records equal state and can never union secondary zones. */
|
||||||
status = sync_run_python_tool("import_exercise_body_zones.py",
|
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));
|
tool_output, sizeof(tool_output));
|
||||||
if (status != TRAINLOG_STATUS_OK ||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
strstr(tool_output, "EXERCISE_BODY_ZONES_IMPORT=PASS") == NULL) {
|
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,
|
status = sync_run_python_tool("export_pc_mobile.py", PC_MOBILE_EXPORT_LOCAL,
|
||||||
database_path,
|
database_path,
|
||||||
|
NULL,
|
||||||
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
|
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
|
||||||
if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "PC_MOBILE_EXPORT=PASS") == NULL) {
|
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é.");
|
(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",
|
status = sync_run_python_tool("export_equipment_associations.py",
|
||||||
EQUIPMENT_ASSOCIATIONS_LOCAL,
|
EQUIPMENT_ASSOCIATIONS_LOCAL,
|
||||||
database_path,
|
database_path,
|
||||||
|
NULL,
|
||||||
EQUIPMENT_ASSOCIATIONS_RESULT,
|
EQUIPMENT_ASSOCIATIONS_RESULT,
|
||||||
tool_output, sizeof(tool_output));
|
tool_output, sizeof(tool_output));
|
||||||
if (status != TRAINLOG_STATUS_OK ||
|
if (status != TRAINLOG_STATUS_OK ||
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,10 @@ TrainlogSyncScreenAction trainlog_sync_screen_dispatch(
|
||||||
|
|
||||||
direction = state->direction;
|
direction = state->direction;
|
||||||
|
|
||||||
if (key == 'a' || key == 'A') {
|
/* CONTRACT: the desktop product exposes one sync operation. Directional
|
||||||
direction = TRAINLOG_SYNC_ANDROID_TO_PC;
|
* engine modes remain an internal compatibility capability for receipts
|
||||||
} else if (key == 'p' || key == 'P') {
|
* and existing callers, but the TUI always requests the complete exchange. */
|
||||||
direction = TRAINLOG_SYNC_PC_TO_ANDROID;
|
if (key == 's' || key == 'S') {
|
||||||
} else if (key == 'b' || key == 'B') {
|
|
||||||
direction = TRAINLOG_SYNC_BIDIRECTIONAL;
|
direction = TRAINLOG_SYNC_BIDIRECTIONAL;
|
||||||
} else if (state->confirming) {
|
} else if (state->confirming) {
|
||||||
if (key == '\n' || key == TRAINLOG_KEY_ENTER) {
|
if (key == '\n' || key == TRAINLOG_KEY_ENTER) {
|
||||||
|
|
@ -87,7 +86,6 @@ TrainlogSyncScreenAction trainlog_sync_screen_dispatch(
|
||||||
state->direction
|
state->direction
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
/* The former s shortcut is deliberately not an action. */
|
|
||||||
return sync_screen_action(
|
return sync_screen_action(
|
||||||
TRAINLOG_SYNC_SCREEN_NONE,
|
TRAINLOG_SYNC_SCREEN_NONE,
|
||||||
state->direction
|
state->direction
|
||||||
|
|
@ -160,8 +158,8 @@ const char *trainlog_sync_screen_footer(
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (terminal_columns >= 100) {
|
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 <stdlib.h>
|
||||||
|
|
||||||
#include <notcurses/notcurses.h>
|
#include <notcurses/notcurses.h>
|
||||||
|
#include <utf8proc.h>
|
||||||
|
|
||||||
struct TrainlogTerminal {
|
struct TrainlogTerminal {
|
||||||
struct notcurses *notcurses;
|
struct notcurses *notcurses;
|
||||||
|
|
@ -27,11 +28,40 @@ struct TrainlogPanel {
|
||||||
int left;
|
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)
|
static void terminal_apply_style(TrainlogTerminal *terminal)
|
||||||
{
|
{
|
||||||
unsigned red = 205U;
|
|
||||||
unsigned green = 214U;
|
|
||||||
unsigned blue = 244U;
|
|
||||||
TrainlogColorRole role;
|
TrainlogColorRole role;
|
||||||
|
|
||||||
if (terminal == NULL || terminal->plane == NULL) {
|
if (terminal == NULL || terminal->plane == NULL) {
|
||||||
|
|
@ -39,24 +69,14 @@ static void terminal_apply_style(TrainlogTerminal *terminal)
|
||||||
}
|
}
|
||||||
|
|
||||||
role = (TrainlogColorRole)((terminal->style >> 8U) & 0xffU);
|
role = (TrainlogColorRole)((terminal->style >> 8U) & 0xffU);
|
||||||
switch (role) {
|
plane_set_rgb(terminal->plane, true, role_rgb(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);
|
|
||||||
/* CONTRACT: selection remains visible without relying only on foreground
|
/* CONTRACT: selection remains visible without relying only on foreground
|
||||||
* color. A role-aware surface fill survives terminals with weak color
|
* color. A role-aware surface fill survives terminals with weak color
|
||||||
* contrast while ordinary drawing uses the canonical dark background. */
|
* contrast while ordinary drawing uses the canonical dark background. */
|
||||||
if ((terminal->style & TRAINLOG_TEXT_REVERSE) != 0U) {
|
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 {
|
} 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,
|
ncplane_set_styles(terminal->plane,
|
||||||
(terminal->style & TRAINLOG_TEXT_BOLD) != 0U ? NCSTYLE_BOLD : 0U);
|
(terminal->style & TRAINLOG_TEXT_BOLD) != 0U ? NCSTYLE_BOLD : 0U);
|
||||||
|
|
@ -82,7 +102,7 @@ TrainlogTerminal *trainlog_terminal_create(void)
|
||||||
free(terminal);
|
free(terminal);
|
||||||
return NULL;
|
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->pushed_key = TRAINLOG_KEY_NONE;
|
||||||
terminal_apply_style(terminal);
|
terminal_apply_style(terminal);
|
||||||
return terminal;
|
return terminal;
|
||||||
|
|
@ -261,6 +281,10 @@ static int terminal_key(uint32_t id, bool shifted)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (id) {
|
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_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_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;
|
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_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_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_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;
|
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,
|
TrainlogPanel *tui_panel_create(TrainlogTerminal *terminal, int height, int width,
|
||||||
int top, int left)
|
int top, int left)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
16079
tui/src/tui.c
16079
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);
|
CHECK(sqlite3_close(raw) == SQLITE_OK);
|
||||||
raw = NULL;
|
raw = NULL;
|
||||||
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
|
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,
|
CHECK(trainlog_database_list_exercise_body_zones(database,
|
||||||
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(count == 2U);
|
CHECK(count == 2U);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <limits.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
@ -114,9 +116,148 @@ static bool test_custom_equipment_round_trip(void)
|
||||||
return true;
|
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)
|
int main(void)
|
||||||
{
|
{
|
||||||
if (!test_custom_equipment_round_trip()) return 1;
|
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");
|
(void)printf("PASS custom_equipment\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,67 @@ static bool test_transaction_rollback(void)
|
||||||
return true;
|
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 {
|
struct TestCase {
|
||||||
const char *name;
|
const char *name;
|
||||||
bool (*function)(void);
|
bool (*function)(void);
|
||||||
|
|
@ -352,6 +413,7 @@ int main(void)
|
||||||
{"session_insert", test_session_insert},
|
{"session_insert", test_session_insert},
|
||||||
{"body_weight_history", test_body_weight_history},
|
{"body_weight_history", test_body_weight_history},
|
||||||
{"transaction_rollback", test_transaction_rollback},
|
{"transaction_rollback", test_transaction_rollback},
|
||||||
|
{"exercise_merge_aliases_and_conflicts", test_exercise_merge_aliases_and_conflicts},
|
||||||
};
|
};
|
||||||
size_t index;
|
size_t index;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,31 @@ static bool test_measured_max_semantics(void)
|
||||||
working < 85.01
|
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(
|
CHECK(
|
||||||
insert_reps_session(
|
insert_reps_session(
|
||||||
database,
|
database,
|
||||||
|
|
|
||||||
|
|
@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic, "migrate database to schema v11: SQLite rc=",
|
CHECK(strncmp(diagnostic, "migrate database to schema v12: SQLite rc=",
|
||||||
strlen("migrate database to schema v11: SQLite rc=")) == 0);
|
strlen("migrate database to schema v12: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != 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)
|
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];
|
char diagnostic[256];
|
||||||
sqlite3 *raw = NULL;
|
sqlite3 *raw = NULL;
|
||||||
TrainlogDatabase *database = NULL;
|
TrainlogDatabase *database = NULL;
|
||||||
|
|
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
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(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
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(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
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,
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
|
||||||
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
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(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
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;
|
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)
|
int main(void)
|
||||||
{
|
{
|
||||||
TrainlogSyncScreenState state;
|
TrainlogSyncScreenState state;
|
||||||
|
|
@ -54,19 +37,23 @@ int main(void)
|
||||||
TrainlogSyncReport report = {0};
|
TrainlogSyncReport report = {0};
|
||||||
RunRecorder recorder = {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);
|
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.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);
|
action = trainlog_sync_screen_dispatch(&state, 27);
|
||||||
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CANCEL);
|
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CANCEL);
|
||||||
CHECK(!state.confirming);
|
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);
|
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_CONFIRM);
|
||||||
action = trainlog_sync_screen_dispatch(&state, '\n');
|
action = trainlog_sync_screen_dispatch(&state, '\n');
|
||||||
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_RUN);
|
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_RUN);
|
||||||
|
|
@ -74,7 +61,7 @@ int main(void)
|
||||||
&action, record_run, &recorder, &report
|
&action, record_run, &recorder, &report
|
||||||
) == TRAINLOG_STATUS_OK);
|
) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(recorder.calls == 1U);
|
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');
|
action = trainlog_sync_screen_dispatch(&state, '\n');
|
||||||
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_NONE);
|
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_NONE);
|
||||||
CHECK(recorder.calls == 1U);
|
CHECK(recorder.calls == 1U);
|
||||||
|
|
@ -82,7 +69,9 @@ int main(void)
|
||||||
action = trainlog_sync_screen_dispatch(&state, 'r');
|
action = trainlog_sync_screen_dispatch(&state, 'r');
|
||||||
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_REFRESH);
|
CHECK(action.effect == TRAINLOG_SYNC_SCREEN_REFRESH);
|
||||||
CHECK(recorder.calls == 1U);
|
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(action.effect == TRAINLOG_SYNC_SCREEN_NONE);
|
||||||
CHECK(recorder.calls == 1U);
|
CHECK(recorder.calls == 1U);
|
||||||
|
|
||||||
|
|
@ -99,14 +88,10 @@ int main(void)
|
||||||
"Synchroniser Android et PC dans les deux sens maintenant ?"
|
"Synchroniser Android et PC dans les deux sens maintenant ?"
|
||||||
) == 0);
|
) == 0);
|
||||||
|
|
||||||
CHECK(strstr(trainlog_sync_screen_footer(120), "a Android→PC") != NULL);
|
CHECK(strstr(trainlog_sync_screen_footer(120), "Synchroniser maintenant") != NULL);
|
||||||
CHECK(strstr(trainlog_sync_screen_footer(120), "p PC→Android") != NULL);
|
CHECK(strstr(trainlog_sync_screen_footer(80), "Synchroniser PC↔Android") != NULL);
|
||||||
CHECK(strstr(trainlog_sync_screen_footer(120), "b PC↔Android") != NULL);
|
CHECK(strstr(trainlog_sync_screen_footer(120), "Android→PC") == NULL);
|
||||||
CHECK(strstr(trainlog_sync_screen_footer(80), "a A→PC") != NULL);
|
CHECK(strstr(trainlog_sync_screen_footer(120), "PC→Android") == 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);
|
|
||||||
|
|
||||||
puts("PASS sync screen action dispatcher");
|
puts("PASS sync screen action dispatcher");
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,18 @@ static bool test_key_translation(void)
|
||||||
NCKEY_TAB, TRAINLOG_INPUT_PRESS, true, &key));
|
NCKEY_TAB, TRAINLOG_INPUT_PRESS, true, &key));
|
||||||
CHECK(key == TRAINLOG_KEY_SHIFT_TAB);
|
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(
|
CHECK(trainlog_terminal_translate_input(
|
||||||
0x00e9U, TRAINLOG_INPUT_REPEAT, false, &key));
|
0x00e9U, TRAINLOG_INPUT_REPEAT, false, &key));
|
||||||
CHECK(key == 0x00e9);
|
CHECK(key == 0x00e9);
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,15 @@ int main(void)
|
||||||
max_occurrence.load_mode=TRAINLOG_LOAD_NONE;
|
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(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(trainlog_training_exercise_context_load(database,exercise_id,4U,1U,&context)==TRAINLOG_STATUS_OK);
|
||||||
CHECK(strcmp(context.exercise.name,"Leg press renamed")==0);
|
CHECK(strcmp(context.exercise.name,"Leg press renamed")==0);
|
||||||
CHECK(context.knowledge!=NULL && context.knowledge->interpretation!=NULL);
|
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