Compare commits

...

3 commits

Author SHA1 Message Date
fy59
7a12dd3728 release: prepare Trainlog 0.1.1 2026-09-16 07:46:11 +02:00
fy59
d4b56a3aa5 feat(tui): add bilingual presentation 2026-09-16 07:46:01 +02:00
fy59
1674059203 feat(android): refine field workflows and bilingual UI 2026-09-16 07:45:54 +02:00
58 changed files with 5710 additions and 1396 deletions

5
.gitignore vendored
View file

@ -8,6 +8,11 @@ meson-private/
.gradle/
local.properties
**/build/
*.jks
*.keystore
/android/release-signing.properties
/android/release-signing.env
/android/keystore.properties
# Editors
*.swp

View file

@ -1,10 +1,60 @@
# Changelog
- Documented stable-release distribution through GitHub Releases, including
## 0.1.1 — 2026-09-16
- Completed `TRAINLOG_ANDROID_FIELD_INPUT_REFINEMENT_V1`: active and completed
occurrence handles now keep one stable `entry_id`-keyed pointer gesture alive,
accumulate total drag distance, jump directly across multiple positions, show
residual pointer-following motion, restore the origin on cancellation, and
commit only once at completion where persistence applies. Decimal distance,
speed, MAX, target-load, and set-load editors use decimal keyboards, accept
both `.` and `,`, preserve incomplete typing states, and parse finite
locale-independent values only at commit. Units, schemas, formats, identities,
active/completed ownership, and version 0.1.1 remain unchanged.
- Completed `TRAINLOG_ANDROID_COMPLETED_SESSION_REORDER_V1`: the Android
completed-session correction form reuses the dedicated localized right-side
drag handle and accessibility move actions. Reordering remains transient until
Save; Cancel/Back writes nothing, while Save atomically replaces positions and
any factual corrections under the same session/entry/exercise identities.
Stable Compose keys keep editor state attached to `entry_id`; equipment,
targets, actuals, MAX, feedback revisions, and J+1 ownership survive. Existing
V3 synchronization reconciles the same desktop session without duplication.
No schema, exchange format, active-session behavior, or version changed.
- Completed `TRAINLOG_ANDROID_FIELD_WORKFLOW_REFINEMENT_V1`: Android now uses a
full-width semantic-success **Add to session** action, provides a dedicated
accessible right-side drag handle for durable active-draft reordering, and
supports atomic completed-session factual correction. Reorder and correction
retain stable session, occurrence, exercise, equipment, plan, actual, feedback,
and follow-up ownership; corrected facts continue through the existing V3
synchronization identity. Continuous values reject non-finite input and show
explicit km, km/h, and seconds units. No SQLite schema, exchange format,
desktop/TUI behavior, or product version changed.
- Documented stable-release distribution through GitHub and Forgejo Releases,
including
prebuilt Android and architecture-specific dynamically linked Linux assets,
SHA-256 verification, runtime/build/optional dependencies, and the release
signing and exact-tag requirements. No v0.1.0 release is claimed until a
signed APK and authenticated GitHub publication path are available.
signing and exact-tag requirements. The Android artifact is built with the
durable Trainlog release identity and is verified before publication.
- Completed `TRAINLOG_I18N_V0_1_1`: Trainlog 0.1.1 is one synchronized Android
and desktop product version, with French as the default interface language
and English selectable through **Settings → Language** on both surfaces.
The preference is local presentation state (dedicated Android
SharedPreferences; desktop XDG `trainlog/presentation.conf`) and successful
selection updates the interface immediately. Localized Trainlog-owned text,
BODY ZONE labels selected by stable IDs, and visible number/date formatting
do not change training data, user exercise/catalogue names, IDs, SQLite,
schemas, exchange/protocol artifacts, AI, MAX, or feedback. Desktop and
Android synchronization views render local typed status/counters; raw
core/receipt/history summaries remain opaque protocol/operational bytes and
are never injected as cross-device French text. Final validation passed
60/60 normal and 60/60 ASan/UBSan desktop tests, Android 202 total tests
(198 passed, 4 skipped, 0 failed), both validators, source-derived
`TRANSLATABLE_UI=0`, and Android resource parity. Real device/manual visual
switching remains an explicit smoke validation.
- Completed `TRAINLOG_FULL_REPOSITORY_DOCUMENTATION_AUDIT_V2`: added the
canonical documentation index, made README a concise entry point, separated

View file

@ -1,9 +1,10 @@
# Trainlog
Trainlog is a local-first workout and body-data system. Its native Android app
is the lightweight field companion for fast capture and glanceable summaries;
its C17/Notcurses desktop TUI is the detailed surface for correction,
catalogue management, analytics, graphs, and long-term follow-up.
Trainlog is a local-first workout and body-data system. Trainlog Core owns
business truth and canonical desktop persistence. Its native Android app is the
field companion, its C17/Notcurses desktop TUI is the administration and
technical surface, and its documented future local Web sibling is the analysis,
visualization, and program/session-preparation surface.
Android and desktop each own a local SQLite database. The desktop database is
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
@ -13,26 +14,41 @@ over direct MTP; it never copies SQLite database files between devices.
| Surface | Primary responsibility |
|---|---|
| Android | Capture sets, repetitions, loads, durations, continuous activities, feedback, J+1 follow-ups, body measurements, active drafts, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Consult and correct canonical history; manage exercises and equipment; analyze body data, MAX results, training statistics, body zones, calendar periods, and longitudinal graphs. |
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
| Local Web (planned) | Analyze and visualize canonical data; prepare programs and sessions through typed Trainlog Core services. |
In short: **Android captures and summarizes. The TUI analyzes and tracks over
time.** Android intentionally remains lightweight for analytics.
No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI; `TRAINLOG_WEB_V1` is currently
contract-only and has no implementation.
## Releases
Prebuilt Trainlog releases are published on both official mirrors:
When a Trainlog release is published, its prebuilt assets are available from
both official mirrors:
- [GitHub Releases](https://github.com/labfytools/trainlog/releases)
- [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
Each stable release provides:
Each published stable release provides:
- a signed Android APK;
- a Linux x86-64 TUI executable;
- SHA-256 checksums.
Both mirrors publish the same Trainlog version and release assets.
Both mirrors publish the same Trainlog product version and release assets. The
implemented product version is **0.1.1** on Android and desktop; this is one
shared Trainlog version, not separate interface versions. Version 0.1.1 is the
current stable release.
Version 0.1.1 presents Trainlog in French by default, with English selectable
from **Settings → Language** on Android and the desktop TUI. The selection is
local to that installation and updates the visible interface immediately; it
does not translate user exercise/catalogue names or change training data,
stable IDs, databases, schemas, exchange artifacts, synchronization protocols,
AI, MAX, or feedback. Synchronization status is rendered locally from typed
status and counters; raw protocol and operational summaries are not translated
or injected into the other surface.
See the [documentation](docs/README.md) and [changelog](CHANGELOG.md) for more
details.
@ -41,7 +57,7 @@ details.
### Download a prebuilt release
You can download Trainlog from either official mirror:
For a published release, download Trainlog from either official mirror:
- [GitHub](https://github.com/labfytools/trainlog/releases)
- [Forgejo](https://git.labfytools.com/fy59/trainlog/releases)
@ -55,8 +71,8 @@ not a universally portable Linux binary: the host must provide compatible
runtime libraries listed under [Dependencies](#dependencies).
```bash
chmod +x trainlog-tui-linux-x86_64-v0.1.0
./trainlog-tui-linux-x86_64-v0.1.0
chmod +x trainlog-tui-linux-x86_64-v<version>
./trainlog-tui-linux-x86_64-v<version>
```
The executable can remain in the download directory. Moving it into a directory
@ -94,6 +110,19 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleRelease
is configured by the release operator. The repository does not contain signing
credentials and does not silently generate them.
Release hosts provide `TRAINLOG_RELEASE_STORE_FILE`,
`TRAINLOG_RELEASE_STORE_PASSWORD`, `TRAINLOG_RELEASE_KEY_ALIAS`, and
`TRAINLOG_RELEASE_KEY_PASSWORD`, or a private
`~/.config/trainlog/release-signing.properties` file containing the equivalent
`storeFile`, `storePassword`, `keyAlias`, and `keyPassword` keys. Environment
variables take precedence. Keystores and local credential files are ignored by
Git; neither belongs in source control, build logs, or release notes.
The Android release key is the permanent update identity for the application.
The release operator must preserve an encrypted external backup of both the
keystore and its credentials. Losing either prevents future compatible Android
updates; generating a replacement key is not a normal release procedure.
## Dependencies
### Linux runtime dependencies
@ -200,6 +229,7 @@ As of 2026-09-15:
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
Android-triggered bidirectional smoke test.
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`.
The latest executable result belongs in
[current state](docs/current_state.md), not in multiple README narratives.

View file

@ -1,8 +1,40 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.plugin.compose")
}
/*
* CONTRACT: release-signing material is machine-local. Environment variables
* take precedence over the private properties file so CI and release hosts can
* inject secrets without copying them into the source tree.
*/
val releaseSigningProperties = Properties()
val releaseSigningPropertiesPath =
providers.environmentVariable("TRAINLOG_RELEASE_CREDENTIALS_FILE").orNull
?: "${System.getProperty("user.home")}/.config/trainlog/release-signing.properties"
val releaseSigningPropertiesFile = file(releaseSigningPropertiesPath)
if (releaseSigningPropertiesFile.isFile) {
releaseSigningPropertiesFile.inputStream().use(releaseSigningProperties::load)
}
fun releaseSigningValue(environmentName: String, propertyName: String): String? =
providers.environmentVariable(environmentName).orNull
?.takeIf { it.isNotBlank() }
?: releaseSigningProperties.getProperty(propertyName)?.takeIf { it.isNotBlank() }
val releaseStoreFile = releaseSigningValue("TRAINLOG_RELEASE_STORE_FILE", "storeFile")
val releaseStorePassword = releaseSigningValue("TRAINLOG_RELEASE_STORE_PASSWORD", "storePassword")
val releaseKeyAlias = releaseSigningValue("TRAINLOG_RELEASE_KEY_ALIAS", "keyAlias")
val releaseKeyPassword = releaseSigningValue("TRAINLOG_RELEASE_KEY_PASSWORD", "keyPassword")
val releaseSigningComplete = listOf(
releaseStoreFile,
releaseStorePassword,
releaseKeyAlias,
releaseKeyPassword,
).all { !it.isNullOrBlank() }
android {
namespace = "com.labfytools.trainlog"
compileSdk = 37
@ -12,12 +44,31 @@ android {
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
versionCode = 2
versionName = "0.1.1"
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
if (releaseSigningComplete) {
create("release") {
storeFile = file(requireNotNull(releaseStoreFile))
storePassword = requireNotNull(releaseStorePassword)
keyAlias = requireNotNull(releaseKeyAlias)
keyPassword = requireNotNull(releaseKeyPassword)
}
}
}
buildTypes {
getByName("release") {
if (releaseSigningComplete) {
signingConfig = signingConfigs.getByName("release")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@ -53,6 +104,27 @@ android {
}
}
/* WHY: Android Gradle otherwise emits app-release-unsigned.apk successfully,
* which is build evidence but can be mistaken for a publishable artifact.
* INVARIANT: every task that packages a release fails before packaging unless
* all four external values exist and the configured keystore is a regular
* file. Gradle/apksigner still perform the cryptographic validation. */
tasks.matching { it.name == "packageRelease" }.configureEach {
doFirst {
if (!releaseSigningComplete) {
throw GradleException(
"Trainlog release signing is not configured. Set the four " +
"TRAINLOG_RELEASE_* variables or provide the private " +
"release-signing.properties file.",
)
}
val configuredStore = file(requireNotNull(releaseStoreFile))
if (!configuredStore.isFile) {
throw GradleException("Trainlog release keystore does not exist: $configuredStore")
}
}
}
dependencies {
val composeBom =
platform("androidx.compose:compose-bom:2026.08.00")

View file

@ -9,20 +9,26 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.runtime.CompositionLocalProvider
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.ui.TrainlogApp
import com.labfytools.trainlog.ui.TrainlogAppState
import com.labfytools.trainlog.ui.LanguagePresentation
import com.labfytools.trainlog.ui.LanguageSettingsOwner
import com.labfytools.trainlog.ui.LocalLanguagePresentation
import com.labfytools.trainlog.ui.theme.TrainlogTheme
class MainActivity : ComponentActivity() {
private val appState by viewModels<TrainlogAppState>()
private lateinit var languageSettings: LanguageSettingsOwner
override fun onCreate(
savedInstanceState: Bundle?
) {
super.onCreate(savedInstanceState)
languageSettings = LanguageSettingsOwner(applicationContext)
val repository =
TrainlogRepository(
@ -47,15 +53,22 @@ class MainActivity : ComponentActivity() {
)
setContent {
TrainlogTheme {
TrainlogApp(
/* CONTRACT: reading this observable property makes a language
* selection recompose the existing activity immediately. */
val language = languageSettings.language
CompositionLocalProvider(
LocalLanguagePresentation provides LanguagePresentation(language, languageSettings::select),
) {
TrainlogTheme {
TrainlogApp(
repository = repository,
exporter = exporter,
inbox = inbox,
requestOutbox =
requestOutbox,
appState = appState,
)
)
}
}
}
}

View file

@ -6581,13 +6581,13 @@ class TrainlogRepository(
(draft.speedKmh != null)) &&
(
!wantsSpeed ||
draft.speedKmh!! > 0.0
(draft.speedKmh!!.isFinite() && draft.speedKmh > 0.0)
) &&
(wantsDistance ==
(draft.distanceKm != null)) &&
(
!wantsDistance ||
draft.distanceKm!! > 0.0
(draft.distanceKm!!.isFinite() && draft.distanceKm > 0.0)
)
)
}

View file

@ -55,6 +55,25 @@ fun AndroidAppShell(
content: @Composable () -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext().resources
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions
AppSection.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
AppSection.STATISTICS -> R.string.nav_statistics; AppSection.SYNC -> R.string.nav_sync
AppSection.SETTINGS -> R.string.nav_settings
})
fun title(): String = strings.getString(when (route) {
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions
is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
is AppRoute.EquipmentCreate -> R.string.route_equipment_create; AppRoute.Statistics -> R.string.nav_statistics
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
})
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val root = route == route.section.rootRoute()
@ -81,7 +100,7 @@ fun AndroidAppShell(
AppSection.entries.filterNot { it == AppSection.EQUIPMENT }.forEach { section ->
NavigationDrawerItem(
icon = { Icon(painterResource(drawerIcons.getValue(section)), contentDescription = null) },
label = { Text(section.label) },
label = { Text(sectionLabel(section)) },
selected = route.section == section,
onClick = {
onOpenSection(section)
@ -96,14 +115,14 @@ fun AndroidAppShell(
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (root) "TRAINLOG" else routeTitle(route)) },
title = { Text(if (root) "TRAINLOG" else title()) },
navigationIcon = {
IconButton(
onClick = {
if (root) scope.launch { drawerState.open() } else onBack()
},
modifier = Modifier.semantics {
contentDescription = if (root) "Ouvrir la navigation" else "Retour"
contentDescription = strings.getString(if (root) R.string.a11y_open_navigation else R.string.a11y_back)
},
) {
Icon(
@ -116,7 +135,7 @@ fun AndroidAppShell(
if (!root) {
IconButton(
onClick = { scope.launch { drawerState.open() } },
modifier = Modifier.semantics { contentDescription = "Navigation" },
modifier = Modifier.semantics { contentDescription = strings.getString(R.string.a11y_navigation) },
) { Icon(painterResource(R.drawable.ic_menu), contentDescription = null) }
}
},

View file

@ -22,7 +22,7 @@ class AndroidSpeechRecognitionAdapter(context: Context) : SpeechRecognitionAdapt
instance.setRecognitionListener(object : RecognitionListener {
override fun onPartialResults(results: Bundle) = listener.onPartial(best(results))
override fun onResults(results: Bundle) = listener.onFinal(best(results))
override fun onError(error: Int) = listener.onError("Reconnaissance interrompue (code $error).")
override fun onError(error: Int) = listener.onError("speech_error:$error")
override fun onReadyForSpeech(params: Bundle?) = Unit
override fun onBeginningOfSpeech() = Unit
override fun onRmsChanged(rmsdB: Float) = Unit

View file

@ -10,9 +10,9 @@ 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"),
enum class AppSection {
HOME, SESSIONS, EXERCISES,
EQUIPMENT, STATISTICS, SYNC, SETTINGS,
}
sealed interface AppRoute {
@ -158,29 +158,6 @@ class AppNavigationController(
}
}
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.AiSessionDrafts -> "Brouillons"
AppRoute.CompletedSessions -> "Séances effectuées"
is AppRoute.SessionDetail -> "Détail de la séance"
is AppRoute.SessionCorrection -> "Modifier 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.

View file

@ -25,6 +25,7 @@ import com.labfytools.trainlog.data.SaveBodyObservationResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.BodyObservationDraft
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
class BodyScreenState {
val values = List(14) { mutableStateOf("") }
@ -39,6 +40,8 @@ fun BodyScreen(
onBodySaved: () -> Unit,
onBack: () -> Unit,
) {
val strings = localizedContext()
val locale = presentationLocale()
val colors =
LocalTrainlogColors.current
@ -81,13 +84,13 @@ fun BodyScreen(
}
TrainlogScreen(
subtitle = "Mensurations"
subtitle = strings.getString(R.string.route_body_measurements)
) {
TrainlogFrame(
title = "Général"
title = strings.getString(R.string.general)
) {
BodyMetricField(
label = "Poids",
label = strings.getString(R.string.weight),
unit = "kg",
value = bodyWeight,
onValueChange = {
@ -97,7 +100,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Cou",
label = strings.getString(R.string.neck),
unit = "cm",
value = neck,
onValueChange = {
@ -107,7 +110,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Épaules",
label = strings.getString(R.string.shoulders),
unit = "cm",
value = shoulders,
onValueChange = {
@ -117,7 +120,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Poitrine",
label = strings.getString(R.string.chest),
unit = "cm",
value = chest,
onValueChange = {
@ -127,7 +130,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Tour de taille",
label = strings.getString(R.string.waist),
unit = "cm",
value = waist,
onValueChange = {
@ -137,7 +140,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Hanches",
label = strings.getString(R.string.hips),
unit = "cm",
value = hips,
onValueChange = {
@ -148,10 +151,10 @@ fun BodyScreen(
}
TrainlogFrame(
title = "Membres"
title = strings.getString(R.string.limbs)
) {
BodyMetricField(
label = "Bras gauche",
label = strings.getString(R.string.left_arm),
unit = "cm",
value = leftArm,
onValueChange = {
@ -161,7 +164,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Bras droit",
label = strings.getString(R.string.right_arm),
unit = "cm",
value = rightArm,
onValueChange = {
@ -171,7 +174,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Avant-bras gauche",
label = strings.getString(R.string.left_forearm),
unit = "cm",
value = leftForearm,
onValueChange = {
@ -181,7 +184,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Avant-bras droit",
label = strings.getString(R.string.right_forearm),
unit = "cm",
value = rightForearm,
onValueChange = {
@ -191,7 +194,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Cuisse gauche",
label = strings.getString(R.string.left_thigh),
unit = "cm",
value = leftThigh,
onValueChange = {
@ -201,7 +204,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Cuisse droite",
label = strings.getString(R.string.right_thigh),
unit = "cm",
value = rightThigh,
onValueChange = {
@ -211,7 +214,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Mollet gauche",
label = strings.getString(R.string.left_calf),
unit = "cm",
value = leftCalf,
onValueChange = {
@ -221,7 +224,7 @@ fun BodyScreen(
)
BodyMetricField(
label = "Mollet droit",
label = strings.getString(R.string.right_calf),
unit = "cm",
value = rightCalf,
onValueChange = {
@ -232,13 +235,13 @@ fun BodyScreen(
}
TrainlogFrame(
title = "Enregistrement"
title = strings.getString(R.string.recording)
) {
TrainlogAction(
label =
"Enregistrer les mensurations",
strings.getString(R.string.body_save),
description =
"Les champs vides sont ignorés.",
strings.getString(R.string.empty_fields_ignored),
accent =
colors.success,
onClick = {
@ -279,7 +282,7 @@ fun BodyScreen(
}
) {
message =
"Une valeur est invalide."
strings.getString(R.string.invalid_value)
} else {
val doubles =
parsed.map {
@ -337,19 +340,19 @@ fun BodyScreen(
clearForm()
revision += 1
message =
"Mensurations enregistrées."
strings.getString(R.string.body_saved)
onBodySaved()
}
SaveBodyObservationResult.Invalid -> {
message =
"Ajoutez au moins une mesure positive."
strings.getString(R.string.positive_measure_required)
}
SaveBodyObservationResult.DatabaseError -> {
message =
"Erreur base locale."
strings.getString(R.string.local_database_error)
}
}
}
@ -365,7 +368,7 @@ fun BodyScreen(
color =
if (
message ==
"Mensurations enregistrées."
strings.getString(R.string.body_saved)
) {
colors.success
} else {
@ -376,13 +379,13 @@ fun BodyScreen(
}
TrainlogFrame(
title = "Derniers relevés",
title = strings.getString(R.string.latest_measurements),
active =
recent.isNotEmpty(),
) {
if (recent.isEmpty()) {
TrainlogInfo(
"Aucun relevé enregistré."
strings.getString(R.string.body_none)
)
} else {
recent.forEach {
@ -398,14 +401,13 @@ fun BodyScreen(
)
append(
" · ${item.metricCount} mesure(s)"
" · " + strings.resources.getQuantityString(R.plurals.measurement_count, item.metricCount, item.metricCount)
)
item.bodyWeightKg
?.let {
append(
" · %.1f kg"
.format(it)
" · %.1f kg".format(locale, it)
)
}
},

View file

@ -7,11 +7,16 @@ package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import com.labfytools.trainlog.data.CorrectCompletedSessionResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ExerciseProfile
@ -22,6 +27,7 @@ import com.labfytools.trainlog.model.SessionExerciseDetail
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
/**
* WHY: a completed-session correction must be reviewed as one bounded form,
@ -37,6 +43,7 @@ fun CompletedSessionCorrectionScreen(
onFinished: (saved: Boolean) -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val original = remember(sessionId) { repository.getSessionDetail(sessionId) }
var drafts by remember(sessionId) {
mutableStateOf(original?.exercises?.map(SessionExerciseDetail::correctionDraft).orEmpty())
@ -44,81 +51,116 @@ fun CompletedSessionCorrectionScreen(
var pendingSetRemoval by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var pendingOccurrenceRemoval by remember { mutableStateOf<Int?>(null) }
var message by remember { mutableStateOf<String?>(null) }
val decimalText = remember(sessionId) { mutableStateMapOf<String, String>() }
TrainlogScreen("Modifier la séance", scrollKey = "session-correction:$sessionId") {
TrainlogScreen(strings.getString(R.string.route_session_correction), scrollKey = "session-correction:$sessionId") {
if (original == null) {
TrainlogInfo("Séance introuvable.", colors.error)
TrainlogInfo(strings.getString(R.string.session_not_found), colors.error)
return@TrainlogScreen
}
TrainlogInfo("Les anciennes interprétations de profil sont conservées. Les identifiants de la séance et des occurrences retenues ne changent pas.", colors.muted)
TrainlogInfo(strings.getString(R.string.correction_profile_note), colors.muted)
drafts.forEachIndexed { occurrenceIndex, draft ->
TrainlogFrame("${occurrenceIndex + 1}. ${draft.exercise.name}") {
/* INVARIANT: Compose editor/focus state follows the stable entry,
* never a position that can be occupied by another occurrence. */
key(draft.entryId) {
var reorderVisualOffset by remember(draft.entryId) { mutableStateOf(0f) }
TrainlogFrame(
"${occurrenceIndex + 1}. ${draft.exercise.name}",
modifier = Modifier.graphicsLayer { translationY = reorderVisualOffset },
titleTrailing = {
ExerciseReorderHandle(
entryId = draft.entryId,
index = occurrenceIndex,
itemCount = drafts.size,
onMove = { from, to ->
/* CONTRACT: completed history is untouched here;
* Save owns the sole transactional mutation. */
drafts = reorderExerciseOccurrences(drafts, from, to)
},
onCommit = {},
onVisualOffset = { reorderVisualOffset = it },
)
},
) {
draft.plan?.let { plan ->
TrainlogInputField("Nombre de séries cible", plan.sets.toString(), { raw ->
TrainlogInputField(strings.getString(R.string.field_target_sets), plan.sets.toString(), { raw ->
raw.toIntOrNull()?.let { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(sets = value))) { drafts = it } }
})
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions cibles" else "Durée cible par série (s)",
TrainlogInputField(strings.getString(if (draft.exercise.trackingMode == TrackingMode.REPS) R.string.field_target_reps else R.string.field_target_duration),
(plan.reps ?: plan.durationSeconds ?: 0).toString(), { raw -> raw.toIntOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = if (draft.exercise.trackingMode == TrackingMode.REPS) plan.copy(reps = value, durationSeconds = null) else plan.copy(reps = null, durationSeconds = value))) { drafts = it }
} })
TrainlogInputField("Repos (s)", plan.restSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
TrainlogInputField(strings.getString(R.string.field_rest_seconds), plan.restSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(restSeconds = value))) { drafts = it }
} })
if (plan.weightKg != null) TrainlogInputField("Charge cible (kg)", plan.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(weightKg = value))) { drafts = it }
} })
if (plan.weightKg != null) CompletedDecimalField(
"${draft.entryId}:target-weight", strings.getString(R.string.field_target_weight),
plan.weightKg, decimalText, allowZero = false,
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(weightKg = value))) { drafts = it } }
}
if (draft.maxWeightKg != null) {
TrainlogInputField("MAX explicite (kg)", draft.maxWeightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(maxWeightKg = value)) { drafts = it }
} })
CompletedDecimalField(
"${draft.entryId}:max", strings.getString(R.string.field_explicit_max),
draft.maxWeightKg, decimalText, allowZero = false,
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(maxWeightKg = value)) { drafts = it } }
} else if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
TrainlogInputField("Durée continue (s)", draft.continuousDurationSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
TrainlogInputField(strings.getString(R.string.field_continuous_duration), draft.continuousDurationSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(continuousDurationSeconds = value)) { drafts = it }
} })
draft.speedKmh?.let { speed -> TrainlogInputField("Vitesse (km/h)", speed.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = value)) { drafts = it }
} }) }
draft.distanceKm?.let { distance -> TrainlogInputField("Distance (km)", distance.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceDraft(drafts, occurrenceIndex, draft.copy(distanceKm = value)) { drafts = it }
} }) }
draft.speedKmh?.let { speed -> CompletedDecimalField(
"${draft.entryId}:speed", strings.getString(R.string.field_speed), speed,
decimalText, allowZero = false,
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = value)) { drafts = it } } }
draft.distanceKm?.let { distance -> CompletedDecimalField(
"${draft.entryId}:distance", strings.getString(R.string.field_distance), distance,
decimalText, allowZero = false,
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(distanceKm = value)) { drafts = it } } }
} else {
draft.sets.forEachIndexed { setIndex, set ->
TrainlogInfo("Série ${setIndex + 1}", colors.accent)
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions" else "Durée (s)",
TrainlogInfo(strings.getString(R.string.set_number, setIndex + 1), colors.accent)
TrainlogInputField(strings.getString(if (draft.exercise.trackingMode == TrackingMode.REPS) R.string.field_reps else R.string.field_duration_seconds),
(if (draft.exercise.trackingMode == TrackingMode.REPS) set.reps else set.durationSeconds).toString(), { raw -> raw.toIntOrNull()?.let { value ->
val changed = if (draft.exercise.trackingMode == TrackingMode.REPS) set.copy(reps = value) else set.copy(durationSeconds = value)
replaceSet(drafts, occurrenceIndex, setIndex, changed) { drafts = it }
} })
if (set.weightKg != null) TrainlogInputField("Charge (kg)", set.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it }
} })
TrainlogDeleteButton("Supprimer cette série",
if (set.weightKg != null) CompletedDecimalField(
"${draft.entryId}:set-weight:$setIndex", strings.getString(R.string.field_weight),
set.weightKg, decimalText, allowZero = true,
) { value -> replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it } }
TrainlogDeleteButton(strings.getString(R.string.a11y_delete_set),
{ pendingSetRemoval = occurrenceIndex to setIndex },
Modifier.testTag("remove-completed-set-${draft.entryId}-$setIndex"))
}
TrainlogAction("+ Ajouter une série", "Ajouter explicitement un fait réalisé.", {
TrainlogAction(strings.getString(R.string.add_set), strings.getString(R.string.add_set_description), {
replaceDraft(drafts, occurrenceIndex, draft.copy(sets = draft.sets + SessionSetDraft())) { drafts = it }
}, accent = colors.success)
}
TrainlogDeleteButton("Supprimer cette occurrence",
TrainlogDeleteButton(strings.getString(R.string.delete_occurrence),
{ pendingOccurrenceRemoval = occurrenceIndex },
Modifier.testTag("remove-completed-occurrence-${draft.entryId}"))
}
}
}
message?.let { TrainlogInfo(it, colors.error) }
TrainlogPrimaryAction("Enregistrer", "Appliquer toute la correction dans une transaction.", {
TrainlogPrimaryAction(strings.getString(R.string.save), strings.getString(R.string.correction_save_description), {
if (decimalText.any { (field, raw) ->
val value = parseFiniteDecimal(raw)
value == null || if (field.contains(":set-weight:")) value < 0.0 else value <= 0.0
}) {
message = strings.getString(R.string.invalid_decimal_value)
return@TrainlogPrimaryAction
}
when (val result = repository.correctCompletedSession(sessionId, SessionDraft(drafts, original.summary.sessionType))) {
CorrectCompletedSessionResult.Saved -> onFinished(true)
is CorrectCompletedSessionResult.Invalid -> message = result.message
is CorrectCompletedSessionResult.DatabaseError -> message = result.message
is CorrectCompletedSessionResult.Invalid -> message = localizedRepositoryMessage(strings, result.message)
is CorrectCompletedSessionResult.DatabaseError -> message = localizedRepositoryMessage(strings, result.message)
}
})
TrainlogAction("Annuler", "Quitter sans modifier la base.", { onFinished(false) }, accent = colors.muted)
TrainlogAction(strings.getString(R.string.dialog_cancel), strings.getString(R.string.cancel_no_database_change), { onFinished(false) }, accent = colors.muted)
}
pendingSetRemoval?.let { (occurrence, set) ->
DestructiveConfirmationDialog("Supprimer cette série ?", "Ce fait réalisé disparaîtra de la séance corrigée.", "Supprimer", { pendingSetRemoval = null },
deleteContentDescription = "Supprimer cette série") {
DestructiveConfirmationDialog(strings.getString(R.string.delete_set_question), strings.getString(R.string.delete_set_detail), strings.getString(R.string.delete), { pendingSetRemoval = null },
deleteContentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set)) {
val draft = drafts[occurrence]
replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it }
pendingSetRemoval = null
@ -126,16 +168,49 @@ fun CompletedSessionCorrectionScreen(
}
pendingOccurrenceRemoval?.let { occurrence ->
val draft = drafts[occurrence]
DestructiveConfirmationDialog("Supprimer cette occurrence ?",
"Impact : ${draft.sets.size} série(s) réalisée(s)${if (draft.continuousDurationSeconds > 0) ", activité continue" else ""}${if (draft.maxWeightKg != null) ", MAX" else ""}. Ses ressentis et révisions seront supprimés avec leur parent.",
"Supprimer", { pendingOccurrenceRemoval = null },
deleteContentDescription = "Supprimer cette occurrence") {
DestructiveConfirmationDialog(strings.getString(R.string.delete_occurrence_question),
strings.getString(R.string.occurrence_delete_impact,
strings.resources.getQuantityString(R.plurals.performed_set_count, draft.sets.size, draft.sets.size),
if (draft.continuousDurationSeconds > 0) strings.getString(R.string.continuous_activity_suffix) else "",
if (draft.maxWeightKg != null) strings.getString(R.string.max_suffix) else ""),
strings.getString(R.string.delete), { pendingOccurrenceRemoval = null },
deleteContentDescription = strings.getString(R.string.a11y_delete_occurrence)) {
drafts = drafts.filterIndexed { index, _ -> index != occurrence }
pendingOccurrenceRemoval = null
}
}
}
/**
* WHY: a controlled numeric model cannot represent intermediate input such as
* `0,` without destroying what the user is typing.
* CONTRACT: visible text remains raw and accepts both decimal separators;
* finite/sign validation is repeated by Save before repository mutation.
*/
@Composable
private fun CompletedDecimalField(
fieldKey: String,
label: String,
value: Double,
rawValues: MutableMap<String, String>,
allowZero: Boolean,
onParsed: (Double) -> Unit,
) {
val raw = rawValues[fieldKey] ?: presentationNumber(value, 3)
TrainlogInputField(
label = label,
value = raw,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
onValueChange = { updated ->
if (decimalEditorTextValid(updated)) {
rawValues[fieldKey] = updated
parseFiniteDecimal(updated)?.takeIf { if (allowZero) it >= 0.0 else it > 0.0 }
?.let(onParsed)
}
},
)
}
private fun SessionExerciseDetail.correctionDraft() = SessionExerciseDraft(
entryId = entryId,
exercise = ExerciseProfile(exerciseId, exerciseName, exerciseName, recordingMode, trackingMode, dataFields),

View file

@ -47,6 +47,7 @@ import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import com.labfytools.trainlog.R
class ExerciseScreenState {
val name = mutableStateOf("")
@ -73,7 +74,7 @@ class ExerciseScreenState {
}
fun prepareEdit(exercise: ExerciseProfile) {
if (editedExerciseId.value == exercise.exerciseId) return
require(!dirty) { "un éditeur sale ne peut pas être remplacé" }
require(!dirty) { "dirty editor state cannot be replaced" }
editedExerciseId.value = exercise.exerciseId
name.value = exercise.name
recordingMode.value = exercise.recordingMode
@ -106,6 +107,7 @@ fun ExerciseScreen(
) {
val colors =
LocalTrainlogColors.current
val strings = localizedContext()
var name by state.name
var recordingMode by state.recordingMode
@ -150,18 +152,18 @@ fun ExerciseScreen(
}
TrainlogScreen(
subtitle = if (editedExerciseId == null) "Créer un exercice" else "Modifier l'exercice"
subtitle = strings.getString(if (editedExerciseId == null) R.string.route_exercise_create else R.string.route_exercise_edit)
) {
TrainlogFrame(
title =
if (editedExerciseId == null) {
"Nouvel exercice"
strings.getString(R.string.new_exercise)
} else {
"Modifier l'exercice"
strings.getString(R.string.route_exercise_edit)
}
) {
TrainlogField(
label = "Nom",
label = strings.getString(R.string.name),
value = name,
onValueChange = {
name = it
@ -170,10 +172,10 @@ fun ExerciseScreen(
)
TrainlogChoiceGroup(
label = "Organisation",
label = strings.getString(R.string.organization),
) {
TrainlogChoice(
label = "Séries",
label = strings.getString(R.string.organization_sets),
selected =
recordingMode ==
RecordingMode.SETS,
@ -189,7 +191,7 @@ fun ExerciseScreen(
)
TrainlogChoice(
label = "Continu",
label = strings.getString(R.string.continuous),
selected =
recordingMode ==
RecordingMode.CONTINUOUS,
@ -207,7 +209,7 @@ fun ExerciseScreen(
}
TrainlogChoiceGroup(
label = "Mesure principale",
label = strings.getString(R.string.primary_measurement),
) {
if (
recordingMode ==
@ -215,7 +217,7 @@ fun ExerciseScreen(
) {
TrainlogChoice(
label =
"Répétitions",
strings.getString(R.string.field_reps),
selected =
trackingMode ==
TrackingMode.REPS,
@ -230,7 +232,7 @@ fun ExerciseScreen(
}
TrainlogChoice(
label = "Durée",
label = strings.getString(R.string.field_duration),
selected =
trackingMode ==
TrackingMode.DURATION,
@ -250,10 +252,10 @@ fun ExerciseScreen(
) {
TrainlogChoiceGroup(
label =
"Données complémentaires",
strings.getString(R.string.supplemental_data),
) {
TrainlogChoice(
label = "Vitesse",
label = strings.getString(R.string.field_speed_short),
selected = speed,
enabled = true,
onClick = {
@ -263,7 +265,7 @@ fun ExerciseScreen(
)
TrainlogChoice(
label = "Distance",
label = strings.getString(R.string.field_distance_short),
selected = distance,
enabled = true,
onClick = {
@ -276,9 +278,9 @@ fun ExerciseScreen(
}
}
TrainlogChoiceGroup(label = "Zone principale") {
TrainlogChoiceGroup(label = strings.getString(R.string.primary_zone)) {
TrainlogChoice(
label = "Non renseignée",
label = strings.getString(R.string.not_specified_feminine),
selected = primaryZoneId == null,
onClick = {
primaryZoneId = null
@ -288,7 +290,7 @@ fun ExerciseScreen(
)
BodyZoneChoices(zones) { zone, indented ->
TrainlogChoice(
label = (if (indented) " ↳ " else "") + zone.displayName,
label = (if (indented) " ↳ " else "") + localizedBodyZoneName(strings, zone.zoneId, zone.displayName),
selected = primaryZoneId == zone.zoneId,
enabled = zone.kind != BodyZoneKind.GROUP,
onClick = {
@ -300,10 +302,10 @@ fun ExerciseScreen(
}
}
TrainlogChoiceGroup(label = "Zones secondaires") {
TrainlogChoiceGroup(label = strings.getString(R.string.secondary_zones)) {
BodyZoneChoices(zones) { zone, indented ->
TrainlogChoice(
label = (if (indented) " ↳ " else "") + zone.displayName,
label = (if (indented) " ↳ " else "") + localizedBodyZoneName(strings, zone.zoneId, zone.displayName),
selected = zone.zoneId in secondaryZoneIds,
enabled = primaryZoneId != null &&
zone.kind != BodyZoneKind.GROUP && zone.zoneId != primaryZoneId,
@ -359,7 +361,7 @@ fun ExerciseScreen(
if (editedExerciseId != null && repository.canEditExerciseProfile(editedExerciseId!!).not()) {
TrainlogInfo(
text =
"Les séances passées et l'occurrence déjà présente dans le brouillon resteront inchangées. Le nouveau profil s'appliquera aux prochaines utilisations.",
strings.getString(R.string.profile_change_note),
color = colors.warning,
)
}
@ -367,17 +369,17 @@ fun ExerciseScreen(
TrainlogPrimaryAction(
label =
if (editedExerciseId != null) {
"Enregistrer les modifications"
strings.getString(R.string.save_changes)
} else if (inline) {
"Créer et revenir à la séance"
strings.getString(R.string.create_return_session)
} else {
"Enregistrer l'exercice"
strings.getString(R.string.exercise_save)
},
description =
if (editedExerciseId == null) {
"Ajouter ce profil au catalogue local."
strings.getString(R.string.add_profile_description)
} else {
"Conserver l'identité et mettre à jour le catalogue."
strings.getString(R.string.identity_update_description)
},
onClick = save@{
if (editedExerciseId != null && incompatibleProfileChange && !confirmedIncompatible) {
@ -386,7 +388,7 @@ fun ExerciseScreen(
}
if (editedExerciseId == null &&
recordingMode == RecordingMode.SETS && primaryZoneId == null) {
message = "Une zone principale est requise pour un nouvel exercice musculaire."
message = strings.getString(R.string.primary_zone_required)
return@save
}
val current = editedExerciseId
@ -427,12 +429,12 @@ fun ExerciseScreen(
CreateExerciseResult.Conflict -> {
message =
"Un exercice portant ce nom existe déjà."
strings.getString(R.string.exercise_name_exists)
}
CreateExerciseResult.Invalid -> {
message =
"Profil ou nom invalide."
strings.getString(R.string.invalid_profile_name)
}
is CreateExerciseResult.DatabaseError -> {
@ -447,20 +449,20 @@ fun ExerciseScreen(
}
EditExerciseResult.Conflict -> {
message = "Un autre exercice porte déjà ce nom."
message = strings.getString(R.string.other_exercise_name_exists)
}
EditExerciseResult.InvalidNameOrProfile -> {
message = "Nom ou profil invalide."
message = strings.getString(R.string.invalid_name_profile)
}
EditExerciseResult.IncompatibleProfileChange -> {
message =
"Le profil ne peut pas changer après utilisation."
strings.getString(R.string.profile_used_error)
}
EditExerciseResult.DatabaseError -> {
message = "Enregistrement en base impossible."
message = strings.getString(R.string.database_save_failed)
}
}
},
@ -468,8 +470,8 @@ fun ExerciseScreen(
if (editedExerciseId != null) {
TrainlogAction(
label = "Annuler",
description = "Revenir au catalogue sans modification.",
label = strings.getString(R.string.dialog_cancel),
description = strings.getString(R.string.cancel_catalog_description),
accent = colors.muted,
onClick = {
editedExerciseId = null
@ -494,27 +496,27 @@ fun ExerciseScreen(
}
}
if (catalogueVisible) TrainlogFrame(title = "Exercices existants", active = false) {
if (catalogueVisible) TrainlogFrame(title = strings.getString(R.string.existing_exercises), active = false) {
TrainlogInputField(
label = "Recherche par préfixe",
label = strings.getString(R.string.search_prefix),
value = searchQuery,
onValueChange = { searchQuery = it },
)
TrainlogChoiceGroup(label = "Filtre par zone") {
TrainlogChoiceGroup(label = strings.getString(R.string.zone_filter)) {
TrainlogChoice(
label = "Toutes les zones",
label = strings.getString(R.string.all_zones),
selected = filterZoneId == null && !unclassifiedFilter,
onClick = { filterZoneId = null; unclassifiedFilter = false },
)
BodyZoneChoices(zones, includeGroups = true) { zone, indented ->
TrainlogChoice(
label = (if (indented) " ↳ " else "") + zone.displayName,
label = (if (indented) " ↳ " else "") + localizedBodyZoneName(strings, zone.zoneId, zone.displayName),
selected = filterZoneId == zone.zoneId && !unclassifiedFilter,
onClick = { filterZoneId = zone.zoneId; unclassifiedFilter = false },
)
}
TrainlogChoice(
label = "Non renseignés",
label = strings.getString(R.string.not_specified_plural),
selected = unclassifiedFilter,
onClick = { filterZoneId = null; unclassifiedFilter = true },
)
@ -526,20 +528,20 @@ fun ExerciseScreen(
unclassifiedOnly = unclassifiedFilter,
)
if (exercises.isEmpty()) {
TrainlogInfo("Aucun exercice enregistré.")
TrainlogInfo(strings.getString(R.string.exercise_none_saved))
} else {
exercises.forEach { exercise ->
TrainlogAction(
label = "Modifier · ${exercise.name}",
description = exerciseZoneSummary(repository, exercise),
label = strings.getString(R.string.modify_named, exercise.name),
description = exerciseZoneSummary(repository, exercise, strings),
accent = colors.accent,
onClick = { startEditing(exercise) },
)
repository.getExerciseKnowledge(exercise.exerciseId)?.let { knowledge ->
val expanded = exercise.exerciseId in expandedKnowledgeIds
TrainlogAction(
label = if (expanded) "− Connaissances" else "+ Connaissances",
description = knowledgeSummary(knowledge),
label = strings.getString(if (expanded) R.string.knowledge_collapse else R.string.knowledge_expand),
description = knowledgeSummary(knowledge, strings),
accent = colors.muted,
onClick = {
expandedKnowledgeIds = if (expanded) {
@ -552,8 +554,8 @@ fun ExerciseScreen(
if (expanded) KnowledgePanel(repository, knowledge)
}
TrainlogAction(
label = "Voir les derniers MAX",
description = "Ouvrir la liste agrégée existante dans Statistiques.",
label = strings.getString(R.string.view_latest_max),
description = strings.getString(R.string.view_latest_max_description),
accent = colors.warning,
onClick = onOpenMaxima,
)
@ -562,7 +564,7 @@ fun ExerciseScreen(
}
TrainlogFrame(
title = "Contrat",
title = strings.getString(R.string.contract),
active = false,
) {
TrainlogInfo(
@ -570,20 +572,20 @@ fun ExerciseScreen(
)
TrainlogInfo(
"Aucune règle ne dépend du nom."
strings.getString(R.string.name_no_rules)
)
}
}
if (confirmingIncompatible) AlertDialog(
onDismissRequest = { confirmingIncompatible = false },
title = { Text("Modifier le mode de suivi ?") },
text = { Text("Les anciennes séances et le brouillon actif resteront inchangés. Les nouvelles utilisations suivront le nouveau profil.") },
title = { Text(strings.getString(R.string.tracking_change_question)) },
text = { Text(strings.getString(R.string.profile_change_dialog_detail)) },
confirmButton = { TextButton(onClick = {
confirmedIncompatible = true
confirmingIncompatible = false
message = "Transition confirmée. Enregistrez pour appliquer le profil aux prochaines utilisations."
}) { Text("Modifier") } },
dismissButton = { TextButton(onClick = { confirmingIncompatible = false }) { Text("Annuler") } },
message = strings.getString(R.string.profile_transition_confirmed)
}) { Text(strings.getString(R.string.modify)) } },
dismissButton = { TextButton(onClick = { confirmingIncompatible = false }) { Text(strings.getString(R.string.dialog_cancel)) } },
)
}
@ -595,6 +597,7 @@ fun ExerciseEditorRoute(
inline: Boolean,
onSaved: () -> Unit,
) {
val strings = localizedContext()
val profile = remember(exerciseId) {
exerciseId?.let { id -> repository.listExercises().firstOrNull { it.exerciseId == id } }
}
@ -602,9 +605,9 @@ fun ExerciseEditorRoute(
if (profile == null) state.prepareCreate() else state.prepareEdit(profile)
}
if (exerciseId != null && profile == null) {
TrainlogScreen("Modifier l'exercice") { TrainlogInfo("Exercice introuvable.") }
TrainlogScreen(strings.getString(R.string.route_exercise_edit)) { TrainlogInfo(strings.getString(R.string.exercise_not_found)) }
} else if (state.editedExerciseId.value != exerciseId) {
TrainlogScreen("Modifier l'exercice") { TrainlogInfo("Chargement du profil…") }
TrainlogScreen(strings.getString(R.string.route_exercise_edit)) { TrainlogInfo(strings.getString(R.string.profile_loading)) }
} else {
ExerciseScreen(repository, state, inline, {}, onSaved, {}, catalogueVisible = false)
}
@ -613,6 +616,7 @@ fun ExerciseEditorRoute(
@Composable
private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKnowledge) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
/* WHY: conditional content is opened only under an explicit uncertainty
* label; it cannot be mistaken for an ordinary resolved classification. */
val interpretation = when (knowledge.resolutionStatus) {
@ -621,11 +625,11 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
ExerciseKnowledgeStatus.UNRESOLVED -> null
}
if (interpretation == null) {
TrainlogInfo("Classification scientifique non résolue.", colors.warning)
TrainlogInfo("Confiance : ${confidenceLabel(knowledge.confidence)}", colors.muted)
TrainlogInfo(strings.getString(R.string.science_unresolved), colors.warning)
TrainlogInfo(strings.getString(R.string.confidence_inline, confidenceLabel(knowledge.confidence, strings)), colors.muted)
} else if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) {
TrainlogInfo(
"Interprétation conditionnelle · à confirmer : ${interpretation.requiredConfirmation.orEmpty()}",
strings.getString(R.string.conditional_interpretation, interpretation.requiredConfirmation.orEmpty()),
colors.warning,
)
}
@ -635,24 +639,26 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
val secondary = resolved.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val stabilizers = resolved.stabilizerMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val actions = resolved.actionIds.mapNotNull(repository::getJointActionKnowledge)
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.displayName ?: resolved.primaryZoneId
val secondaryZones = resolved.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.let { localizedBodyZoneName(strings, it.zoneId, it.displayName) } ?: resolved.primaryZoneId
val secondaryZones = resolved.secondaryZoneIds.map { id -> repository.bodyZone(id)?.let { localizedBodyZoneName(strings, it.zoneId, it.displayName) } ?: id }
val runtimeEquipment = repository.listEquipment().associateBy { it.equipmentId }
val equipment = repository.listEquipmentForKnownExercise(knowledge.exerciseId)
.map { runtimeEquipment[it.equipmentId]?.displayName ?: it.equipmentId }
TrainlogInfo("Mouvements : ${actions.joinToString { it.displayNameFr }.ifEmpty { patterns.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("Stabilisateurs : ${stabilizers.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
TrainlogInfo("Zones scientifiques : $primaryZone" + if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}")
TrainlogInfo("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}")
TrainlogInfo("Confiance : ${confidenceLabel(resolved.confidence)}", colors.muted)
val french = LocalLanguagePresentation.current.language == AppLanguage.FRENCH
TrainlogInfo(strings.getString(R.string.movements_value, actions.joinToString { scientificCatalogLabel(it.actionId, it.displayNameFr, null, french) }.ifEmpty { patterns.joinToString { scientificCatalogLabel(it.patternId, it.displayNameFr, null, french) }.ifEmpty { strings.getString(R.string.unclassified) } }))
TrainlogInfo(strings.getString(R.string.primary_muscles, primary.joinToString { scientificCatalogLabel(it.muscleId, it.displayNameFr, it.displayName, french) }.ifEmpty { strings.getString(R.string.unclassified) }))
TrainlogInfo(strings.getString(R.string.secondary_muscles, secondary.joinToString { scientificCatalogLabel(it.muscleId, it.displayNameFr, it.displayName, french) }.ifEmpty { strings.getString(R.string.none_established) }))
TrainlogInfo(strings.getString(R.string.stabilizers, stabilizers.joinToString { scientificCatalogLabel(it.muscleId, it.displayNameFr, it.displayName, french) }.ifEmpty { strings.getString(R.string.none_established) }))
TrainlogInfo(strings.getString(R.string.scientific_zones, primaryZone,
if (secondaryZones.isEmpty()) "" else strings.getString(R.string.secondary_suffix, secondaryZones.joinToString())))
TrainlogInfo(strings.getString(R.string.compatible_equipment_value, equipment.joinToString().ifEmpty { strings.getString(R.string.not_established) }))
TrainlogInfo(strings.getString(R.string.confidence_inline, confidenceLabel(resolved.confidence, strings)), colors.muted)
}
knowledge.limitations.forEach { TrainlogInfo("Limite : $it", colors.muted) }
knowledge.limitations.forEach { TrainlogInfo(strings.getString(R.string.limitation_value, it), colors.muted) }
knowledge.sourceRefs.mapNotNull(repository::getScienceReference).forEach { reference ->
TrainlogInfo(
"Source : ${reference.authorsOrOrganization} · ${reference.title}" +
(reference.year?.let { " ($it)" } ?: ""),
strings.getString(R.string.source_value, reference.authorsOrOrganization, reference.title,
reference.year?.let { " ($it)" } ?: ""),
colors.muted,
)
}
@ -666,27 +672,28 @@ fun ExerciseCatalogueScreen(
onCreate: () -> Unit,
onOpenDetail: (String) -> Unit,
) {
val strings = localizedContext()
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) {
TrainlogScreen(strings.getString(R.string.exercise_catalog), scrollKey = "exercise-catalogue") {
TrainlogPrimaryAction(strings.getString(R.string.route_exercise_create), strings.getString(R.string.create_exercise_description), onCreate)
TrainlogInputField(strings.getString(R.string.search_prefix), query, { query = it })
TrainlogChoiceGroup(strings.getString(R.string.filter)) {
TrainlogChoice(strings.getString(R.string.all_zones), zoneId == null && !unclassified) {
zoneId = null; unclassified = false
}
zones.filter { it.parentZoneId == null }.forEach { zone ->
TrainlogChoice(zone.displayName, zoneId == zone.zoneId && !unclassified) {
TrainlogChoice(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), zoneId == zone.zoneId && !unclassified) {
zoneId = zone.zoneId; unclassified = false
}
}
TrainlogChoice("Non renseignés", unclassified) { zoneId = null; unclassified = true }
TrainlogChoice(strings.getString(R.string.not_specified_plural), unclassified) { zoneId = null; unclassified = true }
}
TrainlogFrame("Catalogue", active = exercises.isNotEmpty()) {
if (exercises.isEmpty()) TrainlogInfo("Aucun exercice trouvé.")
TrainlogFrame(strings.getString(R.string.catalog), active = exercises.isNotEmpty()) {
if (exercises.isEmpty()) TrainlogInfo(strings.getString(R.string.exercise_none_found))
if (exercises.isNotEmpty()) {
/* CONTRACT: filtering and ordering stay repository-owned. This
* fixed viewport bounds presentation only; its child owns the
@ -699,7 +706,7 @@ fun ExerciseCatalogueScreen(
.testTag("exercise-catalogue-scroll"),
) {
exercises.forEach { exercise ->
TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise), {
TrainlogAction(exercise.name, exerciseZoneSummary(repository, exercise, strings), {
state.selectedExerciseId = exercise.exerciseId
onOpenDetail(exercise.exerciseId)
})
@ -718,24 +725,25 @@ fun ExerciseDetailScreen(
onOpenMaxima: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val context = remember(exerciseId) { repository.getTrainingExerciseContext(exerciseId) }
TrainlogScreen("Fiche exercice", scrollKey = "exercise-detail:$exerciseId") {
TrainlogScreen(strings.getString(R.string.route_exercise_detail), scrollKey = "exercise-detail:$exerciseId") {
if (context == null) {
TrainlogInfo("Exercice introuvable.", colors.error)
TrainlogInfo(strings.getString(R.string.exercise_not_found), colors.error)
return@TrainlogScreen
}
TrainlogFrame("Profil") {
TrainlogFrame(strings.getString(R.string.profile)) {
TrainlogInfo(context.exercise.name, colors.accent)
TrainlogInfo(exerciseZoneSummary(repository, context.exercise))
TrainlogInfo(exerciseZoneSummary(repository, context.exercise, strings))
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)
TrainlogAction(strings.getString(R.string.modify), strings.getString(R.string.exercise_modify_description), onModify)
}
TrainlogFrame("Connaissances", active = context.knowledge != null) {
TrainlogFrame(strings.getString(R.string.knowledge), active = context.knowledge != null) {
context.knowledge?.let { KnowledgePanel(repository, it) }
?: TrainlogInfo("Aucune connaissance scientifique liée à cet identifiant.", colors.muted)
?: TrainlogInfo(strings.getString(R.string.knowledge_none), colors.muted)
}
TrainlogFrame("Équipements compatibles", active = context.compatibleEquipment.isNotEmpty()) {
if (context.compatibleEquipment.isEmpty()) TrainlogInfo("Compatibilité non établie.")
TrainlogFrame(strings.getString(R.string.compatible_equipment), active = context.compatibleEquipment.isNotEmpty()) {
if (context.compatibleEquipment.isEmpty()) TrainlogInfo(strings.getString(R.string.compatibility_none))
context.compatibleEquipment.forEach { equipment ->
val name = repository.listEquipment().firstOrNull { it.equipmentId == equipment.equipmentId }?.displayName
?: listOfNotNull(equipment.manufacturer, equipment.model).joinToString(" ").ifBlank { equipment.equipmentId }
@ -744,25 +752,25 @@ fun ExerciseDetailScreen(
}
context.latestExplicitMax?.let { max ->
TrainlogFrame("MAX") {
TrainlogInfo("Dernier résultat : ${max.maxWeightKg} kg · ${max.startedAt.take(10)}", colors.warning)
TrainlogInfo(strings.getString(R.string.latest_result, max.maxWeightKg, formatDate(max.startedAt)), colors.warning)
}
}
TrainlogAction("Voir les derniers MAX", "Ouvrir la liste agrégée existante dans Statistiques.", onOpenMaxima)
TrainlogAction(strings.getString(R.string.view_latest_max), strings.getString(R.string.view_latest_max_description), onOpenMaxima)
}
}
private fun knowledgeSummary(knowledge: ExerciseKnowledge): String = when (knowledge.resolutionStatus) {
private fun knowledgeSummary(knowledge: ExerciseKnowledge, context: android.content.Context): String = when (knowledge.resolutionStatus) {
ExerciseKnowledgeStatus.RESOLVED_FAMILY_VARIANT_LIMITED ->
"Classification scientifique · confiance ${confidenceLabel(knowledge.confidence)}"
context.getString(R.string.science_classification_confidence, confidenceLabel(knowledge.confidence, context))
ExerciseKnowledgeStatus.CONDITIONAL ->
"Classification conditionnelle · incertitude explicite"
ExerciseKnowledgeStatus.UNRESOLVED -> "Classification scientifique non résolue"
context.getString(R.string.conditional_classification)
ExerciseKnowledgeStatus.UNRESOLVED -> context.getString(R.string.science_unresolved)
}
private fun confidenceLabel(confidence: KnowledgeConfidence): String = when (confidence) {
KnowledgeConfidence.HIGH -> "élevée"
KnowledgeConfidence.MODERATE -> "modérée"
KnowledgeConfidence.UNCERTAIN -> "incertaine"
private fun confidenceLabel(confidence: KnowledgeConfidence, context: android.content.Context): String = when (confidence) {
KnowledgeConfidence.HIGH -> context.getString(R.string.confidence_high)
KnowledgeConfidence.MODERATE -> context.getString(R.string.confidence_moderate)
KnowledgeConfidence.UNCERTAIN -> context.getString(R.string.confidence_uncertain)
}
@Composable
@ -771,11 +779,12 @@ private fun BodyZoneChoices(
includeGroups: Boolean = false,
content: @Composable (BodyZone, Boolean) -> Unit,
) {
val strings = localizedContext()
zones.forEach { zone ->
if (includeGroups || zone.kind != BodyZoneKind.GROUP) {
content(zone, zone.parentZoneId != null)
} else {
TrainlogInfo(zone.displayName)
TrainlogInfo(localizedBodyZoneName(strings, zone.zoneId, zone.displayName))
}
}
}
@ -783,19 +792,26 @@ private fun BodyZoneChoices(
private fun exerciseZoneSummary(
repository: TrainlogRepository,
exercise: ExerciseProfile,
context: android.content.Context,
): String {
val primary = exercise.primaryZoneId?.let(repository::bodyZone)
?: return "Zone : Non renseignée"
?: return context.getString(R.string.zone_not_specified)
val secondary = exercise.secondaryZoneIds.mapNotNull(repository::bodyZone)
val group = repository.bodyZoneAncestors(primary.zoneId).firstOrNull()
return buildString {
append("Zone principale : ${primary.displayName}")
append(" · Zones secondaires : ")
append(if (secondary.isEmpty()) "Aucune" else secondary.joinToString { it.displayName })
group?.let { append(" · Groupe : ${it.displayName}") }
append(context.getString(R.string.primary_zone_value, localizedBodyZoneName(context, primary.zoneId, primary.displayName)))
append(context.getString(R.string.secondary_zones_value,
if (secondary.isEmpty()) context.getString(R.string.value_none_feminine) else secondary.joinToString { localizedBodyZoneName(context, it.zoneId, it.displayName) }))
group?.let { append(context.getString(R.string.group_value, localizedBodyZoneName(context, it.zoneId, it.displayName))) }
}
}
/** INVARIANT: scientific catalog IDs/names are never rewritten; this derives presentation text only. */
private fun scientificCatalogLabel(id: String, frenchLabel: String, englishLabel: String?, french: Boolean): String =
if (french) frenchLabel else englishLabel ?: id.split('_').joinToString(" ") { token ->
token.replaceFirstChar { character -> character.uppercase() }
}
@Composable
private fun TrainlogField(
label: String,

View file

@ -45,16 +45,16 @@ class FeedbackDictationController(
fun edit(text: String) { if (state.phase != DictationPhase.Listening) update(state.copy(
phase = DictationPhase.Editing, committedText = text, partialText = "", message = null)) }
fun start(permissionGranted: Boolean) {
fun start(permissionGranted: Boolean, languageTag: String = "fr-FR") {
if (!permissionGranted) {
update(state.copy(phase = DictationPhase.Error, partialText = "",
message = "Microphone refusé : la saisie manuelle reste disponible."))
message = "feedback_microphone_denied"))
} else if (!recognizer.available) {
update(state.copy(phase = DictationPhase.Error, partialText = "",
message = "Reconnaissance vocale indisponible : utilisez la saisie manuelle."))
message = "feedback_speech_unavailable"))
} else {
update(state.copy(phase = DictationPhase.Listening, partialText = "", message = null))
recognizer.start("fr-FR", this)
recognizer.start(languageTag, this)
}
}
@ -78,7 +78,7 @@ class FeedbackDictationController(
fun beginSaving(): String? {
val text = state.visibleText.trim()
if (text.isBlank()) { update(state.copy(phase = DictationPhase.Error,
message = "Un ressenti vide ne peut pas être enregistré.")); return null }
message = "feedback_empty")); return null }
update(state.copy(phase = DictationPhase.Saving, committedText = text, partialText = ""))
return text
}

View file

@ -33,6 +33,7 @@ import com.labfytools.trainlog.ui.theme.TrainlogTypography
import androidx.core.content.ContextCompat
import com.labfytools.trainlog.data.MAX_FEEDBACK_UTF8_BYTES
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
@Composable
fun FeedbackEditor(
@ -44,6 +45,15 @@ fun FeedbackEditor(
) {
val context = LocalContext.current
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val speechTag = if (LocalLanguagePresentation.current.language == AppLanguage.FRENCH) "fr-FR" else "en-US"
fun message(value: String): String = when (value) {
"feedback_microphone_denied" -> strings.getString(R.string.feedback_microphone_denied)
"feedback_speech_unavailable" -> strings.getString(R.string.feedback_speech_unavailable)
"feedback_empty" -> strings.getString(R.string.feedback_empty)
else -> if (value.startsWith("speech_error:")) strings.getString(R.string.speech_interrupted,
value.substringAfter(':').toIntOrNull() ?: -1) else value
}
var state by remember(initialText) { mutableStateOf(DictationState(committedText = initialText)) }
val controller = remember(initialText) { FeedbackDictationController(
recognizerFactory?.invoke(context) ?: AndroidSpeechRecognitionAdapter(context),
@ -51,7 +61,7 @@ fun FeedbackEditor(
if (initialText.isNotEmpty()) it.edit(initialText)
} }
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
controller.start(granted)
controller.start(granted, speechTag)
}
DisposableEffect(controller) { onDispose { controller.destroy() } }
val transcriptScroll = rememberScrollState()
@ -64,10 +74,10 @@ fun FeedbackEditor(
}
}
TrainlogFrame(title = "Dictée du ressenti") {
TrainlogFrame(title = strings.getString(R.string.feedback_dictation)) {
if (state.phase == DictationPhase.Listening) {
TrainlogInfo("● Enregistrement en cours", color = colors.error)
TrainlogIconAction(TrainlogIcons.Stop, "Arrêter la dictée",
TrainlogInfo(strings.getString(R.string.feedback_recording), color = colors.error)
TrainlogIconAction(TrainlogIcons.Stop, strings.getString(R.string.feedback_stop),
onClick = { controller.stop() }, accent = colors.warning,
modifier = Modifier.testTag("feedback-stop"))
Box(Modifier.fillMaxWidth().heightIn(min = 96.dp, max = 192.dp)
@ -76,22 +86,22 @@ fun FeedbackEditor(
BasicText(state.visibleText, style = TrainlogTypography.normal.copy(color = colors.text))
}
} else {
TrainlogInputField("Texte", state.visibleText, onValueChange = {
TrainlogInputField(strings.getString(R.string.feedback_text), state.visibleText, onValueChange = {
if (it.toByteArray(Charsets.UTF_8).size <= MAX_FEEDBACK_UTF8_BYTES) controller.edit(it)
}, testTag = "feedback-edit-transcript", singleLine = false, minLines = 4, maxLines = 8)
val resume = state.committedText.isNotBlank()
TrainlogIconAction(TrainlogIcons.Mic,
if (resume) "Reprendre la dictée" else "Démarrer la dictée", onClick = {
strings.getString(if (resume) R.string.feedback_resume else R.string.feedback_start), onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED)
controller.start(true) else permission.launch(Manifest.permission.RECORD_AUDIO)
controller.start(true, speechTag) else permission.launch(Manifest.permission.RECORD_AUDIO)
}, accent = colors.accent, modifier = Modifier.testTag("feedback-mic"))
TrainlogAction(if (editing) "Enregistrer les modifications" else "Enregistrer",
if (editing) "Créer une nouvelle révision immutable." else "Créer un enregistrement historique immutable.", onClick = {
TrainlogAction(strings.getString(if (editing) R.string.save_changes else R.string.save),
strings.getString(if (editing) R.string.feedback_revision_description else R.string.feedback_create_description), onClick = {
controller.beginSaving()?.let { text -> onSave(text)?.let(controller::saveFailed) }
}, accent = colors.success)
TrainlogAction("Annuler", "Ne rien enregistrer.",
TrainlogAction(strings.getString(R.string.dialog_cancel), strings.getString(R.string.save_nothing),
onClick = { controller.cancel(); onCancel() }, accent = colors.muted)
}
state.message?.let { TrainlogInfo(it, color = colors.error) }
state.message?.let { TrainlogInfo(message(it), color = colors.error) }
}
}

View file

@ -10,6 +10,9 @@ import androidx.compose.runtime.remember
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
@Composable
fun HistoryScreen(
@ -17,6 +20,8 @@ fun HistoryScreen(
onBack: () -> Unit,
onOpenSession: (String) -> Unit,
) {
val locale = presentationLocale()
val strings = localizedContext()
val colors =
LocalTrainlogColors.current
@ -30,16 +35,16 @@ fun HistoryScreen(
}
TrainlogScreen(
subtitle = "Séances effectuées"
subtitle = strings.getString(R.string.route_completed_sessions)
) {
TrainlogFrame(
title = "Séances",
title = strings.getString(R.string.nav_sessions),
active =
sessions.isNotEmpty(),
) {
if (sessions.isEmpty()) {
TrainlogInfo(
"Aucune séance enregistrée."
strings.getString(R.string.history_none)
)
} else {
sessions.forEach {
@ -54,8 +59,8 @@ fun HistoryScreen(
sessionTypeLabel(
session.sessionType
) +
" · " +
"${session.exerciseCount} exercice(s)",
" · " + strings.resources.getQuantityString(
R.plurals.exercise_count, session.exerciseCount, session.exerciseCount),
onClick = {
onOpenSession(
session.sessionId
@ -76,21 +81,22 @@ fun HistoryScreen(
}
TrainlogFrame(
title = "Derniers MAX",
title = strings.getString(R.string.route_latest_maxima),
active = latestMaxima.isNotEmpty(),
) {
if (latestMaxima.isEmpty()) {
TrainlogInfo("Aucun max explicite enregistré.")
TrainlogInfo(strings.getString(R.string.max_none))
} else {
latestMaxima.forEach { max ->
val weight = "%.2f".format(java.util.Locale.FRANCE, max.maxWeightKg)
val weight = "%.2f".format(locale, max.maxWeightKg)
.trimEnd('0').trimEnd(',')
TrainlogInfo(
text = "${max.exerciseName} · $weight kg · ${formatStartedAt(max.startedAt).take(10)}",
text = "${max.exerciseName} · $weight kg · ${formatDate(max.startedAt)}",
color = colors.warning,
)
TrainlogInfo(
text = "Équipement : ${max.equipmentDisplayName ?: "aucun"}",
text = strings.getString(R.string.equipment_value,
max.equipmentDisplayName ?: strings.getString(R.string.value_none)),
color = colors.muted,
)
}
@ -99,6 +105,7 @@ fun HistoryScreen(
}
}
@Composable
internal fun sessionTypeLabel(
value: SessionType,
): String =
@ -106,18 +113,27 @@ internal fun sessionTypeLabel(
value ==
SessionType.MAX_TEST
) {
"TEST MAX"
uiString(R.string.session_max_test)
} else {
"ENTRAÎNEMENT"
uiString(R.string.session_training)
}
@Composable
internal fun formatStartedAt(
value: String,
): String {
return value
.replace(
'T',
' '
)
.take(16)
val locale = presentationLocale()
val pattern = uiString(R.string.date_time_pattern)
return runCatching {
OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern(pattern, locale))
}.getOrElse { value.replace('T', ' ').take(16) }
}
@Composable
internal fun formatDate(value: String): String {
val locale = presentationLocale()
val pattern = uiString(R.string.date_pattern)
return runCatching {
OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern(pattern, locale))
}.getOrElse { value.take(10) }
}

View file

@ -58,6 +58,7 @@ import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import com.labfytools.trainlog.R
@Composable
fun HomeScreen(
@ -74,32 +75,35 @@ fun HomeScreen(
onSync: () -> Unit,
) {
val colors = LocalTrainlogColors.current
TrainlogScreen("Accueil", scrollKey = "home") {
val strings = localizedContext()
val locale = presentationLocale()
TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") {
draftError?.let { TrainlogInfo(it, colors.error) }
TrainlogFrame("Séance") {
TrainlogFrame(strings.getString(R.string.home_session)) {
if (activeDraft != null) {
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement"
TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession)
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) strings.getString(R.string.session_max_test) else strings.getString(R.string.training)
TrainlogPrimaryAction(strings.getString(R.string.resume_current_session), "$kind · " + strings.resources.getQuantityString(R.plurals.exercise_count, activeDraft.exercises.size, activeDraft.exercises.size), onSession)
} else {
TrainlogPrimaryAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
}
}
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics)
latestSession?.let { session ->
TrainlogFrame("Dernière séance") {
TrainlogAction(formatStartedAt(session.startedAt), "${sessionTypeLabel(session.sessionType)} · ${session.exerciseCount} exercice(s)", { onOpenLatestSession(session.sessionId) })
TrainlogFrame(strings.getString(R.string.latest_session)) {
TrainlogAction(formatStartedAt(session.startedAt), sessionTypeLabel(session.sessionType) + " · " +
strings.resources.getQuantityString(R.plurals.exercise_count, session.exerciseCount, session.exerciseCount), { onOpenLatestSession(session.sessionId) })
}
}
latestMaximum?.let { maximum ->
val weight = "%.2f".format(java.util.Locale.FRANCE, maximum.maxWeightKg).trimEnd('0').trimEnd(',')
TrainlogInfo("Dernier MAX · ${maximum.exerciseName} · $weight kg · ${maximum.startedAt.take(10)}", colors.warning)
val weight = "%.2f".format(locale, maximum.maxWeightKg).trimEnd('0').trimEnd(',', '.')
TrainlogInfo(strings.getString(R.string.latest_max_value, maximum.exerciseName, weight, formatDate(maximum.startedAt)), colors.warning)
}
TrainlogFrame("Accès rapides", active = false) {
TrainlogFrame(strings.getString(R.string.quick_access), active = false) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TrainlogActionTile("Synchronisation", "État et actions", TrainlogIcons.Refresh,
TrainlogActionTile(strings.getString(R.string.nav_sync), strings.getString(R.string.home_sync_state), TrainlogIcons.Refresh,
onSync, Modifier.weight(1f).fillMaxHeight().testTag("home-sync-action"))
TrainlogActionTile("Mensurations", "Suivi corporel", TrainlogIcons.BodyMeasurements,
TrainlogActionTile(strings.getString(R.string.route_body_measurements), strings.getString(R.string.home_body_tracking), TrainlogIcons.BodyMeasurements,
onBody, Modifier.weight(1f).fillMaxHeight().testTag("home-body-action"))
}
}
@ -118,75 +122,76 @@ internal fun BodyZoneHomeSection(
onOpenStatistics: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
var showExposureInfo by remember { mutableStateOf(false) }
var selectedId by remember(overview.zones) {
mutableStateOf(overview.recommendations.firstOrNull()?.zoneId ?: overview.zones.firstOrNull()?.zoneId)
}
val selected = overview.zones.firstOrNull { it.zoneId == selectedId }
TrainlogFrame("Zones à travailler", active = overview.recommendations.isNotEmpty()) {
TrainlogFrame(strings.getString(R.string.zones_to_work), active = overview.recommendations.isNotEmpty()) {
TextButton(onClick = { showExposureInfo = true }) {
Text("Exposition récente ⓘ")
Text(strings.getString(R.string.recent_exposure) + " ⓘ")
}
if (!overview.hasTrainingHistory) {
TrainlogInfo("Pas encore d’historique d’entraînement.")
TrainlogInfo(strings.getString(R.string.no_training_history))
}
if (overview.hasInvalidHistoryTimestamp) {
TrainlogInfo("Données insuffisantes : un horodatage historique ne peut pas être interprété.", colors.warning)
TrainlogInfo(strings.getString(R.string.invalid_history_timestamp), colors.warning)
}
BodyZoneMap(overview.zones, selectedId) { selectedId = it }
selected?.let { zone ->
BasicText(
"Sélection : ${zone.displayName}",
strings.getString(R.string.selection_value, localizedBodyZoneName(strings, zone.zoneId, zone.displayName)),
modifier = Modifier.padding(bottom = 8.dp),
style = TrainlogTypography.normal.copy(color = colors.lavender),
)
}
TrainlogInfo("Priorités", colors.accent)
TrainlogInfo(strings.getString(R.string.priorities), colors.accent)
overview.recommendations.take(3).forEachIndexed { index, zone ->
TrainlogAction(
"${index + 1}. ${zone.displayName} · ${homeStateLabel(zone)}",
recommendationReason(zone),
"${index + 1}. ${localizedBodyZoneName(strings, zone.zoneId, zone.displayName)} · ${homeStateLabel(zone, strings)}",
recommendationReason(zone, strings),
{ selectedId = zone.zoneId },
)
}
if (overview.recommendations.isEmpty()) {
TrainlogInfo("Aucune zone disposant d’un exercice résolu n’est disponible.")
TrainlogInfo(strings.getString(R.string.resolved_zone_none))
}
selected?.let { zone ->
Column(
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
BasicText(zone.displayName, style = TrainlogTypography.section.copy(color = colors.text))
BasicText(homeStateLabel(zone), style = TrainlogTypography.small.copy(color = stateColor(zone.state, colors)))
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
BasicText(homeStateLabel(zone, strings), style = TrainlogTypography.small.copy(color = stateColor(zone.state, colors)))
BasicText(
"Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}",
strings.getString(R.string.latest_primary_work_label, ageLabel(zone.primaryExposureAgeSeconds, strings)),
style = TrainlogTypography.small.copy(color = colors.text),
)
BasicText(
recentWorkLabel(zone.primaryWork7Days, "principal") + " · " +
recentWorkLabel(zone.secondaryWork7Days, "secondaire"),
recentWorkLabel(zone.primaryWork7Days, true, strings) + " · " +
recentWorkLabel(zone.secondaryWork7Days, false, strings),
style = TrainlogTypography.small.copy(color = colors.text),
)
BasicText(
availableExerciseLabel(zone.availableExerciseCount),
availableExerciseLabel(zone.availableExerciseCount, strings),
style = TrainlogTypography.small.copy(color = colors.text),
)
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
TrainlogAction("Voir les exercices", "", { onOpenExercises(zone.zoneId) })
TrainlogAction("Voir les statistiques", "", onOpenStatistics)
TrainlogAction(strings.getString(R.string.view_exercises), "", { onOpenExercises(zone.zoneId) })
TrainlogAction(strings.getString(R.string.view_statistics), "", onOpenStatistics)
}
}
}
if (showExposureInfo) {
AlertDialog(
onDismissRequest = { showExposureInfo = false },
title = { Text("Exposition récente") },
title = { Text(strings.getString(R.string.recent_exposure)) },
text = {
Text("Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.")
Text(strings.getString(R.string.exposure_explanation))
},
confirmButton = {
TextButton(onClick = { showExposureInfo = false }) { Text("Compris") }
TextButton(onClick = { showExposureInfo = false }) { Text(strings.getString(R.string.understood)) }
},
)
}
@ -213,9 +218,11 @@ private fun BodyMapPanel(
modifier: Modifier,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val visibleZones = view.zoneIds.mapNotNull(zones::get)
Column(modifier.semantics { contentDescription = "Carte corporelle, ${view.label}" }) {
BasicText(view.label, style = TrainlogTypography.small.copy(color = colors.muted))
val viewLabel = strings.getString(view.labelId)
Column(modifier.semantics { contentDescription = strings.getString(R.string.body_map, viewLabel) }) {
BasicText(viewLabel, style = TrainlogTypography.small.copy(color = colors.muted))
BoxWithConstraints(Modifier.fillMaxWidth().height(236.dp)) {
val panelWidth = maxWidth
val panelHeight = maxHeight
@ -281,14 +288,14 @@ private fun BodyMapPanel(
role = Role.Button
selected = chosen
contentDescription = buildString {
append(view.label)
append(viewLabel)
append(", ")
append(zone.displayName)
append(localizedBodyZoneName(strings, zone.zoneId, zone.displayName))
append(", ")
append(homeStateLabel(zone))
append(if (chosen) ", sélectionnée" else ", non sélectionnée")
append(homeStateLabel(zone, strings))
append(strings.getString(if (chosen) R.string.selected_suffix else R.string.not_selected_suffix))
}
onClick(label = "Sélectionner ${zone.displayName}") {
onClick(label = strings.getString(R.string.select_zone, localizedBodyZoneName(strings, zone.zoneId, zone.displayName))) {
select(zone.zoneId)
true
}
@ -300,13 +307,13 @@ private fun BodyMapPanel(
}
private enum class BodyView(
val label: String,
@param:androidx.annotation.StringRes val labelId: Int,
val testName: String,
val zoneIds: List<String>,
val accessibilityBounds: Map<String, Rect>,
) {
FRONT(
label = "Face",
labelId = R.string.body_front,
testName = "front",
zoneIds = listOf("shoulders", "chest", "arms", "core", "thighs", "calves"),
accessibilityBounds = mapOf(
@ -319,7 +326,7 @@ private enum class BodyView(
),
),
BACK(
label = "Dos",
labelId = R.string.body_back,
testName = "back",
zoneIds = listOf("shoulders", "back", "arms", "glutes", "thighs", "calves"),
accessibilityBounds = mapOf(
@ -491,28 +498,31 @@ private fun stateColor(state: BodyZoneHomeState, colors: TrainlogColors): Color
}
}
internal fun recommendationReason(zone: BodyZoneHomeStatus): String = when {
zone.lastPrimaryExposure == null -> "Aucune exposition principale enregistrée"
zone.primaryWork7Days == 0 -> "Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}"
else -> recentWorkLabel(zone.primaryWork7Days, "principal")
internal fun recommendationReason(zone: BodyZoneHomeStatus, context: android.content.Context): String = when {
zone.lastPrimaryExposure == null -> context.getString(R.string.no_primary_exposure)
zone.primaryWork7Days == 0 -> context.getString(R.string.latest_primary_work, ageLabel(zone.primaryExposureAgeSeconds, context))
else -> recentWorkLabel(zone.primaryWork7Days, true, context)
}
internal fun homeStateLabel(zone: BodyZoneHomeStatus): String =
internal fun homeStateLabel(zone: BodyZoneHomeStatus, context: android.content.Context): String =
if (zone.state == BodyZoneHomeState.PRIORITIZE && zone.primaryWork7Days > 0) {
"Priorité relative"
context.getString(R.string.relative_priority)
} else {
zone.state.label
context.getString(when (zone.state) { BodyZoneHomeState.PRIORITIZE -> R.string.state_prioritize
BodyZoneHomeState.LITTLE_RECENT_WORK -> R.string.state_little_work; BodyZoneHomeState.RECENT_WORK -> R.string.state_recent_work
BodyZoneHomeState.HIGH_RECENT_EXPOSURE -> R.string.state_high_exposure; BodyZoneHomeState.INSUFFICIENT_DATA -> R.string.state_insufficient
BodyZoneHomeState.UNSUPPORTED -> R.string.state_unsupported })
}
internal fun recentWorkLabel(count: Int, kind: String): String =
"Travail $kind sur 7 j : $count"
internal fun recentWorkLabel(count: Int, primary: Boolean, context: android.content.Context): String =
context.getString(if (primary) R.string.recent_primary_work else R.string.recent_secondary_work, count)
private fun availableExerciseLabel(count: Int): String =
"$count ${if (count == 1) "exercice disponible" else "exercices disponibles"}"
private fun availableExerciseLabel(count: Int, context: android.content.Context): String =
context.resources.getQuantityString(R.plurals.available_exercise_count, count, count)
internal fun ageLabel(seconds: Long?): String = when {
seconds == null -> "jamais enregistrée"
seconds < 86400L -> "aujourd’hui"
seconds < 2L * 86400L -> "il y a 1 jour"
else -> "il y a ${seconds / 86400L} jours"
internal fun ageLabel(seconds: Long?, context: android.content.Context): String = when {
seconds == null -> context.getString(R.string.age_never)
seconds < 86400L -> context.getString(R.string.age_today)
seconds < 2L * 86400L -> context.getString(R.string.age_yesterday)
else -> context.resources.getQuantityString(R.plurals.age_days, (seconds / 86400L).toInt(), seconds / 86400L)
}

View file

@ -0,0 +1,133 @@
package com.labfytools.trainlog.ui
import android.content.Context
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.labfytools.trainlog.R
import java.util.Locale
import java.text.NumberFormat
enum class AppLanguage(val tag: String) {
FRENCH("fr"),
ENGLISH("en");
companion object {
fun fromStoredTag(tag: String?): AppLanguage = entries.firstOrNull { it.tag == tag } ?: FRENCH
}
}
/**
* WHY: language is device-local presentation state and must never enter the
* canonical workout database or an exchange artifact.
* CONTRACT: this owner persists exactly one BCP-47 tag in its own preferences
* file; French is used when the preference is absent or invalid.
* INVARIANT: changing [language] updates Compose synchronously and does not
* call TrainlogRepository, SQLite, import, export, or synchronization code.
*/
@Stable
class LanguageSettingsOwner(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
var language: AppLanguage by mutableStateOf(AppLanguage.fromStoredTag(preferences.getString(KEY_LANGUAGE, null)))
private set
fun select(value: AppLanguage) {
if (language == value) return
preferences.edit().putString(KEY_LANGUAGE, value.tag).apply()
language = value
}
companion object {
internal const val PREFERENCES_NAME = "trainlog_presentation_settings"
internal const val KEY_LANGUAGE = "language_tag"
}
}
@Immutable
data class LanguagePresentation(
val language: AppLanguage,
val select: (AppLanguage) -> Unit,
)
val LocalLanguagePresentation = staticCompositionLocalOf<LanguagePresentation> {
/* Preview and focused component tests have no Activity owner. Their
* presentation contract is the same deterministic French default. */
LanguagePresentation(AppLanguage.FRENCH) {}
}
@Composable
fun localizedContext(): Context {
val base = LocalContext.current
val language = LocalLanguagePresentation.current.language
val configuration = Configuration(base.resources.configuration).apply {
setLocale(Locale.forLanguageTag(language.tag))
}
return base.createConfigurationContext(configuration)
}
@Composable
fun presentationLocale(): Locale = Locale.forLanguageTag(LocalLanguagePresentation.current.language.tag)
@Composable
fun uiString(@StringRes id: Int, vararg arguments: Any): String =
localizedContext().resources.getString(id, *arguments)
@Composable
fun uiQuantity(@PluralsRes id: Int, quantity: Int, vararg arguments: Any): String =
localizedContext().resources.getQuantityString(id, quantity, *arguments)
@Composable
fun presentationNumber(value: Double, maximumFractionDigits: Int = 2): String =
NumberFormat.getNumberInstance(presentationLocale()).apply {
isGroupingUsed = false
minimumFractionDigits = 0
this.maximumFractionDigits = maximumFractionDigits
}.format(value)
/**
* CONTRACT: body-zone IDs are canonical catalog data; only their visible labels are localized.
* Unknown future IDs deliberately retain the catalog label instead of changing identity data.
*/
fun localizedBodyZoneName(context: Context, zoneId: String, catalogLabel: String): String {
val id = when (zoneId) {
"full_body" -> R.string.zone_full_body
"upper_body" -> R.string.zone_upper_body
"chest" -> R.string.zone_chest
"back" -> R.string.zone_back
"shoulders" -> R.string.zone_shoulders
"arms" -> R.string.zone_arms
"core" -> R.string.zone_core
"lower_body" -> R.string.zone_lower_body
"glutes" -> R.string.zone_glutes
"thighs" -> R.string.zone_thighs
"calves" -> R.string.zone_calves
else -> return catalogLabel
}
return context.getString(id)
}
/**
* WHY: repository diagnostics are persistence/domain values, not translated payload fields.
* CONTRACT: classify them at the Android presentation boundary without rewriting repository state.
*/
fun localizedRepositoryMessage(context: Context, raw: String): String {
val normalized = raw.lowercase(Locale.ROOT)
val id = when {
"seule la sélection" in normalized -> R.string.draft_selection_missing
listOf("introuvable", "not found", "missing").any(normalized::contains) -> R.string.repository_item_missing
listOf("base locale", "base de données", "database", "sqlite").any(normalized::contains) -> R.string.repository_database_error
listOf("invalide", "invalid", "incompatible").any(normalized::contains) -> R.string.repository_invalid_data
listOf("accès fichiers", "autorisation", "permission", "file access").any(normalized::contains) -> R.string.repository_file_access
else -> R.string.repository_operation_error
}
return context.getString(id)
}

View file

@ -34,6 +34,7 @@ import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
@Composable
fun SessionsHub(
@ -45,22 +46,24 @@ fun SessionsHub(
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)
val strings = localizedContext()
TrainlogScreen(strings.getString(R.string.nav_sessions)) {
TrainlogFrame(strings.getString(R.string.route_session_editor), active = draft != null) {
if (draft == null) TrainlogInfo(strings.getString(R.string.current_session_none))
else TrainlogAction(strings.getString(R.string.resume), strings.getString(R.string.durable_draft_count,
strings.resources.getQuantityString(R.plurals.exercise_count, draft.exercises.size, draft.exercises.size)), onResume, accent = colors.success)
}
TrainlogFrame("Préparer") {
TrainlogFrame(strings.getString(R.string.prepare)) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("sessions-prepare-row"),
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TrainlogActionTile("Nouvelle séance manuelle", "Créer ou reprendre", TrainlogIcons.Edit,
TrainlogActionTile(strings.getString(R.string.new_manual_session), strings.getString(R.string.create_or_resume), TrainlogIcons.Edit,
onManual, Modifier.weight(1f).fillMaxHeight().testTag("sessions-manual-action"))
TrainlogActionTile("Brouillons", "$pendingAiDraftCount séance(s) préparée(s)",
TrainlogActionTile(strings.getString(R.string.route_ai_drafts), strings.resources.getQuantityString(R.plurals.prepared_session_count, pendingAiDraftCount, pendingAiDraftCount),
TrainlogIcons.Drafts, onDrafts,
Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action"))
}
}
TrainlogButton("Séances effectuées", onHistory,
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
Modifier.fillMaxWidth().testTag("sessions-history-action"))
}
}
@ -73,48 +76,49 @@ fun AiSessionDraftsScreen(
onStarted: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
var revision by remember { mutableStateOf(0) }
var message by remember { mutableStateOf<String?>(null) }
var pendingDeletion by remember { mutableStateOf<Pair<String, String>?>(null) }
val drafts = remember(externalRevision, revision) { repository.listAiSessionDrafts() }
TrainlogScreen("Brouillons") {
TrainlogScreen(strings.getString(R.string.route_ai_drafts)) {
message?.let { TrainlogInfo(it) }
if (drafts.isEmpty()) TrainlogInfo("Aucun brouillon importé.")
if (drafts.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
drafts.forEach { draft ->
TrainlogFrame(draft.title ?: "Proposition de séance", active = true) {
TrainlogFrame(draft.title ?: strings.getString(R.string.session_proposal), active = true) {
TrainlogInfo(listOfNotNull(
draft.plannedFor?.let { "Prévue le $it" },
"${draft.entries.size} exercice(s)",
draft.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, draft.entries.size, draft.entries.size),
).joinToString(" · "))
draft.notes?.let { TrainlogInfo(it) }
draft.entries.forEach { entry ->
val plan = checkNotNull(entry.plan)
val metric = plan.reps?.let { "$it répétitions" }
?: "${plan.durationSeconds} secondes"
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
TrainlogInfo("${entry.exercise.name} · ${plan.sets} × $metric$weight · repos ${plan.restSeconds} s")
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
}
TrainlogPrimaryAction("Démarrer", "Copier cette proposition dans la séance en cours.") {
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_proposal_description)) {
when (val result = repository.startAiSessionDraft(draft.draftId)) {
StartAiSessionDraftResult.Started -> {
onPendingChanged()
onStarted()
}
StartAiSessionDraftResult.ExistingActiveDraft ->
message = "Une séance est déjà en cours. Le brouillon importé a été conservé."
message = strings.getString(R.string.draft_already_active)
StartAiSessionDraftResult.NotPending -> {
message = "Ce brouillon n'est plus disponible."
message = strings.getString(R.string.draft_missing)
revision++
onPendingChanged()
}
is StartAiSessionDraftResult.Error -> message = result.message
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
}
TrainlogDeleteButton(
contentDescription = "Supprimer ce brouillon",
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
onClick = {
pendingDeletion = draft.draftId to (draft.title ?: "Proposition de séance")
pendingDeletion = draft.draftId to (draft.title ?: strings.getString(R.string.session_proposal))
},
)
}
@ -122,12 +126,12 @@ fun AiSessionDraftsScreen(
}
pendingDeletion?.let { (draftId, title) ->
DestructiveConfirmationDialog(
title = "Supprimer ce brouillon ?",
detail = "Le brouillon « $title » sera supprimé.\nCette action est irréversible.",
confirmLabel = "Supprimer",
title = strings.getString(R.string.delete_draft_question),
detail = strings.getString(R.string.delete_draft_detail, title),
confirmLabel = strings.getString(R.string.delete),
onCancel = { pendingDeletion = null },
dismissOnClickOutside = true,
deleteContentDescription = "Supprimer ce brouillon",
deleteContentDescription = localizedContext().getString(R.string.a11y_delete_draft),
) {
/* CONTRACT: dialog confirmation is the sole UI path to the existing
* tombstone mutation. Dismissal never reaches the repository. */
@ -137,7 +141,7 @@ fun AiSessionDraftsScreen(
revision++
onPendingChanged()
}
is ActiveDraftMutationResult.Error -> message = result.message
is ActiveDraftMutationResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
}
}
@ -147,13 +151,16 @@ fun AiSessionDraftsScreen(
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é.")
val locale = presentationLocale()
val strings = localizedContext()
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
TrainlogFrame(strings.getString(R.string.max_capacities), active = maxima.isNotEmpty()) {
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
maxima.forEach { max ->
val weight = "%.2f".format(java.util.Locale.FRANCE, max.maxWeightKg).trimEnd('0').trimEnd(',')
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
TrainlogInfo("${formatStartedAt(max.startedAt).take(10)} · Équipement : ${max.equipmentDisplayName ?: "aucun"}", colors.muted)
TrainlogInfo("${formatDate(max.startedAt)} · " + strings.getString(R.string.equipment_value,
max.equipmentDisplayName ?: strings.getString(R.string.value_none)), colors.muted)
}
}
}
@ -178,14 +185,15 @@ fun EquipmentScreen(
onCreate: () -> Unit,
onOpenDetail: (String) -> Unit,
) {
val strings = localizedContext()
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é.")
TrainlogScreen(strings.getString(R.string.equipment_catalog), scrollKey = "equipment-catalogue") {
TrainlogPrimaryAction(strings.getString(R.string.create_equipment), strings.getString(R.string.create_personal_machine), onCreate)
TrainlogInputField(strings.getString(R.string.search_equipment), state.query, { state.query = it })
TrainlogFrame(strings.getString(R.string.catalog), active = equipment.isNotEmpty()) {
if (equipment.isEmpty()) TrainlogInfo(strings.getString(R.string.equipment_none))
equipment.forEach { entry ->
TrainlogAction(entry.displayName, listOf(entry.labelName, equipmentSemanticsLabel(entry.loadSemantics)).filter { it.isNotBlank() }.joinToString(" · "), {
TrainlogAction(entry.displayName, listOf(entry.labelName, equipmentSemanticsLabel(entry.loadSemantics, strings)).filter { it.isNotBlank() }.joinToString(" · "), {
state.selectedEquipmentId = entry.equipmentId
onOpenDetail(entry.equipmentId)
})
@ -197,31 +205,32 @@ fun EquipmentScreen(
@Composable
fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, onOpenExercise: (String) -> Unit) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val entry = remember(equipmentId) { repository.listEquipment().firstOrNull { it.equipmentId == equipmentId } }
val options = remember(equipmentId) { repository.listEquipmentExerciseOptions(equipmentId) }
TrainlogScreen("Fiche équipement", scrollKey = "equipment-detail:$equipmentId") {
TrainlogScreen(strings.getString(R.string.route_equipment_detail), scrollKey = "equipment-detail:$equipmentId") {
if (entry == null) {
TrainlogInfo("Référence inconnue · $equipmentId", colors.warning)
TrainlogInfo(strings.getString(R.string.unknown_reference, equipmentId), colors.warning)
return@TrainlogScreen
}
TrainlogFrame("Définition") {
TrainlogFrame(strings.getString(R.string.definition)) {
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)
TrainlogInfo(strings.getString(if (entry.type == "custom_machine") R.string.personal else R.string.provided))
if (entry.labelName.isNotBlank()) TrainlogInfo(strings.getString(R.string.label_value, entry.labelName))
TrainlogInfo(strings.getString(R.string.type_value, entry.type))
TrainlogInfo(strings.getString(R.string.load_value, equipmentSemanticsLabel(entry.loadSemantics, strings)))
if (entry.aliases.isNotEmpty()) TrainlogInfo(strings.getString(R.string.aliases_value, entry.aliases.joinToString()), colors.muted)
}
TrainlogFrame("Exercices possibles", active = options.isNotEmpty()) {
if (options.isEmpty()) TrainlogInfo("Aucune association vérifiée. L’anatomie n’est pas déduite du nom de la machine.", colors.muted)
TrainlogFrame(strings.getString(R.string.possible_exercises_plain), active = options.isNotEmpty()) {
if (options.isEmpty()) TrainlogInfo(strings.getString(R.string.association_none), colors.muted)
options.forEach { (relation, exercise) ->
val confidence = when (relation.confidence) {
KnowledgeConfidence.HIGH -> "élevée"
KnowledgeConfidence.MODERATE -> "modérée"
KnowledgeConfidence.UNCERTAIN -> "incertaine"
KnowledgeConfidence.HIGH -> strings.getString(R.string.confidence_high)
KnowledgeConfidence.MODERATE -> strings.getString(R.string.confidence_moderate)
KnowledgeConfidence.UNCERTAIN -> strings.getString(R.string.confidence_uncertain)
}
TrainlogAction(exercise.exerciseName,
listOfNotNull(relation.configurationLabel, "Confiance $confidence", "Sources vérifiées : ${relation.sourceRefs.size}").joinToString(" · "),
listOfNotNull(relation.configurationLabel, strings.getString(R.string.confidence_value, confidence), strings.getString(R.string.verified_sources, relation.sourceRefs.size)).joinToString(" · "),
{ onOpenExercise(exercise.exerciseId) })
}
}
@ -231,18 +240,19 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
@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.") {
val strings = localizedContext()
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
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}."
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
is CreateEquipmentResult.DatabaseError -> state.message = strings.getString(R.string.creation_failed, result.message)
}
}
state.message?.let { TrainlogInfo(it, colors.error) }
@ -250,45 +260,73 @@ fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreen
}
}
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"
}
private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics, context: android.content.Context) = context.getString(when (value) {
EquipmentLoadSemantics.NONE -> R.string.load_none
EquipmentLoadSemantics.EXTERNAL -> R.string.load_external
EquipmentLoadSemantics.ASSISTANCE -> R.string.load_assistance
EquipmentLoadSemantics.BODYWEIGHT -> R.string.load_bodyweight
EquipmentLoadSemantics.CARDIO -> R.string.load_cardio
})
private fun catalogCountsText(context: android.content.Context, imported: Int, reconciled: Int): String =
listOf(
context.resources.getQuantityString(R.plurals.catalog_new, imported, imported),
context.resources.getQuantityString(R.plurals.catalog_reconciled, reconciled, reconciled),
).joinToString(", ")
@Composable
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
val colors = LocalTrainlogColors.current
val context = LocalContext.current
val language = LocalLanguagePresentation.current
val strings = localizedContext()
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
var message by remember { mutableStateOf<String?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
authorized = inbox.hasFolderAccess()
message = if (authorized) "Accès fichiers autorisé." else "Accès fichiers toujours requis."
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
if (authorized) {
when (val result = inbox.importPcCatalog()) {
is CatalogInboxResult.Imported -> {
message = "Dossier autorisé · ${result.imported} exercice(s) importé(s), ${result.reconciled} réconcilié(s)."
message = strings.getString(R.string.settings_folder_imported,
catalogCountsText(strings, result.imported, result.reconciled))
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
CatalogInboxResult.FileNotFound -> message = strings.getString(R.string.settings_no_catalog)
CatalogInboxResult.FolderNotAuthorized -> message = strings.getString(R.string.settings_folder_revoked)
is CatalogInboxResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
}
}
TrainlogScreen("Paramètres") {
TrainlogFrame("DOSSIER D'ÉCHANGE") {
TrainlogInfo(if (authorized) "Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé" else "Accès fichiers requis", if (authorized) colors.success else colors.warning)
TrainlogAction(if (authorized) "Ouvrir les réglages d'accès" else "Autoriser", "Autoriser l'accès au dossier Trainlog dans Android.", { launcher.launch(directStoragePermissionIntent(context)) })
if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", {
TrainlogScreen(strings.getString(R.string.settings_screen)) {
TrainlogFrame(strings.getString(R.string.settings_language_section)) {
TrainlogInfo("${strings.getString(R.string.settings_language_label)} : " +
strings.getString(if (language.language == AppLanguage.FRENCH) R.string.language_french else R.string.language_english))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TrainlogButton(
strings.getString(R.string.language_french),
{ language.select(AppLanguage.FRENCH) },
Modifier.weight(1f),
enabled = language.language != AppLanguage.FRENCH,
)
TrainlogButton(
strings.getString(R.string.language_english),
{ language.select(AppLanguage.ENGLISH) },
Modifier.weight(1f),
enabled = language.language != AppLanguage.ENGLISH,
)
}
}
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })
if (authorized) TrainlogAction(strings.getString(R.string.settings_reload_catalog), strings.getString(R.string.settings_reload_description), {
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
is CatalogInboxResult.Imported -> { message = strings.getString(R.string.settings_catalog_reloaded,
catalogCountsText(strings, result.imported, result.reconciled)); onCatalogChanged() }
CatalogInboxResult.FileNotFound -> message = strings.getString(R.string.settings_no_catalog)
CatalogInboxResult.FolderNotAuthorized -> { authorized = false; message = strings.getString(R.string.settings_folder_revoked) }
is CatalogInboxResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
})
message?.let { TrainlogInfo(it, if (authorized) colors.success else colors.error) }

View file

@ -25,6 +25,7 @@ import com.labfytools.trainlog.model.SessionExerciseDetail
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
@Composable
fun SessionDetailScreen(
@ -36,6 +37,8 @@ fun SessionDetailScreen(
) {
val colors =
LocalTrainlogColors.current
val locale = presentationLocale()
val strings = localizedContext()
var revision by remember(sessionId) { mutableStateOf(0) }
var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) }
@ -61,15 +64,15 @@ fun SessionDetailScreen(
}
TrainlogScreen(
subtitle = "Détail de la séance"
subtitle = strings.getString(R.string.route_session_detail)
) {
if (detail == null) {
TrainlogFrame(
title = "Erreur"
title = strings.getString(R.string.error_title)
) {
TrainlogInfo(
text =
"Séance introuvable.",
strings.getString(R.string.session_not_found),
color =
colors.error,
)
@ -79,7 +82,7 @@ fun SessionDetailScreen(
}
TrainlogFrame(
title = "Séance"
title = strings.getString(R.string.home_session)
) {
TrainlogInfo(
formatStartedAt(
@ -89,11 +92,8 @@ fun SessionDetailScreen(
TrainlogInfo(
text =
"Type : " +
sessionTypeLabel(
detail.summary
.sessionType
),
strings.getString(R.string.session_type_value,
sessionTypeLabel(detail.summary.sessionType)),
color =
if (
detail.summary
@ -107,21 +107,22 @@ fun SessionDetailScreen(
)
TrainlogInfo(
"${detail.summary.exerciseCount} exercice(s)"
strings.resources.getQuantityString(R.plurals.exercise_count,
detail.summary.exerciseCount, detail.summary.exerciseCount)
)
TrainlogButton("Modifier la séance", onCorrect,
TrainlogButton(strings.getString(R.string.route_session_correction), onCorrect,
Modifier.fillMaxWidth().testTag("edit-completed-session"))
if (detail.summary.sessionType == SessionType.MAX_TEST) {
TrainlogAction(
label = "Reprendre ce Test max",
description = "Continuer la même séance en conservant son identifiant et sa date.",
label = strings.getString(R.string.resume_max_test),
description = strings.getString(R.string.resume_max_description),
accent = colors.success,
onClick = {
when (val result = repository.resumeMaxTestSession(detail.summary.sessionId)) {
ActiveDraftMutationResult.Saved -> onResumeMaxTest()
is ActiveDraftMutationResult.Error -> resumeMessage = result.message
is ActiveDraftMutationResult.Error -> resumeMessage = localizedRepositoryMessage(strings, result.message)
}
},
)
@ -139,25 +140,25 @@ fun SessionDetailScreen(
"${index + 1}. ${exercise.exerciseName}"
) {
exercise.equipmentDisplayName?.let { equipment ->
TrainlogInfo("Équipement : $equipment", color = colors.muted)
TrainlogInfo(strings.getString(R.string.equipment_inline, equipment), color = colors.muted)
}
exercise.plan?.let { plan ->
TrainlogInfo(
"Plan : ${plan.sets} × ${plan.reps ?: plan.durationSeconds} · " +
"repos ${plan.restSeconds} s · " +
(plan.weightKg?.let { "charge cible $it kg" }
?: "sans charge numérique"),
strings.getString(R.string.plan_summary, plan.sets,
(plan.reps ?: plan.durationSeconds).toString(), plan.restSeconds,
plan.weightKg?.let { strings.getString(R.string.target_load_inline, it) }
?: strings.getString(R.string.no_numeric_load)),
color = colors.muted,
)
}
if (editingEntryId == exercise.entryId) {
TrainlogInputField(
label = "Rechercher une machine",
label = strings.getString(R.string.search_machine),
value = equipmentQuery,
onValueChange = { equipmentQuery = it },
)
TrainlogDeleteButton(
contentDescription = "Retirer la machine de cet exercice",
contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_remove_machine),
modifier = Modifier.testTag("delete-completed-equipment-${exercise.entryId}"),
onClick = {
pendingEquipmentChange = exercise.entryId to null
@ -179,17 +180,17 @@ fun SessionDetailScreen(
}
} else {
TrainlogAction(
label = "Modifier l'équipement",
description = "Choisir, remplacer ou retirer la machine de cette entrée.",
label = strings.getString(R.string.modify_equipment),
description = strings.getString(R.string.modify_equipment_description),
accent = colors.muted,
onClick = { editingEntryId = exercise.entryId },
)
}
if (exercise.maxWeightKg != null) {
val rendered = "%.2f".format(java.util.Locale.FRANCE, exercise.maxWeightKg)
val rendered = "%.2f".format(locale, exercise.maxWeightKg)
.trimEnd('0').trimEnd(',')
TrainlogInfo(
text = "Max : $rendered kg",
text = strings.getString(R.string.max_value, rendered),
color = colors.warning,
)
} else if (
@ -208,67 +209,70 @@ fun SessionDetailScreen(
)
}
exercise.feedback.forEach { item ->
TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}")
TrainlogAction("Modifier", "Corriger le texte sans déplacer l’observation.",
val elapsed = exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)
// CONTRACT: repository diagnostics remain stable data; only this visible sentinel is localized.
val localizedElapsed = if (elapsed == "Pendant la séance") strings.getString(R.string.during_session) else elapsed
TrainlogInfo("$localizedElapsed · ${item.rawText}${if (item.modified) strings.getString(R.string.modified_suffix) else ""}")
TrainlogAction(strings.getString(R.string.modify), strings.getString(R.string.feedback_move_description),
onClick = { editingFeedbackId = item.feedbackId }, accent = colors.muted)
if (editingFeedbackId == item.feedbackId) FeedbackEditor(
initialText = item.rawText, editing = true,
onSave = { text -> when (val result = repository.reviseExerciseFeedback(item.feedbackId, text)) {
is SaveFeedbackResult.Saved -> { editingFeedbackId = null; revision++; null }
is SaveFeedbackResult.Invalid -> result.message
is SaveFeedbackResult.DatabaseError -> result.message
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
} }, onCancel = { editingFeedbackId = null })
}
if (feedbackEntryId == exercise.entryId) {
FeedbackEditor(onSave = { text ->
when (val result = repository.saveExerciseFeedback(detail.summary.sessionId, exercise.entryId, text)) {
is SaveFeedbackResult.Saved -> { feedbackEntryId = null; revision++; null }
is SaveFeedbackResult.Invalid -> result.message
is SaveFeedbackResult.DatabaseError -> result.message
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
}
}, onCancel = { feedbackEntryId = null })
} else {
TrainlogButton("Ajouter un ressenti", { feedbackEntryId = exercise.entryId },
TrainlogButton(strings.getString(R.string.add_feedback), { feedbackEntryId = exercise.entryId },
Modifier.fillMaxWidth().testTag("add-exercise-feedback-${exercise.entryId}"),
icon = TrainlogIcons.Mic)
}
}
}
TrainlogFrame(title = "Suivi après séance") {
TrainlogFrame(title = strings.getString(R.string.session_follow_up)) {
detail.followUps.forEach { item ->
TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt), color = colors.accent)
TrainlogInfo(item.rawText + if (item.modified) " · Modifié" else "")
TrainlogAction("Modifier", "Corriger le texte sans changer le repère H+.",
TrainlogInfo(item.rawText + if (item.modified) strings.getString(R.string.modified_suffix) else "")
TrainlogAction(strings.getString(R.string.modify), strings.getString(R.string.feedback_marker_description),
onClick = { editingFollowUpId = item.followupId }, accent = colors.muted)
if (editingFollowUpId == item.followupId) FeedbackEditor(
initialText = item.rawText, editing = true,
onSave = { text -> when (val result = repository.reviseSessionFollowUp(item.followupId, text)) {
is SaveFeedbackResult.Saved -> { editingFollowUpId = null; revision++; null }
is SaveFeedbackResult.Invalid -> result.message
is SaveFeedbackResult.DatabaseError -> result.message
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
} }, onCancel = { editingFollowUpId = null })
}
if (addingFollowUp) {
FeedbackEditor(onSave = { text ->
when (val result = repository.saveSessionFollowUp(detail.summary.sessionId, text)) {
is SaveFeedbackResult.Saved -> { addingFollowUp = false; revision++; null }
is SaveFeedbackResult.Invalid -> result.message
is SaveFeedbackResult.DatabaseError -> result.message
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
}
}, onCancel = { addingFollowUp = false })
} else TrainlogButton("Ajouter un enregistrement", { addingFollowUp = true },
} else TrainlogButton(strings.getString(R.string.add_record), { addingFollowUp = true },
Modifier.fillMaxWidth().testTag("add-session-follow-up"), icon = TrainlogIcons.Mic,
containerColor = colors.success)
}
pendingEquipmentChange?.let { (entryId, equipmentId) ->
DestructiveConfirmationDialog(
title = if (equipmentId == null) "Retirer la machine de cette entrée ?" else "Remplacer la machine associée ?",
detail = "L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés.",
confirmLabel = if (equipmentId == null) "Retirer" else "Remplacer",
title = strings.getString(if (equipmentId == null) R.string.remove_machine_question else R.string.replace_machine_question),
detail = strings.getString(R.string.machine_change_detail),
confirmLabel = strings.getString(if (equipmentId == null) R.string.remove else R.string.replace),
onCancel = { pendingEquipmentChange = null },
deleteContentDescription = if (equipmentId == null) {
"Retirer la machine de cet exercice"
strings.getString(R.string.a11y_remove_machine)
} else null,
onConfirm = {
if (repository.setCompletedSessionEquipment(detail.summary.sessionId,entryId,equipmentId)) {
@ -287,6 +291,8 @@ private fun SetsDetail(
) {
val colors =
LocalTrainlogColors.current
val locale = presentationLocale()
val strings = localizedContext()
TrainlogInfo(
text =
@ -294,9 +300,9 @@ private fun SetsDetail(
exercise.trackingMode ==
TrackingMode.REPS
) {
"Mode : séries · répétitions"
strings.getString(R.string.mode_sets_reps)
} else {
"Mode : séries · durée"
strings.getString(R.string.mode_sets_duration)
},
color = colors.accent,
)
@ -304,13 +310,13 @@ private fun SetsDetail(
if (exercise.trackingMode == TrackingMode.REPS) {
val loadHeading =
if (loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
"Assistance (kg)"
strings.getString(R.string.assistance_kg)
} else {
"Charge (kg)"
strings.getString(R.string.field_weight)
}
/* Readable row table: history must expose every persisted value and
* distinguish an absent load from an explicit zero. */
TrainlogInfo("Série | Répétitions | $loadHeading", color = colors.muted)
TrainlogInfo(strings.getString(R.string.sets_table, loadHeading), color = colors.muted)
}
exercise.sets
@ -324,11 +330,11 @@ private fun SetsDetail(
TrackingMode.REPS
) {
val renderedWeight = set.weightKg?.let {
"%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',')
"%.2f".format(locale, it).trimEnd('0').trimEnd(',', '.')
} ?: "—"
"${index + 1} | ${set.reps} | $renderedWeight"
} else {
"Série ${index + 1} : ${formatDuration(set.durationSeconds)}"
strings.getString(R.string.set_duration, index + 1, formatDuration(set.durationSeconds))
}
)
}
@ -340,15 +346,16 @@ private fun ContinuousDetail(
) {
val colors =
LocalTrainlogColors.current
val strings = localizedContext()
TrainlogInfo(
text =
"Mode : continu",
strings.getString(R.string.mode_continuous),
color = colors.accent,
)
TrainlogInfo(
"Durée : ${formatDuration(exercise.continuousDurationSeconds)}"
strings.getString(R.string.duration_value, formatDuration(exercise.continuousDurationSeconds))
)
if (
@ -356,9 +363,7 @@ private fun ContinuousDetail(
ExerciseDataFields.SPEED_KMH != 0
) {
TrainlogInfo(
"Vitesse : %.1f km/h".format(
exercise.speedKmh ?: 0.0
)
strings.getString(R.string.speed_value, exercise.speedKmh ?: 0.0)
)
}
@ -367,13 +372,12 @@ private fun ContinuousDetail(
ExerciseDataFields.DISTANCE_KM != 0
) {
TrainlogInfo(
"Distance : %.2f km".format(
exercise.distanceKm ?: 0.0
)
strings.getString(R.string.distance_value, exercise.distanceKm ?: 0.0)
)
}
}
@Composable
private fun formatDuration(
seconds: Int,
): String {
@ -381,8 +385,8 @@ private fun formatDuration(
seconds > 0 &&
seconds % 60 == 0
) {
return "${seconds / 60} min"
return uiQuantity(R.plurals.duration_minutes_value, seconds / 60, seconds / 60)
}
return "$seconds s"
return uiQuantity(R.plurals.duration_seconds_value, seconds, seconds)
}

View file

@ -23,6 +23,7 @@ import java.time.OffsetDateTime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.labfytools.trainlog.R
internal object SessionGeneratorPreviewController {
fun remove(preview: SessionGenerationPreview, index: Int): SessionGenerationPreview {
@ -45,10 +46,7 @@ internal object SessionGeneratorPreviewController {
internal object SessionGeneratorFormController {
val goals = listOf(
"general" to "Général",
"strength" to "Force",
"hypertrophy" to "Hypertrophie",
"endurance" to "Endurance locale",
"general", "strength", "hypertrophy", "endurance",
)
fun duration(text: String, allowed: IntRange): Int? = text.toIntOrNull()?.takeIf { it in allowed }
@ -57,7 +55,7 @@ internal object SessionGeneratorFormController {
if (normalized.isEmpty()) return Result.success(null)
val value = normalized.toDoubleOrNull()
return if (value != null && value.isFinite() && value > 0.0) Result.success(value)
else Result.failure(IllegalArgumentException("La charge cible doit être un nombre positif fini."))
else Result.failure(IllegalArgumentException("generator_weight_invalid"))
}
}
@ -112,6 +110,7 @@ fun SessionGeneratorScreen(
onAccepted: () -> Unit,
onExistingDraft: () -> Unit,
) {
val strings = localizedContext()
val colors = LocalTrainlogColors.current
val scope = rememberCoroutineScope()
val options = remember { repository.sessionGenerationFormOptions() }
@ -146,8 +145,8 @@ fun SessionGeneratorScreen(
warningAcknowledged = result.preview.exposure.warningLevel == GenerationWarningLevel.NONE
message = null
}
is SessionGenerationResult.Invalid -> if (requestIdentity == state.requestIdentity) message = result.message
is SessionGenerationResult.DatabaseError -> if (requestIdentity == state.requestIdentity) message = result.message
is SessionGenerationResult.Invalid -> if (requestIdentity == state.requestIdentity) message = localizedRepositoryMessage(strings, result.message)
is SessionGenerationResult.DatabaseError -> if (requestIdentity == state.requestIdentity) message = localizedRepositoryMessage(strings, result.message)
}
} finally {
busy = false
@ -155,99 +154,100 @@ fun SessionGeneratorScreen(
}
}
TrainlogScreen(subtitle = "Programmer une séance") {
TrainlogScreen(subtitle = strings.getString(R.string.route_session_generator)) {
val current = preview
if (current == null) {
TrainlogFrame("Zone corporelle") {
TrainlogChoiceChips(zones.map { it.zoneId to it.displayName }, zoneId) { zoneId = it }
TrainlogFrame(strings.getString(R.string.generator_zone)) {
TrainlogChoiceChips(zones.map { it.zoneId to localizedBodyZoneName(strings, it.zoneId, it.displayName) }, zoneId) { zoneId = it }
}
TrainlogFrame("Objectif") {
TrainlogChoiceChips(SessionGeneratorFormController.goals.filter { it.first in options.goalIds }, goalId) { goalId = it }
TrainlogFrame(strings.getString(R.string.generator_goal)) {
TrainlogChoiceChips(SessionGeneratorFormController.goals.filter { it in options.goalIds }.map {
it to strings.getString(when (it) { "general" -> R.string.goal_general; "strength" -> R.string.goal_strength
"hypertrophy" -> R.string.goal_hypertrophy; else -> R.string.goal_endurance })
}, goalId) { goalId = it }
}
TrainlogFrame("Durée") {
TrainlogFrame(strings.getString(R.string.field_duration)) {
TrainlogChoiceChips(options.durationPresets.map { it.toString() to "$it min" }, durationText) { durationText = it }
TrainlogInputField(
"Durée personnalisée (${options.customMinutes.first} à ${options.customMinutes.last} min)",
strings.getString(R.string.generator_custom_duration, options.customMinutes.first, options.customMinutes.last),
durationText,
onValueChange = { durationText = it },
)
}
TrainlogAction("Générer", "Analyser l'historique en lecture seule et préparer la proposition.", accent = colors.success, onClick = {
TrainlogAction(strings.getString(R.string.generate), strings.getString(R.string.generate_description), accent = colors.success, onClick = {
val minutes = SessionGeneratorFormController.duration(durationText, options.customMinutes)
if (minutes == null) message = "La durée doit être comprise entre ${options.customMinutes.first} et ${options.customMinutes.last} minutes."
if (minutes == null) message = strings.getString(R.string.duration_range_error, options.customMinutes.first, options.customMinutes.last)
else generate(SessionGenerationRequest(zoneId, goalId, minutes, OffsetDateTime.now().toString()))
})
} else {
TrainlogFrame("Proposition") {
TrainlogInfo("Durée cible : ${current.request.durationMinutes} min · estimation : ${current.estimatedDurationSeconds / 60} min")
TrainlogFrame(strings.getString(R.string.proposal)) {
TrainlogInfo(strings.getString(R.string.target_duration_estimate, current.request.durationMinutes, current.estimatedDurationSeconds / 60))
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)
TrainlogInfo(strings.getString(R.string.proposal_short), colors.warning)
TrainlogInfo(strings.getString(R.string.warmup_not_generated), colors.muted)
if (current.insufficientResolvedCandidates) TrainlogInfo(
"La couverture est incomplète faute de contextes résolus disponibles" +
strings.getString(R.string.coverage_incomplete,
current.shortageCodes.takeIf { it.isNotEmpty() }
?.joinToString(prefix = " : ", postfix = ".") { generationShortageLabel(it) }
.orEmpty(),
?.joinToString(prefix = " : ", postfix = ".") { generationShortageLabel(it, strings) }.orEmpty()),
colors.warning,
)
val exposure = current.exposure
TrainlogInfo("Exposition observée : ${exposure.within24h.primarySetCount} séries principales et ${exposure.within24h.secondarySetCount} secondaires sur 24 h ; ${exposure.within72h.primarySetCount} principales et ${exposure.within72h.secondarySetCount} secondaires sur 72 h.")
exposure.latestStartedAt?.let { TrainlogInfo("Dernière exposition observée : $it", colors.muted) }
TrainlogInfo(strings.getString(R.string.exposure_summary, exposure.within24h.primarySetCount, exposure.within24h.secondarySetCount, exposure.within72h.primarySetCount, exposure.within72h.secondarySetCount))
exposure.latestStartedAt?.let { TrainlogInfo(strings.getString(R.string.latest_exposure, formatStartedAt(it)), colors.muted) }
if (exposure.warningLevel != GenerationWarningLevel.NONE) {
TrainlogInfo("Attention informative : l'historique montre une exposition récente. Cela ne mesure pas la récupération physiologique.", colors.warning)
if (!warningAcknowledged) TrainlogAction("Continuer malgré l'avertissement", "Conserver la proposition et autoriser son acceptation.", accent = colors.warning, onClick = {
TrainlogInfo(strings.getString(R.string.recent_exposure_warning), colors.warning)
if (!warningAcknowledged) TrainlogAction(strings.getString(R.string.continue_warning), strings.getString(R.string.continue_warning_description), accent = colors.warning, onClick = {
if (!busy) warningAcknowledged = true
})
}
}
current.exercises.forEachIndexed { index, item ->
TrainlogFrame("${index + 1}. ${item.exerciseName}") {
TrainlogInfo("Équipement : ${item.equipmentName}")
TrainlogInfo("Cible : ${item.plan.sets} × ${item.plan.reps} · repos ${item.plan.restSeconds} s")
TrainlogInfo(if (item.plan.weightKg == null) "Charge cible : aucune charge numérique proposée."
else "Charge cible : ${item.plan.weightKg} kg")
TrainlogInfo("Zone principale : ${item.primaryZoneName}")
TrainlogInfo("Mouvement : ${item.patternNames.joinToString()}")
TrainlogInfo(strings.getString(R.string.equipment_generator, item.equipmentName))
TrainlogInfo(strings.getString(R.string.target_summary, item.plan.sets, item.plan.reps.toString(), item.plan.restSeconds))
TrainlogInfo(if (item.plan.weightKg == null) strings.getString(R.string.target_load_none_proposed)
else strings.getString(R.string.target_load_proposed, item.plan.weightKg))
TrainlogInfo(strings.getString(R.string.primary_zone_value_short, item.primaryZoneName))
TrainlogInfo(strings.getString(R.string.movement_value, item.patternNames.joinToString()))
if (item.recency.recentSameExercise || item.recency.recentSamePattern)
TrainlogInfo("Historique récent similaire détecté ; information uniquement.", colors.warning)
TrainlogInfo(strings.getString(R.string.recent_similar), colors.warning)
item.loadSourceStartedAt?.let {
TrainlogInfo(
"Charge issue d'une dose réellement observée le $it ; " +
"son applicabilité aujourd'hui reste incertaine.",
strings.getString(R.string.observed_load_note, formatDate(it)),
colors.muted,
)
}
TrainlogInfo(
"Raisons : ${item.rationaleCodes.joinToString { generationReasonLabel(it) }}",
strings.getString(R.string.reasons_value, item.rationaleCodes.joinToString { generationReasonLabel(it, strings) }),
colors.muted,
)
if (editingIndex == index) {
TrainlogInputField("Séries", setsText, onValueChange = { setsText = it })
TrainlogInputField("Répétitions", repsText, onValueChange = { repsText = it })
TrainlogInputField("Repos (secondes)", restText, onValueChange = { restText = it })
TrainlogInfo("Charge : choix utilisateur, jamais une recommandation.", colors.muted)
TrainlogInputField(strings.getString(R.string.sets_label), setsText, onValueChange = { setsText = it })
TrainlogInputField(strings.getString(R.string.field_reps), repsText, onValueChange = { repsText = it })
TrainlogInputField(strings.getString(R.string.rest_seconds), restText, onValueChange = { restText = it })
TrainlogInfo(strings.getString(R.string.user_load_note), colors.muted)
TrainlogChoiceChips(
listOf("AUTOMATIC" to "Automatique", "PERCENT_MAX" to "% MAX", "NONE" to "Aucune"),
listOf("AUTOMATIC" to strings.getString(R.string.automatic), "PERCENT_MAX" to "% MAX", "NONE" to strings.getString(R.string.none)),
loadChoice.name,
) { selected ->
loadChoice = GeneratorLoadChoice.valueOf(selected)
if (loadChoice != GeneratorLoadChoice.AUTOMATIC) loadText = ""
}
if (loadChoice == GeneratorLoadChoice.PERCENT_MAX)
TrainlogInputField("Pourcentage du MAX (1 à 100)", maxPercentText,
TrainlogInputField(strings.getString(R.string.max_percentage_field), maxPercentText,
onValueChange = { maxPercentText = it })
if (loadChoice == GeneratorLoadChoice.AUTOMATIC)
TrainlogInputField(
"Charge cible manuelle (vide = automatique)",
strings.getString(R.string.manual_target_load),
loadText,
onValueChange = { loadText = it },
)
TrainlogAction("Appliquer", "Réestimer la durée et requalifier la charge observée.", accent = colors.success, onClick = {
TrainlogAction(strings.getString(R.string.apply), strings.getString(R.string.reestimate_description), accent = colors.success, onClick = {
val parsedWeight = if (loadChoice == GeneratorLoadChoice.AUTOMATIC)
SessionGeneratorFormController.manualWeight(loadText)
else Result.success(null)
if (parsedWeight.isFailure) {
message = parsedWeight.exceptionOrNull()?.message
message = strings.getString(R.string.generator_weight_invalid)
} else {
val weight = parsedWeight.getOrNull()
if (!busy) scope.launch {
@ -260,8 +260,8 @@ fun SessionGeneratorScreen(
maxPercentText.toIntOrNull())
}) {
is SessionGenerationResult.Generated -> { preview = result.preview; editingIndex = null; message = null }
is SessionGenerationResult.Invalid -> message = result.message
is SessionGenerationResult.DatabaseError -> message = result.message
is SessionGenerationResult.Invalid -> message = localizedRepositoryMessage(strings, result.message)
is SessionGenerationResult.DatabaseError -> message = localizedRepositoryMessage(strings, result.message)
}
} finally {
busy = false
@ -269,7 +269,7 @@ fun SessionGeneratorScreen(
}
}
})
} else TrainlogAction("Modifier la cible", "Modifier séries, répétitions, repos ou charge.", onClick = {
} else TrainlogAction(strings.getString(R.string.modify_target), strings.getString(R.string.modify_target_description), onClick = {
if (!busy) {
editingIndex = index; setsText = item.plan.sets.toString(); repsText = item.plan.reps.toString()
restText = item.plan.restSeconds.toString()
@ -286,68 +286,64 @@ fun SessionGeneratorScreen(
item.plan.weightKg?.toString().orEmpty() else ""
}
})
TrainlogAction("Monter", "Déplacer cet exercice avant le précédent.", onClick = {
TrainlogAction(strings.getString(R.string.move_up), strings.getString(R.string.move_up_description), onClick = {
if (!busy) preview = SessionGeneratorPreviewController.move(current, index, -1)
}, accent = colors.muted)
TrainlogAction("Descendre", "Déplacer cet exercice après le suivant.", onClick = {
TrainlogAction(strings.getString(R.string.move_down), strings.getString(R.string.move_down_description), onClick = {
if (!busy) preview = SessionGeneratorPreviewController.move(current, index, 1)
}, accent = colors.muted)
TrainlogDeleteButton("Retirer cet exercice de la proposition", onClick = {
TrainlogDeleteButton(strings.getString(R.string.remove_proposal_exercise), onClick = {
if (!busy) preview = SessionGeneratorPreviewController.remove(current, index)
})
}
}
TrainlogAction("Régénérer", "Relancer les mêmes entrées et le même instant de référence.", onClick = { generate(current.request) })
TrainlogAction("Accepter et saisir les valeurs réelles", "Créer le brouillon normal sans préremplir les séries réalisées.", accent = colors.success, onClick = {
if (!warningAcknowledged) message = "Confirmez d'abord l'avertissement d'exposition récente."
TrainlogAction(strings.getString(R.string.regenerate), strings.getString(R.string.regenerate_description), onClick = { generate(current.request) })
TrainlogAction(strings.getString(R.string.accept_actual_values), strings.getString(R.string.accept_actual_description), accent = colors.success, onClick = {
if (!warningAcknowledged) message = strings.getString(R.string.acknowledge_exposure_first)
else if (!busy) scope.launch {
busy = true
try {
when (val result = withContext(Dispatchers.IO) { repository.acceptGeneratedSession(current) }) {
AcceptGeneratedSessionResult.Accepted -> onAccepted()
AcceptGeneratedSessionResult.ExistingActiveDraft -> onExistingDraft()
is AcceptGeneratedSessionResult.Invalid -> message = result.message
is AcceptGeneratedSessionResult.DatabaseError -> message = result.message
is AcceptGeneratedSessionResult.Invalid -> message = localizedRepositoryMessage(strings, result.message)
is AcceptGeneratedSessionResult.DatabaseError -> message = localizedRepositoryMessage(strings, result.message)
}
} finally {
busy = false
}
}
})
TrainlogAction("Annuler la proposition", "Revenir sans écrire de brouillon.", onClick = {
TrainlogAction(strings.getString(R.string.cancel_proposal), strings.getString(R.string.cancel_proposal_description), onClick = {
if (!busy) onBack()
}, accent = colors.muted)
}
if (busy) TrainlogFrame("Traitement") { TrainlogInfo("Analyse en cours…", colors.muted) }
message?.let { TrainlogFrame("Message") { TrainlogInfo(it, colors.error) } }
if (busy) TrainlogFrame(strings.getString(R.string.processing)) { TrainlogInfo(strings.getString(R.string.analysis_running), colors.muted) }
message?.let { TrainlogFrame(strings.getString(R.string.message_title)) { TrainlogInfo(it, colors.error) } }
}
}
private fun generationReasonLabel(code: String): String = when (code) {
"observed_repeated_dose_anchor" -> "dose répétée observée"
"explicit_max_present_no_numeric_prescription" -> "maximum observé sans prescription numérique"
"user_selected_max_percentage" -> "pourcentage de MAX choisi par l'utilisateur"
"compatible_max_unavailable" -> "MAX compatible indisponible"
"assistance_numeric_load_omitted" -> "charge d'assistance omise"
"numeric_load_absent" -> "charge numérique absente"
"manual_target_load" -> "charge saisie manuellement"
"preferred_exercise" -> "exercice préféré"
"requested_primary_zone" -> "zone principale demandée"
"requested_secondary_zone" -> "zone secondaire demandée"
"new_exact_pattern" -> "mouvement distinct"
"recent_same_exercise_penalty" -> "même exercice observé récemment"
"recent_same_pattern_penalty" -> "mouvement similaire observé récemment"
else -> code.replace('_', ' ')
}
private fun generationReasonLabel(code: String, context: android.content.Context): String = context.getString(when (code) {
"observed_repeated_dose_anchor" -> R.string.reason_observed_dose
"explicit_max_present_no_numeric_prescription" -> R.string.reason_max_no_prescription
"user_selected_max_percentage" -> R.string.reason_user_max
"compatible_max_unavailable" -> R.string.reason_max_unavailable
"assistance_numeric_load_omitted" -> R.string.reason_assistance_omitted
"numeric_load_absent" -> R.string.reason_load_absent
"manual_target_load" -> R.string.reason_manual_load
"preferred_exercise" -> R.string.reason_preferred
"requested_primary_zone" -> R.string.reason_primary_zone
"requested_secondary_zone" -> R.string.reason_secondary_zone
"new_exact_pattern" -> R.string.reason_distinct_movement
"recent_same_exercise_penalty" -> R.string.reason_recent_exercise
"recent_same_pattern_penalty" -> R.string.reason_recent_pattern
else -> return code.replace('_', ' ')
})
private fun generationShortageLabel(code: String): String = when (code) {
"missing_upper_region" -> "région supérieure manquante"
"missing_lower_region" -> "région inférieure manquante"
"missing_core_region" -> "tronc manquant"
"missing_upper_push" -> "poussée du haut du corps manquante"
"missing_upper_pull" -> "tirage du haut du corps manquant"
"missing_lower_extension" -> "extension du bas du corps manquante"
"missing_lower_flexion" -> "flexion du bas du corps manquante"
"fewer_than_max_exercises" -> "moins d'exercices distincts disponibles"
else -> code.replace('_', ' ')
}
private fun generationShortageLabel(code: String, context: android.content.Context): String = context.getString(when (code) {
"missing_upper_region" -> R.string.shortage_upper; "missing_lower_region" -> R.string.shortage_lower
"missing_core_region" -> R.string.shortage_core; "missing_upper_push" -> R.string.shortage_push
"missing_upper_pull" -> R.string.shortage_pull; "missing_lower_extension" -> R.string.shortage_lower_extension
"missing_lower_flexion" -> R.string.shortage_lower_flexion; "fewer_than_max_exercises" -> R.string.shortage_exercises
else -> return code.replace('_', ' ')
})

View file

@ -40,6 +40,7 @@ import com.labfytools.trainlog.data.StatisticsSeries
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import com.labfytools.trainlog.R
import java.time.LocalDate
import java.time.temporal.WeekFields
import java.util.Locale
@ -47,6 +48,15 @@ import kotlin.math.max
private enum class StatisticsDetail { PROGRESS, BODY, FREQUENCY, DISTRIBUTION, SUMMARY }
@Composable
private fun statisticsPeriodLabel(period: StatisticsPeriod): String = uiString(when (period) {
StatisticsPeriod.DAYS_7 -> R.string.period_7_days
StatisticsPeriod.DAYS_30 -> R.string.period_30_days
StatisticsPeriod.DAYS_90 -> R.string.period_90_days
StatisticsPeriod.YEAR -> R.string.period_year
StatisticsPeriod.ALL -> R.string.period_all
})
internal data class DashboardWeek(val label: String, val sessions: Int, val maxima: Int)
internal fun sixWeekFrequency(
@ -70,38 +80,40 @@ internal fun sixWeekFrequency(
@Composable
fun StatisticsDashboard(repository: TrainlogRepository) {
val strings = localizedContext()
var period by remember { mutableStateOf(StatisticsPeriod.DAYS_30) }
var detail by remember { mutableStateOf<StatisticsDetail?>(null) }
val overview = remember(period) { repository.loadStatistics(period) }
if (detail != null) return StatisticsDetailScreen(detail!!, overview) { detail = null }
TrainlogScreen("Statistiques") {
TrainlogChoiceChips(StatisticsPeriod.entries.map { it.name to it.label }, period.name) {
TrainlogScreen(strings.getString(R.string.nav_statistics)) {
TrainlogChoiceChips(StatisticsPeriod.entries.map { it.name to statisticsPeriodLabel(it) }, period.name) {
period = StatisticsPeriod.valueOf(it)
}
if (overview.hasInvalidData) TrainlogInfo(
"Certaines données historiques invalides ont été ignorées; les statistiques valides restent disponibles.",
strings.getString(R.string.statistics_invalid_ignored),
)
SummaryGrid(overview)
PerformanceCard(overview) { detail = StatisticsDetail.PROGRESS }
MeasurementsCard(overview) { detail = StatisticsDetail.BODY }
FrequencyCard(overview) { detail = StatisticsDetail.FREQUENCY }
TrainlogFrame("EXPLORER") {
TrainlogAction("Répartition", "Zones et utilisation des exercices, uniquement quand elles sont classifiées.", { detail = StatisticsDetail.DISTRIBUTION })
TrainlogAction("Résumé global", "Indicateurs prudents dérivés des données disponibles.", { detail = StatisticsDetail.SUMMARY })
TrainlogFrame(strings.getString(R.string.explore)) {
TrainlogAction(strings.getString(R.string.distribution), strings.getString(R.string.distribution_description), { detail = StatisticsDetail.DISTRIBUTION })
TrainlogAction(strings.getString(R.string.global_summary), strings.getString(R.string.global_summary_description), { detail = StatisticsDetail.SUMMARY })
}
}
}
@Composable
private fun SummaryGrid(overview: StatisticsOverview) {
TrainlogFrame("RÉSUMÉ · ${overview.period.label}") {
val strings = localizedContext()
TrainlogFrame(strings.getString(R.string.summary_period, statisticsPeriodLabel(overview.period))) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Metric("Séances", overview.summary.sessions.toString(), Modifier.weight(1f))
Metric("Séries", overview.summary.performedSets.toString(), Modifier.weight(1f))
Metric(strings.getString(R.string.nav_sessions), overview.summary.sessions.toString(), Modifier.weight(1f))
Metric(strings.getString(R.string.sets), overview.summary.performedSets.toString(), Modifier.weight(1f))
}
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Metric("Exercices", overview.summary.distinctExercises.toString(), Modifier.weight(1f))
Metric("MAX explicites", overview.summary.explicitMaxima.toString(), Modifier.weight(1f))
Metric(strings.getString(R.string.nav_exercises), overview.summary.distinctExercises.toString(), Modifier.weight(1f))
Metric(strings.getString(R.string.explicit_max_plural), overview.summary.explicitMaxima.toString(), Modifier.weight(1f))
}
}
}
@ -120,17 +132,18 @@ internal fun PerformanceCard(overview: StatisticsOverview, click: () -> Unit) {
val colors = LocalTrainlogColors.current
val work = overview.performanceEvents.sumOf { it.workingImprovements }
val maxima = overview.performanceEvents.sumOf { it.maxImprovements }
TrainlogFrame("PROGRESSION", active = true) {
val strings = localizedContext()
TrainlogFrame(strings.getString(R.string.progression), active = true) {
Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 2.dp)) {
PerformanceTimeline(overview)
Text(
if (work + maxima == 0) "Données insuffisantes pour établir une progression comparable."
else "$work progression(s) travail · $maxima record(s) MAX",
if (work + maxima == 0) strings.getString(R.string.progress_insufficient)
else strings.getString(R.string.progress_counts, work, maxima),
style = TrainlogTypography.small.copy(color = if (work + maxima == 0) colors.muted else colors.text),
modifier = Modifier.padding(top = 6.dp),
)
Text(
"${overview.summary.explicitMaxima} MAX explicite(s) · ouvrir le détail",
strings.getString(R.string.max_open_detail, overview.summary.explicitMaxima),
style = TrainlogTypography.small.copy(color = colors.notice),
modifier = Modifier.padding(top = 4.dp),
)
@ -144,8 +157,8 @@ private fun PerformanceTimeline(overview: StatisticsOverview) {
val recent = overview.performanceEvents.takeLast(6).map { it.workingImprovements + it.maxImprovements }
val values = List(6 - recent.size) { 0 } + recent
val description = if (values.all { it == 0 })
"Progression sur six semaines : aucun événement comparable, six points vides"
else "Progression sur six semaines : ${values.joinToString()} événements"
uiString(R.string.progress_a11y_empty)
else uiString(R.string.progress_a11y_values, values.joinToString())
Canvas(Modifier.fillMaxWidth().height(54.dp).semantics { contentDescription = description }) {
val step = size.width / values.size
val peak = max(1, values.max())
@ -160,10 +173,11 @@ private fun PerformanceTimeline(overview: StatisticsOverview) {
@Composable
internal fun MeasurementsCard(overview: StatisticsOverview, click: () -> Unit) {
val colors = LocalTrainlogColors.current
TrainlogFrame("MENSURATIONS", active = overview.body.isNotEmpty()) {
val strings = localizedContext()
TrainlogFrame(strings.getString(R.string.route_body_measurements).uppercase(presentationLocale()), active = overview.body.isNotEmpty()) {
Column(Modifier.fillMaxWidth().clickable(onClick = click)) {
if (overview.body.isEmpty()) {
Text("Aucune mensuration dans cette période.", style = TrainlogTypography.normal.copy(color = colors.muted))
Text(strings.getString(R.string.body_period_none), style = TrainlogTypography.normal.copy(color = colors.muted))
} else overview.body.chunked(2).forEachIndexed { rowIndex, row ->
Row(
Modifier.fillMaxWidth().padding(top = if (rowIndex == 0) 0.dp else 8.dp),
@ -183,11 +197,11 @@ private fun MeasurementTile(series: StatisticsSeries, modifier: Modifier) {
val latest = series.points.last().value
val delta = series.points.takeIf { it.size >= 2 }?.let { latest - it[it.lastIndex - 1].value }
Column(modifier.background(colors.surface, RoundedCornerShape(10.dp)).padding(10.dp)) {
Text(series.label, style = TrainlogTypography.small.copy(color = colors.muted))
Text("$latest ${series.unit}", style = TrainlogTypography.section.copy(color = colors.text))
if (delta == null) Text("1 relevé · tendance indisponible", style = TrainlogTypography.small.copy(color = colors.muted))
Text(localizedStatisticsSeriesLabel(series), style = TrainlogTypography.small.copy(color = colors.muted))
Text("${presentationNumber(latest)} ${series.unit}", style = TrainlogTypography.section.copy(color = colors.text))
if (delta == null) Text(uiString(R.string.measurement_single), style = TrainlogTypography.small.copy(color = colors.muted))
else {
Text("Δ ${if (delta >= 0) "+" else ""}$delta", style = TrainlogTypography.small.copy(color = colors.info))
Text("Δ ${if (delta >= 0) "+" else ""}${presentationNumber(delta)}", style = TrainlogTypography.small.copy(color = colors.info))
LineChart(listOf(series), Modifier.height(42.dp))
}
}
@ -198,16 +212,18 @@ internal fun FrequencyCard(overview: StatisticsOverview, today: LocalDate = Loca
val colors = LocalTrainlogColors.current
val weeks = sixWeekFrequency(overview.frequency, today)
val current = weeks.last()
TrainlogFrame("FRÉQUENCE", active = overview.frequency.isNotEmpty()) {
val strings = localizedContext()
TrainlogFrame(strings.getString(R.string.frequency), active = overview.frequency.isNotEmpty()) {
Column(Modifier.fillMaxWidth().clickable(onClick = click)) {
FrequencyBars(weeks)
Text(
"Semaine actuelle · ${current.sessions} séance(s) · ${current.maxima} MAX inclus",
strings.getString(R.string.current_week_summary,
strings.resources.getQuantityString(R.plurals.session_count, current.sessions, current.sessions), current.maxima),
style = TrainlogTypography.small.copy(color = colors.accent, fontWeight = FontWeight.Bold),
modifier = Modifier.padding(top = 6.dp),
)
Text(
"Les MAX signalent des séances; ils ne sont jamais additionnés une seconde fois.",
strings.getString(R.string.max_not_double_counted),
style = TrainlogTypography.small.copy(color = colors.muted),
modifier = Modifier.padding(top = 2.dp),
)
@ -218,9 +234,12 @@ internal fun FrequencyCard(overview: StatisticsOverview, today: LocalDate = Loca
@Composable
private fun FrequencyBars(weeks: List<DashboardWeek>) {
val colors = LocalTrainlogColors.current
val description = "Fréquence sur six semaines : " + weeks.mapIndexed { index, week ->
"${if (index == weeks.lastIndex) "actuelle" else week.label} ${week.sessions} séances dont ${week.maxima} MAX"
}.joinToString("; ")
val strings = localizedContext()
val description = strings.getString(R.string.frequency_a11y, weeks.mapIndexed { index, week ->
strings.getString(R.string.frequency_week_a11y,
if (index == weeks.lastIndex) strings.getString(R.string.current_feminine) else week.label,
strings.resources.getQuantityString(R.plurals.session_count, week.sessions, week.sessions), week.maxima)
}.joinToString("; "))
Canvas(Modifier.fillMaxWidth().height(116.dp).semantics { contentDescription = description }) {
val peak = max(1, weeks.maxOf { it.sessions })
val slot = size.width / weeks.size
@ -247,10 +266,10 @@ private fun DashboardCard(title: String, subtitle: String, series: List<Statisti
TrainlogFrame(title, active = series.isNotEmpty()) {
Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 4.dp)) {
Text(subtitle, style = TrainlogTypography.small.copy(color = colors.muted))
if (series.isEmpty()) Text("Pas encore assez de données pour une courbe.", style = TrainlogTypography.normal.copy(color = colors.muted), modifier = Modifier.padding(top = 12.dp))
if (series.isEmpty()) Text(uiString(R.string.chart_insufficient), style = TrainlogTypography.normal.copy(color = colors.muted), modifier = Modifier.padding(top = 12.dp))
else {
LineChart(series.take(3))
Text("${series.size} série(s) comparable(s) · ouvrir le détail", style = TrainlogTypography.small.copy(color = colors.accent), modifier = Modifier.padding(top = 6.dp))
Text(uiString(R.string.chart_series_detail, series.size), style = TrainlogTypography.small.copy(color = colors.accent), modifier = Modifier.padding(top = 6.dp))
}
}
}
@ -281,17 +300,59 @@ private fun LineChart(series: List<StatisticsSeries>, modifier: Modifier = Modif
@Composable
private fun StatisticsDetailScreen(detail: StatisticsDetail, overview: StatisticsOverview, back: () -> Unit) {
val strings = localizedContext()
val (title, series, description) = when (detail) {
StatisticsDetail.PROGRESS -> Triple("Progression", overview.performance, "Chaque ligne de détail conserve un exercice canonique, un équipement et un mode comparables; le dashboard ne somme jamais des kg.")
StatisticsDetail.BODY -> Triple("Mensurations", overview.body, "Aucune interpolation : seuls les relevés réellement enregistrés sont affichés.")
StatisticsDetail.FREQUENCY -> Triple("Fréquence", listOf(StatisticsSeries("f", "Séances", "séances", overview.frequency.map { x -> StatisticsPoint(x.week, x.all.toDouble(), x.week) })), "Nombre de séances avec travail réel par semaine; MAX est un marqueur, pas une seconde séance.")
StatisticsDetail.DISTRIBUTION -> Triple("Répartition", emptyList(), "La répartition détaillée arrive lorsque les occurrences classifiées sont disponibles dans cette vue.")
StatisticsDetail.SUMMARY -> Triple("Résumé global", emptyList(), "${overview.summary.sessions} séances · ${overview.summary.performedSets} séries · ${overview.summary.distinctExercises} exercices · ${overview.summary.explicitMaxima} MAX explicites")
StatisticsDetail.PROGRESS -> Triple(strings.getString(R.string.progression), overview.performance, strings.getString(R.string.progress_detail_description))
StatisticsDetail.BODY -> Triple(strings.getString(R.string.route_body_measurements), overview.body, strings.getString(R.string.body_detail_description))
StatisticsDetail.FREQUENCY -> Triple(strings.getString(R.string.frequency_title), listOf(StatisticsSeries("f", strings.getString(R.string.nav_sessions), strings.getString(R.string.frequency_unit), overview.frequency.map { x -> StatisticsPoint(x.week, x.all.toDouble(), x.week) })), strings.getString(R.string.frequency_detail_description))
StatisticsDetail.DISTRIBUTION -> Triple(strings.getString(R.string.distribution), emptyList(), strings.getString(R.string.distribution_detail_description))
StatisticsDetail.SUMMARY -> Triple(strings.getString(R.string.global_summary), emptyList(), strings.getString(R.string.summary_detail, overview.summary.sessions, overview.summary.performedSets, overview.summary.distinctExercises, overview.summary.explicitMaxima))
}
TrainlogScreen(title) {
TrainlogAction("← Retour au dashboard", "", back)
TrainlogAction(strings.getString(R.string.back_dashboard), "", back)
TrainlogInfo(description)
if (series.isEmpty()) TrainlogInfo("Aucune donnée comparable dans cette période.")
else series.forEach { DashboardCard(it.label, "${it.points.size} point(s) · ${it.unit}", listOf(it), {}) }
if (series.isEmpty()) TrainlogInfo(strings.getString(R.string.comparable_none))
else series.forEach {
DashboardCard(
localizedStatisticsSeriesLabel(it),
strings.resources.getQuantityString(R.plurals.point_count, it.points.size, it.points.size) + " · ${it.unit}",
listOf(it),
{},
)
}
}
}
@Composable
private fun localizedStatisticsSeriesLabel(series: StatisticsSeries): String {
val strings = localizedContext()
val bodyLabel = when (series.id) {
"body_weight_kg" -> R.string.weight
"neck_cm" -> R.string.neck
"shoulders_cm" -> R.string.shoulders
"chest_cm" -> R.string.chest
"waist_cm" -> R.string.waist
"hips_cm" -> R.string.hips
"left_arm_cm" -> R.string.left_arm
"right_arm_cm" -> R.string.right_arm
"left_forearm_cm" -> R.string.left_forearm
"right_forearm_cm" -> R.string.right_forearm
"left_thigh_cm" -> R.string.left_thigh
"right_thigh_cm" -> R.string.right_thigh
"left_calf_cm" -> R.string.left_calf
"right_calf_cm" -> R.string.right_calf
else -> null
}
if (bodyLabel != null) return strings.getString(bodyLabel)
// INVARIANT: exercise/equipment names in the prefix are user/catalog data and remain byte-for-byte unchanged.
return when {
series.id.startsWith("max:") -> series.label.substringBeforeLast(" — ") + " — " + strings.getString(R.string.explicit_max_label)
series.id.startsWith("work:") -> {
val prefix = series.label.substringBeforeLast(" — ")
.replace(Regex(" reps$"), " " + strings.getString(R.string.reps_short))
.replace(Regex(" duration$"), " s")
prefix + " — " + strings.getString(R.string.performed_loads)
}
else -> series.label
}
}

View file

@ -7,6 +7,7 @@ package com.labfytools.trainlog.ui
/* TRAINLOG_ANDROID_TRIGGERED_SYNC_V1 */
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
@ -31,6 +32,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.labfytools.trainlog.R
/* Package-visible for orchestration tests: this is the production ordering
* boundary, not a duplicate test-only implementation. */
@ -41,12 +43,12 @@ internal suspend fun publishBundleAndRequest(
logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
when (val opened = exporter.openStorageSnapshot()) {
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
SyncRequestResult.Error("Préparation Android → PC : ${opened.message}")
SyncRequestResult.Error("prepare:${opened.message}")
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready ->
when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
is SyncExportResult.Error -> SyncRequestResult.Error(
"Préparation Android → PC : ${publication.message}",
"prepare:${publication.message}",
)
is SyncExportResult.Exported -> {
logDirectExchange("SYNC_BUNDLE", "coordinator.export.success")
@ -57,6 +59,23 @@ internal suspend fun publishBundleAndRequest(
}
}
/**
* WHY: receipt summary is an opaque desktop diagnostic retained for V1
* compatibility and can be written in a language different from this UI.
* CONTRACT: visible completion/failure copy is selected from typed receipt
* status; only locally produced structured catalog counts may be interpolated.
* INVARIANT: [SyncReceiptResult.Received.summary] never reaches presentation.
*/
internal fun visibleReceiptStatus(
strings: Context,
receipt: SyncReceiptResult.Received,
catalogCounts: String? = null,
): String = if (receipt.success) {
strings.getString(R.string.sync_complete, requireNotNull(catalogCounts))
} else {
strings.getString(R.string.sync_receipt_failure)
}
@Composable
fun SyncScreen(
inbox: SyncCatalogInbox,
@ -69,6 +88,15 @@ fun SyncScreen(
LocalTrainlogColors.current
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val strings = localizedContext()
fun catalogCounts(imported: Int, reconciled: Int, skipped: Int? = null): String = buildList {
add(strings.resources.getQuantityString(R.plurals.catalog_new, imported, imported))
add(strings.resources.getQuantityString(R.plurals.catalog_reconciled, reconciled, reconciled))
skipped?.let { add(strings.resources.getQuantityString(R.plurals.catalog_present, it, it)) }
}.joinToString(", ")
fun visibleError(raw: String): String = raw.removePrefix("prepare:").let {
if (raw.startsWith("prepare:")) strings.getString(R.string.sync_prepare_error, it) else raw
}
var status by
remember {
@ -119,7 +147,7 @@ fun SyncScreen(
success = false
status =
"Dossier Trainlog non autorisé."
strings.getString(R.string.sync_folder_unauthorized)
pendingRequestId =
null
@ -143,7 +171,7 @@ fun SyncScreen(
success = false
status =
receipt.summary
visibleReceiptStatus(strings, receipt)
pendingRequestId =
null
@ -160,13 +188,10 @@ fun SyncScreen(
success = true
status =
(
"Synchronisation terminée · " +
receipt.summary +
" · Android catalogue : " +
"${catalog.imported} nouveau(x), " +
"${catalog.reconciled} réconcilié(s), " +
"${catalog.skipped} présent(s)."
visibleReceiptStatus(
strings,
receipt,
catalogCounts(catalog.imported, catalog.reconciled, catalog.skipped),
)
onCatalogChanged()
@ -176,26 +201,21 @@ fun SyncScreen(
success = false
status =
(
"Sync PC terminée, mais catalogue reçu introuvable."
)
strings.getString(R.string.sync_pc_catalog_missing)
}
CatalogInboxResult.FolderNotAuthorized -> {
success = false
status =
"Sync PC terminée, dossier Trainlog non autorisé."
strings.getString(R.string.sync_pc_folder_unauthorized)
}
is CatalogInboxResult.Error -> {
success = false
status =
(
"Sync PC terminée, import Android : " +
catalog.message
)
strings.getString(R.string.sync_pc_import_error, catalog.message)
}
}
@ -210,7 +230,7 @@ fun SyncScreen(
success = false
status =
"Le PC n'a pas répondu dans les 60 secondes."
strings.getString(R.string.sync_timeout)
pendingRequestId =
null
@ -223,20 +243,20 @@ fun SyncScreen(
folderAuthorized = inbox.hasFolderAccess()
success = folderAuthorized
status = if (folderAuthorized) {
"Accès fichiers autorisé · Documents/Trainlog prêt."
strings.getString(R.string.sync_access_ready)
} else {
"Accès fichiers requis. Activez l'autorisation dans Android."
strings.getString(R.string.sync_access_enable)
}
if (folderAuthorized) {
coroutineScope.launch {
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
is CatalogInboxResult.Imported -> {
status = "Accès autorisé · catalogue PC : ${result.imported} nouveau(x), " +
"${result.reconciled} réconcilié(s)."
status = strings.getString(R.string.sync_access_catalog,
catalogCounts(result.imported, result.reconciled))
onCatalogChanged()
}
CatalogInboxResult.FileNotFound ->
status = "Accès autorisé · aucun catalogue PC reçu."
status = strings.getString(R.string.sync_access_no_catalog)
CatalogInboxResult.FolderNotAuthorized -> folderAuthorized = false
is CatalogInboxResult.Error -> status = result.message
}
@ -246,14 +266,14 @@ fun SyncScreen(
TrainlogScreen(
subtitle =
"Synchronisation"
strings.getString(R.string.nav_sync)
) {
TrainlogFrame(
title = "Synchroniser"
title = strings.getString(R.string.sync_action)
) {
TrainlogInfo(
text =
"Le snapshot Android est maintenu automatiquement.",
strings.getString(R.string.sync_snapshot_automatic),
color =
colors.accent,
)
@ -263,12 +283,12 @@ fun SyncScreen(
if (
pendingRequestId != null || syncRunning
) {
"Synchronisation en cours..."
strings.getString(R.string.sync_running)
} else {
"Synchroniser maintenant"
strings.getString(R.string.sync_now)
},
description =
"Android → PC puis PC → Android, en une seule opération.",
strings.getString(R.string.sync_flow_description),
accent =
colors.success,
onClick = {
@ -282,7 +302,7 @@ fun SyncScreen(
success = false
status =
"Accès fichiers requis. Autorisez d'abord Documents/Trainlog."
strings.getString(R.string.sync_authorize_first)
return@TrainlogAction
}
@ -299,15 +319,15 @@ fun SyncScreen(
is SyncRequestResult.Requested -> {
success = true
pendingRequestId = result.requestId
status = "Demande envoyée · attente du PC..."
status = strings.getString(R.string.sync_request_sent)
}
SyncRequestResult.Unsupported -> {
success = false
status = "Android non supporté."
status = strings.getString(R.string.sync_android_unsupported)
}
is SyncRequestResult.Error -> {
success = false
status = result.message
status = visibleError(result.message)
}
}
} finally {
@ -320,11 +340,11 @@ fun SyncScreen(
TrainlogFrame(
title =
"DOSSIER D'ECHANGE"
strings.getString(R.string.sync_folder_section)
) {
if (folderAuthorized) {
TrainlogInfo(
"Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé",
strings.getString(R.string.settings_folder_granted),
color =
colors.success,
)
@ -332,9 +352,9 @@ fun SyncScreen(
TrainlogAction(
label =
if (folderAuthorized) "Ouvrir les réglages d'accès" else "Autoriser l'accès au dossier Trainlog",
strings.getString(if (folderAuthorized) R.string.settings_open_access else R.string.sync_authorize_folder),
description =
"Autoriser l'accès pour gérer tous les fichiers dans Android.",
strings.getString(R.string.sync_manage_files),
onClick = {
permissionLauncher.launch(directStoragePermissionIntent(context))
},
@ -344,9 +364,9 @@ fun SyncScreen(
TrainlogAction(
label =
"Relire le catalogue PC",
strings.getString(R.string.settings_reload_catalog),
description =
"Action de récupération manuelle si nécessaire.",
strings.getString(R.string.sync_manual_recovery),
onClick = {
coroutineScope.launch {
when (val result = withContext(Dispatchers.IO) { inbox.importPcCatalog() }) {
@ -354,12 +374,8 @@ fun SyncScreen(
success = true
status =
(
"Catalogue PC : " +
"${result.imported} nouveau(x), " +
"${result.reconciled} réconcilié(s), " +
"${result.skipped} présent(s)."
)
strings.getString(R.string.sync_catalog_result,
catalogCounts(result.imported, result.reconciled, result.skipped))
onCatalogChanged()
}
@ -368,14 +384,14 @@ fun SyncScreen(
success = false
status =
"Accès fichiers requis pour Documents/Trainlog."
strings.getString(R.string.sync_folder_access_required)
}
CatalogInboxResult.FileNotFound -> {
success = false
status =
"Aucun catalogue PC reçu."
strings.getString(R.string.settings_no_catalog)
}
is CatalogInboxResult.Error -> {
@ -392,7 +408,7 @@ fun SyncScreen(
if (status != null) {
TrainlogFrame(
title = "État",
title = strings.getString(R.string.sync_status),
active = false,
) {
TrainlogInfo(

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import com.labfytools.trainlog.R
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
@ -32,6 +33,8 @@ import kotlinx.coroutines.withContext
@Composable
fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: SyncCatalogInbox, requestOutbox: SyncRequestOutbox, appState: TrainlogAppState) {
val localized = localizedContext()
val strings = localized.resources
val navigation = appState.navigation
val generatorState = appState.generator
val equipmentState = appState.equipment
@ -70,9 +73,9 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
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 ActiveDraftMutationResult.Error -> draftMessage = localizedRepositoryMessage(localized, result.message)
}
is ActiveDraftLoadResult.Error -> draftMessage = draftLoad.message
is ActiveDraftLoadResult.Error -> draftMessage = localizedRepositoryMessage(localized, draftLoad.message)
}
}
@ -89,7 +92,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
routeStateHolder.SaveableStateProvider(routeKey) {
when (val route = navigation.route) {
AppRoute.Home -> HomeScreen(
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.message ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning,
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.let { localizedRepositoryMessage(localized, it.message) } ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning?.let { localizedRepositoryMessage(localized, it) },
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
repository.getBodyZoneHomeOverview(),
::openManualSession,
@ -106,7 +109,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); 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."
draftMessage = strings.getString(R.string.existing_draft_warning)
draftRevision++
/* WHY: ExistingActiveDraft is discovered only after the accept
* action, while the proposal is still dirty. CONTRACT: this
@ -146,13 +149,13 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
val generator = guardedRoute == AppRoute.SessionGenerator
AlertDialog(
onDismissRequest = navigationController::cancelPending,
title = { Text(if (generator) "Proposition non acceptée" else "Modifications non enregistrées") },
text = { Text(if (generator) "La proposition peut rester en mémoire pendant que vous changez de rubrique." else "Les champs restent en mémoire tant que vous ne les abandonnez pas explicitement.") },
confirmButton = { TextButton(onClick = navigationController::keepAndNavigate) { Text("Conserver et quitter") } },
title = { Text(strings.getString(if (generator) R.string.dialog_unaccepted_proposal else R.string.dialog_unsaved_changes)) },
text = { Text(strings.getString(if (generator) R.string.dialog_proposal_retained else R.string.dialog_fields_retained)) },
confirmButton = { TextButton(onClick = navigationController::keepAndNavigate) { Text(strings.getString(R.string.dialog_keep_leave)) } },
dismissButton = {
TextButton(onClick = {
navigationController.discardAndNavigate()
}) { Text(if (generator) "Abandonner la proposition" else "Abandonner les modifications") }
}) { Text(strings.getString(if (generator) R.string.dialog_discard_proposal else R.string.dialog_discard_changes)) }
},
)
}

View file

@ -94,6 +94,7 @@ fun TrainlogFrame(
title: String,
modifier: Modifier = Modifier,
active: Boolean = true,
titleTrailing: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val colors =
@ -112,15 +113,19 @@ fun TrainlogFrame(
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
BasicText(
text = title,
style =
TrainlogTypography.small.copy(
color = accent,
fontWeight =
FontWeight.Bold,
),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
BasicText(
text = title,
modifier = Modifier.weight(1f),
style =
TrainlogTypography.small.copy(
color = accent,
fontWeight =
FontWeight.Bold,
),
)
titleTrailing?.invoke()
}
Box(
modifier =
@ -285,9 +290,11 @@ fun TrainlogButton(
icon: ImageVector? = null,
containerColor: Color? = null,
maxLines: Int = 1,
enabled: Boolean = true,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 52.dp),
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) }
?: ButtonDefaults.buttonColors(),
@ -466,7 +473,7 @@ fun DestructiveConfirmationDialog(
onDismissRequest = onCancel,
title = { Text(title) },
text = { Text(detail) },
dismissButton = { TextButton(onClick = onCancel) { Text("Annuler") } },
dismissButton = { TextButton(onClick = onCancel) { Text(localizedContext().getString(com.labfytools.trainlog.R.string.dialog_cancel)) } },
confirmButton = {
if (deleteContentDescription != null) {
TrainlogDeleteButton(

View file

@ -0,0 +1,399 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Trainlog</string>
<string name="nav_home">Home</string>
<string name="nav_sessions">Sessions</string>
<string name="nav_exercises">Exercises</string>
<string name="nav_equipment">Equipment</string>
<string name="nav_statistics">Statistics</string>
<string name="nav_sync">Synchronization</string>
<string name="nav_settings">Settings</string>
<string name="route_session_editor">Current session</string>
<string name="route_session_generator">Plan a session</string>
<string name="route_ai_drafts">Drafts</string>
<string name="route_completed_sessions">Completed sessions</string>
<string name="route_session_detail">Session details</string>
<string name="route_session_correction">Edit session</string>
<string name="route_exercise_detail">Exercise details</string>
<string name="route_exercise_edit">Edit exercise</string>
<string name="route_exercise_create">Create exercise</string>
<string name="route_equipment_detail">Equipment details</string>
<string name="route_equipment_create">Create equipment</string>
<string name="route_body_measurements">Body measurements</string>
<string name="route_latest_maxima">Latest MAX</string>
<string name="a11y_open_navigation">Open navigation</string>
<string name="a11y_back">Back</string>
<string name="a11y_navigation">Navigation</string>
<string name="settings_language_section">LANGUAGE</string>
<string name="settings_language_label">Language</string>
<string name="language_french">Français</string>
<string name="language_english">English</string>
<string name="settings_exchange_folder">EXCHANGE FOLDER</string>
<string name="settings_screen">Settings</string>
<string name="dialog_cancel">Cancel</string>
<string name="dialog_keep_leave">Keep and leave</string>
<string name="dialog_discard_proposal">Discard proposal</string>
<string name="dialog_discard_changes">Discard changes</string>
<string name="dialog_unaccepted_proposal">Unaccepted proposal</string>
<string name="dialog_unsaved_changes">Unsaved changes</string>
<string name="dialog_proposal_retained">The proposal can remain in memory while you visit another section.</string>
<string name="dialog_fields_retained">The fields remain in memory until you explicitly discard them.</string>
<string name="a11y_delete_exercise">Delete this exercise</string>
<string name="a11y_delete_current_session">Delete the current session</string>
<string name="a11y_delete_set">Delete this set</string>
<string name="a11y_remove_machine">Remove the machine from this exercise</string>
<string name="a11y_delete_draft">Delete this draft</string>
<string name="home_session">Session</string>
<string name="home_sync_state">Status and actions</string>
<string name="home_body_tracking">Body tracking</string>
<string name="settings_access_granted">File access granted.</string>
<string name="settings_access_required">File access is still required.</string>
<string name="settings_folder_granted">Exchange folder: Documents/Trainlog\nFile access: granted</string>
<string name="settings_folder_required">File access required</string>
<string name="settings_open_access">Open access settings</string>
<string name="settings_authorize">Authorize</string>
<string name="settings_authorize_description">Allow access to the Trainlog folder in Android.</string>
<string name="settings_reload_catalog">Reload PC catalog</string>
<string name="settings_reload_description">Explicitly apply the catalog in the authorized folder.</string>
<string name="settings_no_catalog">No PC catalog received.</string>
<string name="settings_folder_revoked">The folder is no longer authorized.</string>
<string name="sync_folder_unauthorized">Trainlog folder not authorized.</string>
<string name="sync_complete">Synchronization complete · Android catalog: %1$s.</string>
<string name="sync_receipt_failure">PC synchronization failed. Check the PC log for diagnostics.</string>
<string name="sync_pc_catalog_missing">PC synchronization completed, but the received catalog could not be found.</string>
<string name="sync_pc_folder_unauthorized">PC synchronization completed, but the Trainlog folder is not authorized.</string>
<string name="sync_pc_import_error">PC synchronization completed; Android import: %1$s</string>
<string name="sync_timeout">The PC did not respond within 60 seconds.</string>
<string name="sync_access_ready">File access granted · Documents/Trainlog is ready.</string>
<string name="sync_access_enable">File access is required. Enable the permission in Android.</string>
<string name="sync_access_catalog">Access granted · PC catalog: %1$s.</string>
<string name="sync_access_no_catalog">Access granted · no PC catalog received.</string>
<string name="sync_action">Synchronize</string>
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
<string name="sync_running">Synchronization in progress…</string>
<string name="sync_now">Synchronize now</string>
<string name="sync_flow_description">Android → PC then PC → Android, in one operation.</string>
<string name="sync_authorize_first">File access is required. Authorize Documents/Trainlog first.</string>
<string name="sync_request_sent">Request sent · waiting for the PC…</string>
<string name="sync_android_unsupported">Android is not supported.</string>
<string name="sync_folder_section">EXCHANGE FOLDER</string>
<string name="sync_authorize_folder">Authorize access to the Trainlog folder</string>
<string name="sync_manage_files">Allow access to manage all files in Android.</string>
<string name="sync_manual_recovery">Manual recovery action if needed.</string>
<string name="sync_catalog_result">PC catalog: %1$s.</string>
<string name="sync_folder_access_required">File access is required for Documents/Trainlog.</string>
<string name="sync_status">Status</string>
<string name="sync_prepare_error">Android → PC preparation: %1$s</string>
<plurals name="catalog_new"><item quantity="one">%d new</item><item quantity="other">%d new</item></plurals>
<plurals name="catalog_reconciled"><item quantity="one">%d reconciled</item><item quantity="other">%d reconciled</item></plurals>
<plurals name="catalog_present"><item quantity="one">%d present</item><item quantity="other">%d present</item></plurals>
<string name="history_none">No sessions recorded.</string>
<string name="max_none">No explicit MAX recorded.</string>
<string name="equipment_value">Equipment: %1$s</string>
<string name="value_none">none</string>
<string name="session_training">TRAINING</string>
<string name="session_max_test">MAX TEST</string>
<plurals name="exercise_count"><item quantity="one">%d exercise</item><item quantity="other">%d exercises</item></plurals>
<string name="date_time_pattern">MM/dd/yyyy h:mm a</string>
<string name="date_pattern">MM/dd/yyyy</string>
<string name="correction_profile_note">Previous profile interpretations are preserved. The identifiers of the session and retained occurrences do not change.</string>
<string name="field_target_sets">Target set count</string>
<string name="field_target_reps">Target repetitions</string>
<string name="field_target_duration">Target duration per set (s)</string>
<string name="field_rest_seconds">Rest (s)</string>
<string name="field_target_weight">Target load (kg)</string>
<string name="field_explicit_max">Explicit MAX (kg)</string>
<string name="field_continuous_duration">Continuous duration (s)</string>
<string name="field_speed">Speed (km/h)</string>
<string name="field_distance">Distance (km)</string>
<string name="field_reps">Repetitions</string>
<string name="field_duration_seconds">Duration (s)</string>
<string name="field_weight">Load (kg)</string>
<string name="set_number">Set %1$d</string>
<string name="add_set">+ Add a set</string>
<string name="add_set_description">Explicitly add a performed fact.</string>
<string name="delete_occurrence">Delete this occurrence</string>
<string name="save">Save</string>
<string name="correction_save_description">Apply the entire correction in one transaction.</string>
<string name="cancel_no_database_change">Leave without changing the database.</string>
<string name="delete_set_question">Delete this set?</string>
<string name="delete_set_detail">This performed fact will disappear from the corrected session.</string>
<string name="delete">Delete</string>
<string name="delete_occurrence_question">Delete this occurrence?</string>
<string name="a11y_delete_occurrence">Delete this occurrence</string>
<plurals name="performed_set_count"><item quantity="one">%d performed set</item><item quantity="other">%d performed sets</item></plurals>
<string name="occurrence_delete_impact">Impact: %1$s%2$s%3$s. Its feedback and revisions will be deleted with their parent.</string>
<string name="continuous_activity_suffix">, continuous activity</string>
<string name="max_suffix">, MAX</string>
<string name="session_not_found">Session not found.</string>
<string name="feedback_dictation">Feedback dictation</string>
<string name="feedback_recording">● Recording</string>
<string name="feedback_stop">Stop dictation</string>
<string name="feedback_text">Text</string>
<string name="feedback_resume">Resume dictation</string>
<string name="feedback_start">Start dictation</string>
<string name="save_changes">Save changes</string>
<string name="feedback_revision_description">Create a new immutable revision.</string>
<string name="feedback_create_description">Create an immutable historical record.</string>
<string name="save_nothing">Save nothing.</string>
<string name="feedback_microphone_denied">Microphone permission denied: manual input remains available.</string>
<string name="feedback_speech_unavailable">Speech recognition is unavailable: use manual input.</string>
<string name="feedback_empty">Empty feedback cannot be saved.</string>
<string name="current_session_none">No session in progress.</string>
<string name="prepare">Prepare</string>
<string name="new_manual_session">New manual session</string>
<string name="create_or_resume">Create or resume</string>
<plurals name="prepared_session_count"><item quantity="one">%d prepared session</item><item quantity="other">%d prepared sessions</item></plurals>
<string name="draft_none">No imported drafts.</string>
<string name="delete_draft_question">Delete this draft?</string>
<string name="max_capacities">CAPABILITIES / MAX</string>
<string name="equipment_catalog">Equipment catalog</string>
<string name="catalog">Catalog</string>
<string name="equipment_none">No equipment found.</string>
<string name="load_value">Load: %1$s</string>
<string name="possible_exercises">Possible exercises</string>
<string name="association_none">No verified association. Anatomy is not inferred from the machine name.</string>
<string name="settings_folder_imported">Folder authorized · %1$s.</string>
<string name="settings_catalog_reloaded">Catalog reloaded · %1$s.</string>
<string name="body_save">Save body measurements</string>
<string name="body_saved">Body measurements saved.</string>
<string name="local_database_error">Local database error.</string>
<string name="body_none">No measurements recorded.</string>
<string name="exercise_save">Save exercise</string>
<string name="existing_exercises">Existing exercises</string>
<string name="exercise_none_saved">No exercises saved.</string>
<string name="modify_named">Edit · %1$s</string>
<string name="view_latest_max">View latest MAX</string>
<string name="name_no_rules">No rule depends on the name.</string>
<string name="tracking_change_question">Change tracking mode?</string>
<string name="modify">Edit</string>
<string name="exercise_not_found">Exercise not found.</string>
<string name="profile_loading">Loading profile…</string>
<string name="primary_muscles">Primary muscles: %1$s</string>
<string name="secondary_muscles">Secondary muscles: %1$s</string>
<string name="stabilizers">Stabilizers: %1$s</string>
<string name="unclassified">Unclassified</string>
<string name="none_established">None established</string>
<string name="exercise_none_found">No exercises found.</string>
<string name="exercise_modify_description">Edit the name, profile, and zones according to the existing rules.</string>
<string name="knowledge_none">No scientific knowledge is linked to this identifier.</string>
<string name="view_latest_max_description">Open the existing aggregated list in Statistics.</string>
<string name="value_none_feminine">None</string>
<string name="field_duration">Duration</string>
<string name="field_speed_short">Speed</string>
<string name="field_distance_short">Distance</string>
<string name="error_title">Error</string>
<string name="draft_unsaved">Draft not saved: %1$s</string>
<string name="draft_unsaved_title">Draft not saved</string>
<string name="last_change_unsaved">Latest change not saved.</string>
<string name="session_saved_locally">Session saved locally.</string>
<string name="normal_session_description">Normal training session.</string>
<string name="max_session_description">Session explicitly dedicated to a MAX measurement.</string>
<string name="exercise_none_added">No exercises added.</string>
<string name="performed_sets_none">No performed sets entered: add the values actually performed.</string>
<string name="modify_name">Edit %1$s</string>
<string name="feedback_edit_description">Correct the text without changing the observation.</string>
<string name="exercise_added">Exercise added to the session.</string>
<string name="exercise_modified">Exercise edited.</string>
<string name="session_save">Save session</string>
<string name="exercise_title">Exercise</string>
<string name="exercise_none">No exercises.</string>
<string name="choose_exercise">Choose an exercise</string>
<string name="exercise_picker_description">Exercise selected. Tap to show suggestions.</string>
<string name="search_exercise">Search for an exercise…</string>
<string name="exercise_none_found_short">No exercises found</string>
<string name="cancel_search">Cancel search</string>
<string name="keep_named_values">Keep %1$s and the entered values.</string>
<string name="entry_title">ENTRY — %1$s</string>
<string name="target_load_note">Planned target load — separate from loads actually performed.</string>
<string name="load_kg_value">Value in kg</string>
<string name="percent_my_max">%% of my MAX</string>
<string name="percent_my_max_field">Percentage of my MAX (1 to 100)</string>
<string name="compatible_max">Compatible MAX: %1$s kg · %2$s</string>
<string name="target_none">No target load will be saved.</string>
<string name="set_reps_field">Set %1$d — Repetitions</string>
<string name="optional_load_note">Load is optional; both decimal separators are accepted. An empty field is not zero.</string>
<string name="sets_count">Number of sets</string>
<string name="set_duration_seconds">Duration per set (seconds)</string>
<string name="duration_minutes">Duration (minutes)</string>
<string name="speed_kmh">Speed km/h</string>
<string name="distance_km">Distance km</string>
<string name="invalid_target_load">Invalid target load.</string>
<string name="cancel_entry">Cancel entry</string>
<string name="delete_numbered_set_question">Delete set %1$d?</string>
<string name="session_type_value">Type: %1$s</string>
<string name="resume_max_test">Resume this MAX test</string>
<string name="resume_max_description">Continue the same session while preserving its identifier and date.</string>
<string name="equipment_inline">Equipment: %1$s</string>
<string name="plan_summary">Plan: %1$d × %2$s · rest %3$d s · %4$s</string>
<string name="target_load_inline">target load %1$s kg</string>
<string name="no_numeric_load">no numeric load</string>
<string name="search_machine">Search for a machine</string>
<string name="modify_equipment">Edit equipment</string>
<string name="modify_equipment_description">Choose, replace, or remove the machine for this entry.</string>
<string name="max_value">MAX: %1$s kg</string>
<string name="modified_suffix"> · Edited</string>
<string name="feedback_move_description">Correct the text without moving the observation.</string>
<string name="add_feedback">Add feedback</string>
<string name="session_follow_up">Post-session follow-up</string>
<string name="feedback_marker_description">Correct the text without changing the H+ marker.</string>
<string name="add_record">Add a record</string>
<string name="remove_machine_question">Remove the machine from this entry?</string>
<string name="replace_machine_question">Replace the associated machine?</string>
<string name="machine_change_detail">The currently recorded machine association will be lost. Sets, MAX, and feedback remain unchanged.</string>
<string name="remove">Remove</string>
<string name="replace">Replace</string>
<string name="mode_sets_reps">Mode: sets · repetitions</string>
<string name="mode_sets_duration">Mode: sets · duration</string>
<string name="assistance_kg">Assistance (kg)</string>
<string name="sets_table">Set | Repetitions | %1$s</string>
<string name="set_duration">Set %1$d: %2$s</string>
<string name="mode_continuous">Mode: continuous</string>
<string name="duration_value">Duration: %1$s</string>
<string name="speed_value">Speed: %1$.1f km/h</string>
<string name="distance_value">Distance: %1$.2f km</string>
<plurals name="duration_minutes_value"><item quantity="one">%d min</item><item quantity="other">%d min</item></plurals>
<plurals name="duration_seconds_value"><item quantity="one">%d sec</item><item quantity="other">%d sec</item></plurals>
<string name="period_7_days">7 days</string><string name="period_30_days">30 days</string><string name="period_90_days">90 days</string><string name="period_year">1 year</string><string name="period_all">All</string>
<string name="statistics_invalid_ignored">Some invalid historical data was ignored; valid statistics remain available.</string>
<string name="explore">EXPLORE</string><string name="distribution">Distribution</string>
<string name="distribution_description">Zones and exercise usage, only when they are classified.</string>
<string name="global_summary">Global summary</string><string name="global_summary_description">Cautious indicators derived from available data.</string>
<string name="summary_period">SUMMARY · %1$s</string><string name="sets">Sets</string><string name="explicit_max_plural">Explicit MAX</string>
<string name="progression">PROGRESSION</string><string name="progress_insufficient">Insufficient data to establish comparable progress.</string>
<string name="progress_counts">%1$d training improvements · %2$d MAX records</string><string name="max_open_detail">%1$d explicit MAX · open details</string>
<string name="body_period_none">No body measurements in this period.</string><string name="measurement_single">1 measurement · trend unavailable</string>
<string name="frequency">FREQUENCY</string><string name="current_week_summary">Current week · %1$s · %2$d MAX included</string>
<string name="max_not_double_counted">MAX values mark sessions; they are never added a second time.</string>
<string name="back_dashboard">← Back to dashboard</string><string name="comparable_none">No comparable data in this period.</string>
<plurals name="session_count"><item quantity="one">%d session</item><item quantity="other">%d sessions</item></plurals>
<string name="progress_detail_description">Each detail row preserves a canonical exercise, comparable equipment, and mode; the dashboard never adds kilograms together.</string>
<string name="body_detail_description">No interpolation: only measurements actually recorded are shown.</string>
<string name="frequency_title">Frequency</string><string name="frequency_unit">sessions</string>
<string name="frequency_detail_description">Number of sessions with actual work per week; MAX is a marker, not a second session.</string>
<string name="distribution_detail_description">Detailed distribution appears when classified occurrences are available in this view.</string>
<string name="summary_detail">%1$d sessions · %2$d sets · %3$d exercises · %4$d explicit MAX</string>
<string name="generator_zone">Body zone</string><string name="generator_goal">Goal</string>
<string name="generator_custom_duration">Custom duration (%1$d to %2$d min)</string>
<string name="generate">Generate</string><string name="generate_description">Analyze history read-only and prepare the proposal.</string>
<string name="duration_range_error">Duration must be between %1$d and %2$d minutes.</string>
<string name="target_duration_estimate">Target duration: %1$d min · estimate: %2$d min</string>
<string name="proposal_short">The proposal is significantly shorter than the target; no exercise is added merely to fill time.</string>
<string name="warmup_not_generated">Warm-up and cool-down are not generated in V1.</string>
<string name="recent_exposure_warning">Informational warning: history shows recent exposure. This does not measure physiological recovery.</string>
<string name="continue_warning">Continue despite warning</string><string name="continue_warning_description">Keep the proposal and allow it to be accepted.</string>
<string name="equipment_generator">Equipment: %1$s</string><string name="target_load_none_proposed">Target load: no numeric load proposed.</string><string name="target_load_proposed">Target load: %1$s kg</string>
<string name="recent_similar">Similar recent history detected; informational only.</string>
<string name="sets_label">Sets</string><string name="user_load_note">Load: user choice, never a recommendation.</string>
<string name="automatic">Automatic</string><string name="none">None</string><string name="apply">Apply</string>
<string name="modify_target">Edit target</string><string name="modify_target_description">Edit sets, repetitions, rest, or load.</string>
<string name="regenerate">Regenerate</string><string name="accept_actual_values">Accept and enter actual values</string>
<string name="cancel_proposal">Cancel proposal</string><string name="cancel_proposal_description">Return without writing a draft.</string>
<string name="processing">Processing</string><string name="analysis_running">Analysis in progress…</string><string name="message_title">Message</string>
<string name="resume">Resume</string><string name="durable_draft_count">%1$s · the durable draft is preserved.</string>
<string name="session_proposal">Session proposal</string><string name="planned_for">Planned for %1$s</string>
<plurals name="repetition_count"><item quantity="one">%d repetition</item><item quantity="other">%d repetitions</item></plurals>
<string name="start">Start</string><string name="start_proposal_description">Copy this proposal into the current session.</string>
<string name="draft_already_active">A session is already in progress. The imported draft was preserved.</string>
<string name="delete_draft_detail">Draft “%1$s” will be deleted.\nThis action cannot be undone.</string>
<string name="training">Training</string><string name="resume_current_session">Resume current session</string>
<string name="new_manual_description">Explicitly create a durable draft.</string><string name="latest_session">Latest session</string><string name="quick_access">Quick access</string>
<string name="zones_to_work">Zones to work</string><string name="recent_exposure">Recent exposure</string><string name="no_training_history">No training history yet.</string>
<string name="invalid_history_timestamp">Insufficient data: a historical timestamp could not be interpreted.</string>
<string name="selection_value">Selection: %1$s</string><string name="priorities">Priorities</string><string name="resolved_zone_none">No zone with a resolved exercise is available.</string>
<string name="exposure_explanation">These colors describe recently recorded work. They do not measure physiological recovery.</string><string name="understood">Got it</string>
<string name="view_exercises">View exercises</string><string name="view_statistics">View statistics</string>
<string name="session_type">Session type</string><string name="training_selected">[✓] Training</string><string name="training_unselected">[ ] Training</string>
<string name="edit_exercise_description">Edit this exercise without removing it from the session.</string><string name="editor_opened">Editor opened for %1$s — prefilled fields appear below.</string>
<string name="editing_scroll_hint">Editing — scroll to ENTRY — %1$s.</string><string name="feedback_value">Feedback · %1$s%2$s</string>
<string name="feedback_now_description">Dictate or enter feedback now, before the session ends.</string><string name="feedback_saved_current">Feedback saved in the current session.</string>
<string name="create_new_exercise">Create a new exercise</string><string name="finalize_failed">Finalization failed, draft preserved: %1$s</string>
<string name="session_exercise_total">%1$s in the session.</string><string name="discard_session_question">Discard the current session?</string>
<string name="discard_session_detail">This will delete the draft, its %1$s, and all their recorded data.</string>
<string name="remove_exercise_question">Remove “%1$s” from the session?</string><string name="remove_occurrence_detail">This occurrence will be removed.</string>
<string name="continuous_fact">the continuous activity</string><string name="max_fact">the MAX value</string>
<string name="create_context">Create context</string><string name="context_compatibility">Temporary compatibility for a custom or unresolved exercise.</string>
<string name="context_exists">This context already exists.</string><string name="context_create_error">Context not created: %1$s</string><string name="equipment_selected">Equipment selected.</string>
<string name="positive_decimal_note">Strictly positive value; both decimal separators are accepted.</string><string name="calculated_target">Calculated target: %1$s kg</string>
<string name="set_independent_values">Each set retains its own repetitions and load.</string><string name="add_set_plain">Add a set</string>
<string name="add_set_row_description">Add an empty row without changing the other sets.</string><string name="add_to_session">Add to session</string>
<string name="delete_set_entered_detail">Its entered repetitions, duration, and load will be deleted.</string>
<string name="validation_set_required">Add at least one set.</string><string name="validation_set_reps">Set %1$d: enter repetitions between 0 and %2$d.</string>
<string name="validation_set_load">Set %1$d: enter a non-negative load or leave the field empty.</string><string name="validation_sets_invalid">Invalid set values.</string>
<string name="exercise_removed">Exercise removed from the session.</string>
<string name="target_positive">Enter a strictly positive target load.</string><string name="max_unavailable">Compatible MAX unavailable.</string>
<string name="max_assistance_unavailable">%%MAX is unavailable for assistance; choose external resistance.</string>
<string name="compatible_load_equipment_required">Choose equipment compatible with a target load.</string><string name="positive_dose_required">Define a positive target dose before the target load.</string>
<string name="percent_range">Percentage must be between 1 and 100.</string><string name="max_read_failed">Unable to read MAX.</string>
<string name="max_choose_external">Compatible MAX unavailable: choose external-resistance equipment.</string><string name="max_known_external_required">%%MAX is unavailable without known external-resistance equipment.</string><string name="max_exact_unavailable">Compatible MAX unavailable for this exact exercise and equipment.</string>
<string name="organization_sets">Sets</string><string name="supplemental_data">Supplemental data</string><string name="not_specified_feminine">Not specified</string><string name="not_specified_plural">Not specified</string>
<string name="profile_change_note">Past sessions and the occurrence already in the draft remain unchanged. The new profile applies to future uses.</string>
<string name="create_return_session">Create and return to session</string><string name="identity_update_description">Preserve identity and update the catalog.</string>
<string name="exercise_name_exists">An exercise with this name already exists.</string><string name="other_exercise_name_exists">Another exercise already has this name.</string><string name="profile_used_error">The profile cannot change after use.</string>
<string name="search_prefix">Prefix search</string><string name="profile_change_dialog_detail">Previous sessions and the active draft remain unchanged. New uses follow the new profile.</string>
<string name="profile_transition_confirmed">Transition confirmed. Save to apply the profile to future uses.</string>
<string name="science_unresolved">Scientific classification unresolved.</string><string name="conditional_interpretation">Conditional interpretation · confirm: %1$s</string>
<string name="movements_value">Movements: %1$s</string><string name="compatible_equipment_value">Compatible equipment: %1$s</string><string name="not_established">Not established</string>
<string name="create_exercise_description">Add a profile to the local catalog.</string><string name="compatible_equipment">Compatible equipment</string><string name="compatibility_none">Compatibility not established.</string>
<string name="latest_result">Latest result: %1$s kg · %2$s</string><string name="science_classification_confidence">Scientific classification · %1$s confidence</string>
<string name="conditional_classification">Conditional classification · explicit uncertainty</string><string name="confidence_high">high</string><string name="confidence_moderate">moderate</string><string name="confidence_low">low</string>
<string name="zone_not_specified">Zone: Not specified</string>
<string name="confidence_uncertain">uncertain</string><string name="primary_zone_value">Primary zone: %1$s</string><string name="secondary_zones_value"> · Secondary zones: %1$s</string><string name="group_value"> · Group: %1$s</string>
<string name="goal_general">General</string><string name="goal_strength">Strength</string><string name="goal_hypertrophy">Hypertrophy</string><string name="goal_endurance">Local endurance</string>
<string name="generator_weight_invalid">Target load must be a finite positive number.</string><string name="proposal">Proposal</string>
<string name="coverage_incomplete">Coverage is incomplete because resolved contexts are unavailable%1$s</string>
<string name="exposure_summary">Observed exposure: %1$d primary and %2$d secondary sets over 24 h; %3$d primary and %4$d secondary over 72 h.</string><string name="latest_exposure">Latest observed exposure: %1$s</string>
<string name="observed_load_note">Load derived from a dose actually observed on %1$s; its applicability today remains uncertain.</string>
<string name="max_percentage_field">MAX percentage (1 to 100)</string><string name="manual_target_load">Manual target load (empty = automatic)</string>
<string name="reestimate_description">Re-estimate duration and reclassify the observed load.</string><string name="move_up">Move up</string><string name="move_up_description">Move this exercise before the previous one.</string><string name="move_down">Move down</string><string name="move_down_description">Move this exercise after the next one.</string>
<string name="regenerate_description">Run again with the same inputs and reference instant.</string><string name="accept_actual_description">Create the normal draft without prefilling performed sets.</string><string name="acknowledge_exposure_first">Confirm the recent-exposure warning first.</string>
<string name="reason_observed_dose">observed repeated dose</string><string name="reason_max_no_prescription">observed maximum without numeric prescription</string><string name="reason_user_max">user-selected MAX percentage</string><string name="reason_max_unavailable">compatible MAX unavailable</string><string name="reason_assistance_omitted">assistance load omitted</string><string name="reason_load_absent">numeric load absent</string><string name="reason_manual_load">manually entered load</string><string name="reason_preferred">preferred exercise</string><string name="reason_primary_zone">requested primary zone</string><string name="reason_secondary_zone">requested secondary zone</string><string name="reason_distinct_movement">distinct movement</string><string name="reason_recent_exercise">same exercise observed recently</string><string name="reason_recent_pattern">similar movement observed recently</string>
<string name="shortage_upper">missing upper region</string><string name="shortage_lower">missing lower region</string><string name="shortage_core">missing core</string><string name="shortage_push">missing upper-body push</string><string name="shortage_pull">missing upper-body pull</string><string name="shortage_lower_extension">missing lower-body extension</string><string name="shortage_lower_flexion">missing lower-body flexion</string><string name="shortage_exercises">fewer distinct exercises available</string>
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="search_equipment">Search by name, label, or alias</string>
<string name="unknown_reference">Unknown reference · %1$s</string><string name="definition">Definition</string><string name="personal">Personal</string><string name="provided">Provided</string><string name="label_value">Label: %1$s</string>
<string name="confidence_value">%1$s confidence</string><string name="verified_sources">Verified sources: %1$d</string><string name="personal_equipment">Personal equipment</string><string name="create">Create</string>
<string name="create_machine_description">Create a personal machine with the current semantics.</string><string name="equipment_name_invalid">Enter a name of 1 to 120 characters.</string><string name="equipment_name_exists">Equipment already has this name.</string><string name="creation_failed">Creation failed: %1$s.</string>
<string name="load_none">no load</string><string name="load_external">external load</string><string name="load_assistance">assistance</string><string name="load_bodyweight">bodyweight</string><string name="load_cardio">cardio</string>
<string name="existing_draft_warning">A session is already in progress. Resume it or return to the preserved proposal.</string><string name="no_numeric_load_proposed">no numeric load proposed</string>
<string name="incompatible_change_detail">This incompatible change will replace:\n- %1$s\nFeedback remains attached to the same entry_id.</string><string name="set_load_field">Set %1$d — %2$s</string>
<string name="selected_suffix">, selected</string><string name="not_selected_suffix">, not selected</string><string name="select_zone">Select %1$s</string>
<string name="progress_a11y_empty">Progress over six weeks: no comparable event, six empty points</string><string name="progress_a11y_values">Progress over six weeks: %1$s events</string>
<string name="chart_insufficient">Not enough data for a chart yet.</string><string name="chart_series_detail">%1$d comparable series · open details</string>
<string name="general">General</string><string name="shoulders">Shoulders</string><string name="empty_fields_ignored">Empty fields are ignored.</string><string name="latest_measurements">Latest measurements</string>
<string name="state_prioritize">Prioritize</string><string name="state_little_work">Little recent work</string><string name="state_recent_work">Recent work</string><string name="state_high_exposure">High recent exposure</string><string name="state_insufficient">Insufficient data</string><string name="state_unsupported">No resolved exercise available</string><string name="relative_priority">Relative priority</string>
<string name="no_primary_exposure">No primary exposure recorded</string><string name="latest_primary_work">Latest primary work: %1$s</string><string name="recent_primary_work">Primary work over 7 days: %1$d</string><string name="recent_secondary_work">Secondary work over 7 days: %1$d</string>
<string name="age_never">never recorded</string><string name="age_today">today</string><string name="age_yesterday">1 day ago</string><plurals name="age_days"><item quantity="one">%d day ago</item><item quantity="other">%d days ago</item></plurals>
<string name="latest_primary_work_label">Latest primary work: %1$s</string>
<string name="frequency_a11y">Frequency over six weeks: %1$s</string><string name="frequency_week_a11y">%1$s %2$s including %3$d MAX</string><string name="current_feminine">current</string>
<string name="weight">Weight</string><string name="target_summary">Target: %1$d × %2$s · rest %3$d s</string><string name="primary_zone_value_short">Primary zone: %1$s</string><string name="movement_value">Movement: %1$s</string><string name="reasons_value">Reasons: %1$s</string><string name="rest_seconds">Rest (seconds)</string>
<string name="name">Name</string><string name="primary_zone">Primary zone</string><string name="secondary_zones">Secondary zones</string><string name="add_profile_description">Add this profile to the local catalog.</string><string name="invalid_profile_name">Invalid profile or name.</string><string name="invalid_name_profile">Invalid name or profile.</string><string name="zone_filter">Filter by zone</string><string name="all_zones">All zones</string>
<string name="confidence_inline">Confidence: %1$s</string><string name="scientific_zones">Scientific zones: %1$s%2$s</string><string name="secondary_suffix"> · secondary: %1$s</string><string name="limitation_value">Limitation: %1$s</string><string name="source_value">Source: %1$s · %2$s%3$s</string><string name="exercise_catalog">Exercise catalog</string><string name="filter">Filter</string><string name="profile">Profile</string>
<string name="latest_max_value">Latest MAX · %1$s · %2$s kg · %3$s</string><string name="type_value">Type: %1$s</string><string name="aliases_value">Aliases: %1$s</string><string name="max_weight">MAX weight (kg)</string><string name="add_entry_description">Add this entry to the draft.</string>
<string name="neck">Neck</string><string name="chest">Chest</string><string name="waist">Waist</string><string name="hips">Hips</string><string name="limbs">Limbs</string>
<string name="left_arm">Left arm</string><string name="right_arm">Right arm</string><string name="left_forearm">Left forearm</string><string name="right_forearm">Right forearm</string><string name="left_thigh">Left thigh</string><string name="right_thigh">Right thigh</string><string name="left_calf">Left calf</string><string name="right_calf">Right calf</string><string name="recording">Recording</string>
<string name="body_map">Body map, %1$s</string><string name="body_front">Front</string><string name="body_back">Back</string>
<string name="plan_draft_summary">%1$s · %2$d × %3$s%4$s · rest %5$d s</string><string name="possible_exercises_plain">Possible exercises</string>
<string name="actions">Actions</string><string name="change_profile_question">Change %1$s’s profile?</string><string name="historical_context_optional">Historical context (optional)</string><string name="new_historical_context">New historical context</string>
<string name="organization">Organization</string><string name="continuous">Continuous</string><string name="primary_measurement">Primary measurement</string><string name="cancel_catalog_description">Return to the catalog without changes.</string><string name="contract">Contract</string><string name="knowledge">Knowledge</string>
<string name="remove_proposal_exercise">Remove this exercise from the proposal</string><string name="speech_interrupted">Recognition interrupted (code %1$d).</string>
<string name="draft_missing">This draft is no longer available.</string><string name="invalid_value">A value is invalid.</string><string name="positive_measure_required">Add at least one positive measurement.</string><plurals name="measurement_count"><item quantity="one">%d measurement</item><item quantity="other">%d measurements</item></plurals><plurals name="available_exercise_count"><item quantity="one">%d exercise available</item><item quantity="other">%d exercises available</item></plurals>
<string name="max_test_selected">[✓] MAX test</string><string name="max_test_unselected">[ ] MAX test</string><string name="feedback_add_count">Feedback (%1$d) · + Add feedback</string><string name="delete_facts_detail">This will delete:\n- %1$s</string><string name="plan_rest_fact">targets and rest</string>
<string name="context_name_invalid">Enter a valid context name.</string><string name="max_weight_invalid">Enter a strictly positive MAX weight (e.g. 100 or 86.5).</string><string name="invalid_values">Invalid values.</string><string name="return_catalog">Return to catalog.</string>
<string name="new_exercise">New exercise</string><string name="primary_zone_required">A primary zone is required for a new muscular exercise.</string>
<string name="feedback_voice">🎙 Feedback</string><string name="draft_max_summary">%1$s · MAX: %2$s kg</string><string name="reps_short">reps</string><string name="profile_continuous">CONTINUOUS</string><string name="profile_sets">SETS</string><string name="profile_reps">REPS</string><string name="profile_duration">DURATION</string>
<plurals name="point_count"><item quantity="one">%d point</item><item quantity="other">%d points</item></plurals>
<string name="database_save_failed">Unable to save to the database.</string><string name="knowledge_expand">+ Knowledge</string><string name="knowledge_collapse">− Knowledge</string>
<plurals name="seconds_count"><item quantity="one">%d second</item><item quantity="other">%d seconds</item></plurals><string name="discard">Discard</string>
<plurals name="feedback_count"><item quantity="one">%d feedback entry</item><item quantity="other">%d feedback entries</item></plurals><string name="during_session">During the session</string>
<string name="profile_speed_suffix"> · speed</string><string name="profile_distance_suffix"> · distance</string>
<string name="zone_full_body">Full body</string><string name="zone_upper_body">Upper body</string><string name="zone_chest">Chest</string><string name="zone_back">Back</string><string name="zone_shoulders">Shoulders</string><string name="zone_arms">Arms</string><string name="zone_core">Abdominals / core</string><string name="zone_lower_body">Lower body</string><string name="zone_glutes">Glutes</string><string name="zone_thighs">Thighs</string><string name="zone_calves">Calves</string>
<string name="performed_loads">performed loads</string><string name="explicit_max_label">explicit MAX</string>
<string name="repository_item_missing">The requested item is no longer available.</string><string name="repository_invalid_data">The requested data is invalid.</string><string name="repository_database_error">The database operation failed.</string><string name="repository_file_access">File access is required.</string><string name="repository_operation_error">The operation failed.</string>
<string name="draft_selection_missing">The exercise being entered no longer exists; only the selection was cleared. Partial input and added exercises were preserved.</string>
<string name="a11y_reorder_exercise">Reorder exercise</string>
<string name="a11y_move_exercise_up">Move exercise up</string>
<string name="a11y_move_exercise_down">Move exercise down</string>
<string name="exercise_order_saved">Session order saved.</string>
<string name="exercise_reorder_failed">Reorder was not saved: %1$s</string>
<string name="invalid_decimal_value">Enter a valid decimal value using “,” or “.”.</string>
</resources>

View file

@ -1,4 +1,399 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Trainlog</string>
<string name="nav_home">Accueil</string>
<string name="nav_sessions">Séances</string>
<string name="nav_exercises">Exercices</string>
<string name="nav_equipment">Équipements</string>
<string name="nav_statistics">Statistiques</string>
<string name="nav_sync">Synchronisation</string>
<string name="nav_settings">Paramètres</string>
<string name="route_session_editor">Séance en cours</string>
<string name="route_session_generator">Programmer une séance</string>
<string name="route_ai_drafts">Brouillons</string>
<string name="route_completed_sessions">Séances effectuées</string>
<string name="route_session_detail">Détail de la séance</string>
<string name="route_session_correction">Modifier la séance</string>
<string name="route_exercise_detail">Fiche exercice</string>
<string name="route_exercise_edit">Modifier l’exercice</string>
<string name="route_exercise_create">Créer un exercice</string>
<string name="route_equipment_detail">Fiche équipement</string>
<string name="route_equipment_create">Créer un équipement</string>
<string name="route_body_measurements">Mensurations</string>
<string name="route_latest_maxima">Derniers MAX</string>
<string name="a11y_open_navigation">Ouvrir la navigation</string>
<string name="a11y_back">Retour</string>
<string name="a11y_navigation">Navigation</string>
<string name="settings_language_section">LANGUE</string>
<string name="settings_language_label">Langue</string>
<string name="language_french">Français</string>
<string name="language_english">English</string>
<string name="settings_exchange_folder">DOSSIER D’ÉCHANGE</string>
<string name="settings_screen">Paramètres</string>
<string name="dialog_cancel">Annuler</string>
<string name="dialog_keep_leave">Conserver et quitter</string>
<string name="dialog_discard_proposal">Abandonner la proposition</string>
<string name="dialog_discard_changes">Abandonner les modifications</string>
<string name="dialog_unaccepted_proposal">Proposition non acceptée</string>
<string name="dialog_unsaved_changes">Modifications non enregistrées</string>
<string name="dialog_proposal_retained">La proposition peut rester en mémoire pendant que vous changez de rubrique.</string>
<string name="dialog_fields_retained">Les champs restent en mémoire tant que vous ne les abandonnez pas explicitement.</string>
<string name="a11y_delete_exercise">Supprimer cet exercice</string>
<string name="a11y_delete_current_session">Supprimer la séance en cours</string>
<string name="a11y_delete_set">Supprimer cette série</string>
<string name="a11y_remove_machine">Retirer la machine de cet exercice</string>
<string name="a11y_delete_draft">Supprimer ce brouillon</string>
<string name="home_session">Séance</string>
<string name="home_sync_state">État et actions</string>
<string name="home_body_tracking">Suivi corporel</string>
<string name="settings_access_granted">Accès fichiers autorisé.</string>
<string name="settings_access_required">Accès fichiers toujours requis.</string>
<string name="settings_folder_granted">Dossier d’échange : Documents/Trainlog\nAccès fichiers : autorisé</string>
<string name="settings_folder_required">Accès fichiers requis</string>
<string name="settings_open_access">Ouvrir les réglages d’accès</string>
<string name="settings_authorize">Autoriser</string>
<string name="settings_authorize_description">Autoriser l’accès au dossier Trainlog dans Android.</string>
<string name="settings_reload_catalog">Relire le catalogue PC</string>
<string name="settings_reload_description">Appliquer explicitement le catalogue présent dans le dossier autorisé.</string>
<string name="settings_no_catalog">Aucun catalogue PC reçu.</string>
<string name="settings_folder_revoked">Le dossier n’est plus autorisé.</string>
<string name="sync_folder_unauthorized">Dossier Trainlog non autorisé.</string>
<string name="sync_complete">Synchronisation terminée · Catalogue Android : %1$s.</string>
<string name="sync_receipt_failure">La synchronisation PC a échoué. Consultez le journal du PC pour le diagnostic.</string>
<string name="sync_pc_catalog_missing">Synchronisation PC terminée, mais catalogue reçu introuvable.</string>
<string name="sync_pc_folder_unauthorized">Synchronisation PC terminée, dossier Trainlog non autorisé.</string>
<string name="sync_pc_import_error">Synchronisation PC terminée, import Android : %1$s</string>
<string name="sync_timeout">Le PC n’a pas répondu dans les 60 secondes.</string>
<string name="sync_access_ready">Accès fichiers autorisé · Documents/Trainlog prêt.</string>
<string name="sync_access_enable">Accès fichiers requis. Activez l’autorisation dans Android.</string>
<string name="sync_access_catalog">Accès autorisé · catalogue PC : %1$s.</string>
<string name="sync_access_no_catalog">Accès autorisé · aucun catalogue PC reçu.</string>
<string name="sync_action">Synchroniser</string>
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
<string name="sync_running">Synchronisation en cours…</string>
<string name="sync_now">Synchroniser maintenant</string>
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
<string name="sync_android_unsupported">Android non supporté.</string>
<string name="sync_folder_section">DOSSIER D’ÉCHANGE</string>
<string name="sync_authorize_folder">Autoriser l’accès au dossier Trainlog</string>
<string name="sync_manage_files">Autoriser l’accès pour gérer tous les fichiers dans Android.</string>
<string name="sync_manual_recovery">Action de récupération manuelle si nécessaire.</string>
<string name="sync_catalog_result">Catalogue PC : %1$s.</string>
<string name="sync_folder_access_required">Accès fichiers requis pour Documents/Trainlog.</string>
<string name="sync_status">État</string>
<string name="sync_prepare_error">Préparation Android → PC : %1$s</string>
<plurals name="catalog_new"><item quantity="one">%d nouveau</item><item quantity="other">%d nouveaux</item></plurals>
<plurals name="catalog_reconciled"><item quantity="one">%d réconcilié</item><item quantity="other">%d réconciliés</item></plurals>
<plurals name="catalog_present"><item quantity="one">%d présent</item><item quantity="other">%d présents</item></plurals>
<string name="history_none">Aucune séance enregistrée.</string>
<string name="max_none">Aucun MAX explicite enregistré.</string>
<string name="equipment_value">Équipement : %1$s</string>
<string name="value_none">aucun</string>
<string name="session_training">ENTRAÎNEMENT</string>
<string name="session_max_test">TEST MAX</string>
<plurals name="exercise_count"><item quantity="one">%d exercice</item><item quantity="other">%d exercices</item></plurals>
<string name="date_time_pattern">dd/MM/yyyy HH:mm</string>
<string name="date_pattern">dd/MM/yyyy</string>
<string name="correction_profile_note">Les anciennes interprétations de profil sont conservées. Les identifiants de la séance et des occurrences retenues ne changent pas.</string>
<string name="field_target_sets">Nombre de séries cible</string>
<string name="field_target_reps">Répétitions cibles</string>
<string name="field_target_duration">Durée cible par série (s)</string>
<string name="field_rest_seconds">Repos (s)</string>
<string name="field_target_weight">Charge cible (kg)</string>
<string name="field_explicit_max">MAX explicite (kg)</string>
<string name="field_continuous_duration">Durée continue (s)</string>
<string name="field_speed">Vitesse (km/h)</string>
<string name="field_distance">Distance (km)</string>
<string name="field_reps">Répétitions</string>
<string name="field_duration_seconds">Durée (s)</string>
<string name="field_weight">Charge (kg)</string>
<string name="set_number">Série %1$d</string>
<string name="add_set">+ Ajouter une série</string>
<string name="add_set_description">Ajouter explicitement un fait réalisé.</string>
<string name="delete_occurrence">Supprimer cette occurrence</string>
<string name="save">Enregistrer</string>
<string name="correction_save_description">Appliquer toute la correction dans une transaction.</string>
<string name="cancel_no_database_change">Quitter sans modifier la base.</string>
<string name="delete_set_question">Supprimer cette série ?</string>
<string name="delete_set_detail">Ce fait réalisé disparaîtra de la séance corrigée.</string>
<string name="delete">Supprimer</string>
<string name="delete_occurrence_question">Supprimer cette occurrence ?</string>
<string name="a11y_delete_occurrence">Supprimer cette occurrence</string>
<plurals name="performed_set_count"><item quantity="one">%d série réalisée</item><item quantity="other">%d séries réalisées</item></plurals>
<string name="occurrence_delete_impact">Impact : %1$s%2$s%3$s. Ses ressentis et révisions seront supprimés avec leur parent.</string>
<string name="continuous_activity_suffix">, activité continue</string>
<string name="max_suffix">, MAX</string>
<string name="session_not_found">Séance introuvable.</string>
<string name="feedback_dictation">Dictée du ressenti</string>
<string name="feedback_recording">● Enregistrement en cours</string>
<string name="feedback_stop">Arrêter la dictée</string>
<string name="feedback_text">Texte</string>
<string name="feedback_resume">Reprendre la dictée</string>
<string name="feedback_start">Démarrer la dictée</string>
<string name="save_changes">Enregistrer les modifications</string>
<string name="feedback_revision_description">Créer une nouvelle révision immuable.</string>
<string name="feedback_create_description">Créer un enregistrement historique immuable.</string>
<string name="save_nothing">Ne rien enregistrer.</string>
<string name="feedback_microphone_denied">Microphone refusé : la saisie manuelle reste disponible.</string>
<string name="feedback_speech_unavailable">Reconnaissance vocale indisponible : utilisez la saisie manuelle.</string>
<string name="feedback_empty">Un ressenti vide ne peut pas être enregistré.</string>
<string name="current_session_none">Aucune séance en cours.</string>
<string name="prepare">Préparer</string>
<string name="new_manual_session">Nouvelle séance manuelle</string>
<string name="create_or_resume">Créer ou reprendre</string>
<plurals name="prepared_session_count"><item quantity="one">%d séance préparée</item><item quantity="other">%d séances préparées</item></plurals>
<string name="draft_none">Aucun brouillon importé.</string>
<string name="delete_draft_question">Supprimer ce brouillon ?</string>
<string name="max_capacities">CAPACITÉS / MAX</string>
<string name="equipment_catalog">Catalogue d’équipements</string>
<string name="catalog">Catalogue</string>
<string name="equipment_none">Aucun équipement trouvé.</string>
<string name="load_value">Charge : %1$s</string>
<string name="possible_exercises">Exercices possibles</string>
<string name="association_none">Aucune association vérifiée. L’anatomie n’est pas déduite du nom de la machine.</string>
<string name="settings_folder_imported">Dossier autorisé · %1$s.</string>
<string name="settings_catalog_reloaded">Catalogue relu · %1$s.</string>
<string name="body_save">Enregistrer les mensurations</string>
<string name="body_saved">Mensurations enregistrées.</string>
<string name="local_database_error">Erreur de base locale.</string>
<string name="body_none">Aucun relevé enregistré.</string>
<string name="exercise_save">Enregistrer l’exercice</string>
<string name="existing_exercises">Exercices existants</string>
<string name="exercise_none_saved">Aucun exercice enregistré.</string>
<string name="modify_named">Modifier · %1$s</string>
<string name="view_latest_max">Voir les derniers MAX</string>
<string name="name_no_rules">Aucune règle ne dépend du nom.</string>
<string name="tracking_change_question">Modifier le mode de suivi ?</string>
<string name="modify">Modifier</string>
<string name="exercise_not_found">Exercice introuvable.</string>
<string name="profile_loading">Chargement du profil…</string>
<string name="primary_muscles">Muscles principaux : %1$s</string>
<string name="secondary_muscles">Muscles secondaires : %1$s</string>
<string name="stabilizers">Stabilisateurs : %1$s</string>
<string name="unclassified">Non classés</string>
<string name="none_established">Aucun établi</string>
<string name="exercise_none_found">Aucun exercice trouvé.</string>
<string name="exercise_modify_description">Modifier le nom, le profil et les zones selon les règles existantes.</string>
<string name="knowledge_none">Aucune connaissance scientifique liée à cet identifiant.</string>
<string name="view_latest_max_description">Ouvrir la liste agrégée existante dans Statistiques.</string>
<string name="value_none_feminine">Aucune</string>
<string name="field_duration">Durée</string>
<string name="field_speed_short">Vitesse</string>
<string name="field_distance_short">Distance</string>
<string name="error_title">Erreur</string>
<string name="draft_unsaved">Brouillon non sauvegardé : %1$s</string>
<string name="draft_unsaved_title">Brouillon non sauvegardé</string>
<string name="last_change_unsaved">Dernière modification non sauvegardée.</string>
<string name="session_saved_locally">Séance sauvegardée localement.</string>
<string name="normal_session_description">Séance normale de travail.</string>
<string name="max_session_description">Séance explicitement dédiée à une mesure de MAX.</string>
<string name="exercise_none_added">Aucun exercice ajouté.</string>
<string name="performed_sets_none">Aucune série réalisée saisie : ajoutez les valeurs réellement effectuées.</string>
<string name="modify_name">Modifier %1$s</string>
<string name="feedback_edit_description">Corriger le texte sans changer l’observation.</string>
<string name="exercise_added">Exercice ajouté à la séance.</string>
<string name="exercise_modified">Exercice modifié.</string>
<string name="session_save">Enregistrer la séance</string>
<string name="exercise_title">Exercice</string>
<string name="exercise_none">Aucun exercice.</string>
<string name="choose_exercise">Choisir un exercice</string>
<string name="exercise_picker_description">Exercice sélectionné. Appuyer pour afficher les suggestions.</string>
<string name="search_exercise">Rechercher un exercice…</string>
<string name="exercise_none_found_short">Aucun exercice trouvé</string>
<string name="cancel_search">Annuler la recherche</string>
<string name="keep_named_values">Conserver %1$s et les valeurs saisies.</string>
<string name="entry_title">SAISIE — %1$s</string>
<string name="target_load_note">Charge cible prévue — séparée des charges réellement effectuées.</string>
<string name="load_kg_value">Valeur en kg</string>
<string name="percent_my_max">%% de mon MAX</string>
<string name="percent_my_max_field">Pourcentage de mon MAX (1 à 100)</string>
<string name="compatible_max">MAX compatible : %1$s kg · %2$s</string>
<string name="target_none">Aucune charge cible ne sera enregistrée.</string>
<string name="set_reps_field">Série %1$d — Répétitions</string>
<string name="optional_load_note">Charge facultative ; les deux séparateurs décimaux sont acceptés. Une case vide n’est pas zéro.</string>
<string name="sets_count">Nombre de séries</string>
<string name="set_duration_seconds">Durée par série (secondes)</string>
<string name="duration_minutes">Durée (minutes)</string>
<string name="speed_kmh">Vitesse km/h</string>
<string name="distance_km">Distance km</string>
<string name="invalid_target_load">Charge cible invalide.</string>
<string name="cancel_entry">Annuler la saisie</string>
<string name="delete_numbered_set_question">Supprimer la série %1$d ?</string>
<string name="session_type_value">Type : %1$s</string>
<string name="resume_max_test">Reprendre ce test MAX</string>
<string name="resume_max_description">Continuer la même séance en conservant son identifiant et sa date.</string>
<string name="equipment_inline">Équipement : %1$s</string>
<string name="plan_summary">Plan : %1$d × %2$s · repos %3$d s · %4$s</string>
<string name="target_load_inline">charge cible %1$s kg</string>
<string name="no_numeric_load">sans charge numérique</string>
<string name="search_machine">Rechercher une machine</string>
<string name="modify_equipment">Modifier l’équipement</string>
<string name="modify_equipment_description">Choisir, remplacer ou retirer la machine de cette entrée.</string>
<string name="max_value">MAX : %1$s kg</string>
<string name="modified_suffix"> · Modifié</string>
<string name="feedback_move_description">Corriger le texte sans déplacer l’observation.</string>
<string name="add_feedback">Ajouter un ressenti</string>
<string name="session_follow_up">Suivi après séance</string>
<string name="feedback_marker_description">Corriger le texte sans changer le repère H+.</string>
<string name="add_record">Ajouter un enregistrement</string>
<string name="remove_machine_question">Retirer la machine de cette entrée ?</string>
<string name="replace_machine_question">Remplacer la machine associée ?</string>
<string name="machine_change_detail">L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés.</string>
<string name="remove">Retirer</string>
<string name="replace">Remplacer</string>
<string name="mode_sets_reps">Mode : séries · répétitions</string>
<string name="mode_sets_duration">Mode : séries · durée</string>
<string name="assistance_kg">Assistance (kg)</string>
<string name="sets_table">Série | Répétitions | %1$s</string>
<string name="set_duration">Série %1$d : %2$s</string>
<string name="mode_continuous">Mode : continu</string>
<string name="duration_value">Durée : %1$s</string>
<string name="speed_value">Vitesse : %1$.1f km/h</string>
<string name="distance_value">Distance : %1$.2f km</string>
<plurals name="duration_minutes_value"><item quantity="one">%d min</item><item quantity="other">%d min</item></plurals>
<plurals name="duration_seconds_value"><item quantity="one">%d s</item><item quantity="other">%d s</item></plurals>
<string name="period_7_days">7 j</string><string name="period_30_days">30 j</string><string name="period_90_days">90 j</string><string name="period_year">1 an</string><string name="period_all">Tout</string>
<string name="statistics_invalid_ignored">Certaines données historiques invalides ont été ignorées ; les statistiques valides restent disponibles.</string>
<string name="explore">EXPLORER</string><string name="distribution">Répartition</string>
<string name="distribution_description">Zones et utilisation des exercices, uniquement lorsqu’elles sont classifiées.</string>
<string name="global_summary">Résumé global</string><string name="global_summary_description">Indicateurs prudents dérivés des données disponibles.</string>
<string name="summary_period">RÉSUMÉ · %1$s</string><string name="sets">Séries</string><string name="explicit_max_plural">MAX explicites</string>
<string name="progression">PROGRESSION</string><string name="progress_insufficient">Données insuffisantes pour établir une progression comparable.</string>
<string name="progress_counts">%1$d progressions de travail · %2$d records MAX</string><string name="max_open_detail">%1$d MAX explicites · ouvrir le détail</string>
<string name="body_period_none">Aucune mensuration dans cette période.</string><string name="measurement_single">1 relevé · tendance indisponible</string>
<string name="frequency">FRÉQUENCE</string><string name="current_week_summary">Semaine actuelle · %1$s · %2$d MAX inclus</string>
<string name="max_not_double_counted">Les MAX signalent des séances ; ils ne sont jamais additionnés une seconde fois.</string>
<string name="back_dashboard">← Retour au tableau de bord</string><string name="comparable_none">Aucune donnée comparable dans cette période.</string>
<plurals name="session_count"><item quantity="one">%d séance</item><item quantity="other">%d séances</item></plurals>
<string name="progress_detail_description">Chaque ligne de détail conserve un exercice canonique, un équipement et un mode comparables ; le tableau de bord ne somme jamais des kg.</string>
<string name="body_detail_description">Aucune interpolation : seuls les relevés réellement enregistrés sont affichés.</string>
<string name="frequency_title">Fréquence</string><string name="frequency_unit">séances</string>
<string name="frequency_detail_description">Nombre de séances avec travail réel par semaine ; MAX est un marqueur, pas une seconde séance.</string>
<string name="distribution_detail_description">La répartition détaillée arrive lorsque les occurrences classifiées sont disponibles dans cette vue.</string>
<string name="summary_detail">%1$d séances · %2$d séries · %3$d exercices · %4$d MAX explicites</string>
<string name="generator_zone">Zone corporelle</string><string name="generator_goal">Objectif</string>
<string name="generator_custom_duration">Durée personnalisée (%1$d à %2$d min)</string>
<string name="generate">Générer</string><string name="generate_description">Analyser l’historique en lecture seule et préparer la proposition.</string>
<string name="duration_range_error">La durée doit être comprise entre %1$d et %2$d minutes.</string>
<string name="target_duration_estimate">Durée cible : %1$d min · estimation : %2$d min</string>
<string name="proposal_short">La proposition est nettement plus courte que la cible ; aucun exercice n’est ajouté pour remplir artificiellement le temps.</string>
<string name="warmup_not_generated">Échauffement et retour au calme ne sont pas générés en V1.</string>
<string name="recent_exposure_warning">Attention informative : l’historique montre une exposition récente. Cela ne mesure pas la récupération physiologique.</string>
<string name="continue_warning">Continuer malgré l’avertissement</string><string name="continue_warning_description">Conserver la proposition et autoriser son acceptation.</string>
<string name="equipment_generator">Équipement : %1$s</string><string name="target_load_none_proposed">Charge cible : aucune charge numérique proposée.</string><string name="target_load_proposed">Charge cible : %1$s kg</string>
<string name="recent_similar">Historique récent similaire détecté ; information uniquement.</string>
<string name="sets_label">Séries</string><string name="user_load_note">Charge : choix utilisateur, jamais une recommandation.</string>
<string name="automatic">Automatique</string><string name="none">Aucune</string><string name="apply">Appliquer</string>
<string name="modify_target">Modifier la cible</string><string name="modify_target_description">Modifier séries, répétitions, repos ou charge.</string>
<string name="regenerate">Régénérer</string><string name="accept_actual_values">Accepter et saisir les valeurs réelles</string>
<string name="cancel_proposal">Annuler la proposition</string><string name="cancel_proposal_description">Revenir sans écrire de brouillon.</string>
<string name="processing">Traitement</string><string name="analysis_running">Analyse en cours…</string><string name="message_title">Message</string>
<string name="resume">Reprendre</string><string name="durable_draft_count">%1$s · le brouillon durable est conservé.</string>
<string name="session_proposal">Proposition de séance</string><string name="planned_for">Prévue le %1$s</string>
<plurals name="repetition_count"><item quantity="one">%d répétition</item><item quantity="other">%d répétitions</item></plurals>
<string name="start">Démarrer</string><string name="start_proposal_description">Copier cette proposition dans la séance en cours.</string>
<string name="draft_already_active">Une séance est déjà en cours. Le brouillon importé a été conservé.</string>
<string name="delete_draft_detail">Le brouillon « %1$s » sera supprimé.\nCette action est irréversible.</string>
<string name="training">Entraînement</string><string name="resume_current_session">Reprendre la séance en cours</string>
<string name="new_manual_description">Créer explicitement un brouillon durable.</string><string name="latest_session">Dernière séance</string><string name="quick_access">Accès rapides</string>
<string name="zones_to_work">Zones à travailler</string><string name="recent_exposure">Exposition récente</string><string name="no_training_history">Pas encore d’historique d’entraînement.</string>
<string name="invalid_history_timestamp">Données insuffisantes : un horodatage historique ne peut pas être interprété.</string>
<string name="selection_value">Sélection : %1$s</string><string name="priorities">Priorités</string><string name="resolved_zone_none">Aucune zone disposant d’un exercice résolu n’est disponible.</string>
<string name="exposure_explanation">Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.</string><string name="understood">Compris</string>
<string name="view_exercises">Voir les exercices</string><string name="view_statistics">Voir les statistiques</string>
<string name="session_type">Type de séance</string><string name="training_selected">[✓] Entraînement</string><string name="training_unselected">[ ] Entraînement</string>
<string name="edit_exercise_description">Corriger cet exercice sans le supprimer de la séance.</string><string name="editor_opened">Éditeur ouvert pour %1$s — les champs préremplis apparaissent ci-dessous.</string>
<string name="editing_scroll_hint">Modification en cours — faites défiler jusqu’à SAISIE — %1$s.</string><string name="feedback_value">Ressenti · %1$s%2$s</string>
<string name="feedback_now_description">Dicter ou saisir un ressenti maintenant, avant la fin de la séance.</string><string name="feedback_saved_current">Ressenti sauvegardé dans la séance en cours.</string>
<string name="create_new_exercise">Créer un nouvel exercice</string><string name="finalize_failed">Échec de finalisation, brouillon conservé : %1$s</string>
<string name="session_exercise_total">%1$s dans la séance.</string><string name="discard_session_question">Abandonner la séance en cours ?</string>
<string name="discard_session_detail">Cette action supprimera le brouillon, ses %1$s et toutes leurs données enregistrées.</string>
<string name="remove_exercise_question">Supprimer « %1$s » de la séance ?</string><string name="remove_occurrence_detail">Cette occurrence sera retirée.</string>
<string name="continuous_fact">l’activité continue</string><string name="max_fact">la donnée MAX</string>
<string name="create_context">Créer le contexte</string><string name="context_compatibility">Compatibilité temporaire pour un exercice personnalisé ou non résolu.</string>
<string name="context_exists">Ce contexte existe déjà.</string><string name="context_create_error">Contexte non créé : %1$s</string><string name="equipment_selected">Équipement sélectionné.</string>
<string name="positive_decimal_note">Valeur strictement positive ; les deux séparateurs décimaux sont acceptés.</string><string name="calculated_target">Cible calculée : %1$s kg</string>
<string name="set_independent_values">Chaque série conserve ses propres répétitions et sa propre charge.</string><string name="add_set_plain">Ajouter une série</string>
<string name="add_set_row_description">Ajouter une ligne vide sans modifier les autres séries.</string><string name="add_to_session">Ajouter à la séance</string>
<string name="delete_set_entered_detail">Ses répétitions, sa durée et sa charge saisies seront supprimées.</string>
<string name="validation_set_required">Ajoutez au moins une série.</string><string name="validation_set_reps">Série %1$d : saisissez des répétitions entre 0 et %2$d.</string>
<string name="validation_set_load">Série %1$d : saisissez une charge non négative ou laissez la case vide.</string><string name="validation_sets_invalid">Valeurs de séries invalides.</string>
<string name="exercise_removed">Exercice retiré de la séance.</string>
<string name="target_positive">Saisissez une charge cible strictement positive.</string><string name="max_unavailable">MAX compatible indisponible.</string>
<string name="max_assistance_unavailable">Le %%MAX est indisponible pour une assistance ; choisissez une résistance externe.</string>
<string name="compatible_load_equipment_required">Choisissez un équipement compatible avec une charge cible.</string><string name="positive_dose_required">Définissez une dose cible positive avant la charge cible.</string>
<string name="percent_range">Le pourcentage doit être compris entre 1 et 100.</string><string name="max_read_failed">Lecture du MAX impossible.</string>
<string name="max_choose_external">MAX compatible indisponible : choisissez un équipement à résistance externe.</string><string name="max_known_external_required">Le %%MAX est indisponible sans équipement connu à résistance externe.</string><string name="max_exact_unavailable">MAX compatible indisponible pour cet exercice et cet équipement exacts.</string>
<string name="organization_sets">Séries</string><string name="supplemental_data">Données complémentaires</string><string name="not_specified_feminine">Non renseignée</string><string name="not_specified_plural">Non renseignés</string>
<string name="profile_change_note">Les séances passées et l’occurrence déjà présente dans le brouillon resteront inchangées. Le nouveau profil s’appliquera aux prochaines utilisations.</string>
<string name="create_return_session">Créer et revenir à la séance</string><string name="identity_update_description">Conserver l’identité et mettre à jour le catalogue.</string>
<string name="exercise_name_exists">Un exercice portant ce nom existe déjà.</string><string name="other_exercise_name_exists">Un autre exercice porte déjà ce nom.</string><string name="profile_used_error">Le profil ne peut pas changer après utilisation.</string>
<string name="search_prefix">Recherche par préfixe</string><string name="profile_change_dialog_detail">Les anciennes séances et le brouillon actif resteront inchangés. Les nouvelles utilisations suivront le nouveau profil.</string>
<string name="profile_transition_confirmed">Transition confirmée. Enregistrez pour appliquer le profil aux prochaines utilisations.</string>
<string name="science_unresolved">Classification scientifique non résolue.</string><string name="conditional_interpretation">Interprétation conditionnelle · à confirmer : %1$s</string>
<string name="movements_value">Mouvements : %1$s</string><string name="compatible_equipment_value">Équipement compatible : %1$s</string><string name="not_established">Non établi</string>
<string name="create_exercise_description">Ajouter un profil au catalogue local.</string><string name="compatible_equipment">Équipements compatibles</string><string name="compatibility_none">Compatibilité non établie.</string>
<string name="latest_result">Dernier résultat : %1$s kg · %2$s</string><string name="science_classification_confidence">Classification scientifique · confiance %1$s</string>
<string name="conditional_classification">Classification conditionnelle · incertitude explicite</string><string name="confidence_high">élevée</string><string name="confidence_moderate">modérée</string><string name="confidence_low">faible</string>
<string name="zone_not_specified">Zone : Non renseignée</string>
<string name="confidence_uncertain">incertaine</string><string name="primary_zone_value">Zone principale : %1$s</string><string name="secondary_zones_value"> · Zones secondaires : %1$s</string><string name="group_value"> · Groupe : %1$s</string>
<string name="goal_general">Général</string><string name="goal_strength">Force</string><string name="goal_hypertrophy">Hypertrophie</string><string name="goal_endurance">Endurance locale</string>
<string name="generator_weight_invalid">La charge cible doit être un nombre positif fini.</string><string name="proposal">Proposition</string>
<string name="coverage_incomplete">La couverture est incomplète faute de contextes résolus disponibles%1$s</string>
<string name="exposure_summary">Exposition observée : %1$d séries principales et %2$d secondaires sur 24 h ; %3$d principales et %4$d secondaires sur 72 h.</string><string name="latest_exposure">Dernière exposition observée : %1$s</string>
<string name="observed_load_note">Charge issue d’une dose réellement observée le %1$s ; son applicabilité aujourd’hui reste incertaine.</string>
<string name="max_percentage_field">Pourcentage du MAX (1 à 100)</string><string name="manual_target_load">Charge cible manuelle (vide = automatique)</string>
<string name="reestimate_description">Réestimer la durée et requalifier la charge observée.</string><string name="move_up">Monter</string><string name="move_up_description">Déplacer cet exercice avant le précédent.</string><string name="move_down">Descendre</string><string name="move_down_description">Déplacer cet exercice après le suivant.</string>
<string name="regenerate_description">Relancer les mêmes entrées et le même instant de référence.</string><string name="accept_actual_description">Créer le brouillon normal sans préremplir les séries réalisées.</string><string name="acknowledge_exposure_first">Confirmez d’abord l’avertissement d’exposition récente.</string>
<string name="reason_observed_dose">dose répétée observée</string><string name="reason_max_no_prescription">maximum observé sans prescription numérique</string><string name="reason_user_max">pourcentage de MAX choisi par l’utilisateur</string><string name="reason_max_unavailable">MAX compatible indisponible</string><string name="reason_assistance_omitted">charge d’assistance omise</string><string name="reason_load_absent">charge numérique absente</string><string name="reason_manual_load">charge saisie manuellement</string><string name="reason_preferred">exercice préféré</string><string name="reason_primary_zone">zone principale demandée</string><string name="reason_secondary_zone">zone secondaire demandée</string><string name="reason_distinct_movement">mouvement distinct</string><string name="reason_recent_exercise">même exercice observé récemment</string><string name="reason_recent_pattern">mouvement similaire observé récemment</string>
<string name="shortage_upper">région supérieure manquante</string><string name="shortage_lower">région inférieure manquante</string><string name="shortage_core">tronc manquant</string><string name="shortage_push">poussée du haut du corps manquante</string><string name="shortage_pull">tirage du haut du corps manquant</string><string name="shortage_lower_extension">extension du bas du corps manquante</string><string name="shortage_lower_flexion">flexion du bas du corps manquante</string><string name="shortage_exercises">moins d’exercices distincts disponibles</string>
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
<string name="unknown_reference">Référence inconnue · %1$s</string><string name="definition">Définition</string><string name="personal">Personnel</string><string name="provided">Fourni</string><string name="label_value">Étiquette : %1$s</string>
<string name="confidence_value">Confiance %1$s</string><string name="verified_sources">Sources vérifiées : %1$d</string><string name="personal_equipment">Équipement personnel</string><string name="create">Créer</string>
<string name="create_machine_description">Créer une machine personnelle avec la sémantique actuelle.</string><string name="equipment_name_invalid">Saisissez un nom de 1 à 120 caractères.</string><string name="equipment_name_exists">Un équipement porte déjà ce nom.</string><string name="creation_failed">Création impossible : %1$s.</string>
<string name="load_none">sans charge</string><string name="load_external">charge externe</string><string name="load_assistance">assistance</string><string name="load_bodyweight">poids du corps</string><string name="load_cardio">cardio</string>
<string name="existing_draft_warning">Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée.</string><string name="no_numeric_load_proposed">aucune charge numérique proposée</string>
<string name="incompatible_change_detail">Ce changement incompatible remplacera :\n- %1$s\nLes ressentis restent attachés au même entry_id.</string><string name="set_load_field">Série %1$d — %2$s</string>
<string name="selected_suffix">, sélectionnée</string><string name="not_selected_suffix">, non sélectionnée</string><string name="select_zone">Sélectionner %1$s</string>
<string name="progress_a11y_empty">Progression sur six semaines : aucun événement comparable, six points vides</string><string name="progress_a11y_values">Progression sur six semaines : %1$s événements</string>
<string name="chart_insufficient">Pas encore assez de données pour une courbe.</string><string name="chart_series_detail">%1$d séries comparables · ouvrir le détail</string>
<string name="general">Général</string><string name="shoulders">Épaules</string><string name="empty_fields_ignored">Les champs vides sont ignorés.</string><string name="latest_measurements">Derniers relevés</string>
<string name="state_prioritize">À privilégier</string><string name="state_little_work">Peu travaillé récemment</string><string name="state_recent_work">Travaillé récemment</string><string name="state_high_exposure">Forte exposition récente</string><string name="state_insufficient">Données insuffisantes</string><string name="state_unsupported">Aucun exercice résolu disponible</string><string name="relative_priority">Priorité relative</string>
<string name="no_primary_exposure">Aucune exposition principale enregistrée</string><string name="latest_primary_work">Dernier travail principal : %1$s</string><string name="recent_primary_work">Travail principal sur 7 j : %1$d</string><string name="recent_secondary_work">Travail secondaire sur 7 j : %1$d</string>
<string name="age_never">jamais enregistrée</string><string name="age_today">aujourd’hui</string><string name="age_yesterday">il y a 1 jour</string><plurals name="age_days"><item quantity="one">il y a %d jour</item><item quantity="other">il y a %d jours</item></plurals>
<string name="latest_primary_work_label">Dernier travail principal : %1$s</string>
<string name="frequency_a11y">Fréquence sur six semaines : %1$s</string><string name="frequency_week_a11y">%1$s %2$s dont %3$d MAX</string><string name="current_feminine">actuelle</string>
<string name="weight">Poids</string><string name="target_summary">Cible : %1$d × %2$s · repos %3$d s</string><string name="primary_zone_value_short">Zone principale : %1$s</string><string name="movement_value">Mouvement : %1$s</string><string name="reasons_value">Raisons : %1$s</string><string name="rest_seconds">Repos (secondes)</string>
<string name="name">Nom</string><string name="primary_zone">Zone principale</string><string name="secondary_zones">Zones secondaires</string><string name="add_profile_description">Ajouter ce profil au catalogue local.</string><string name="invalid_profile_name">Profil ou nom invalide.</string><string name="invalid_name_profile">Nom ou profil invalide.</string><string name="zone_filter">Filtre par zone</string><string name="all_zones">Toutes les zones</string>
<string name="confidence_inline">Confiance : %1$s</string><string name="scientific_zones">Zones scientifiques : %1$s%2$s</string><string name="secondary_suffix"> · secondaires : %1$s</string><string name="limitation_value">Limite : %1$s</string><string name="source_value">Source : %1$s · %2$s%3$s</string><string name="exercise_catalog">Catalogue d’exercices</string><string name="filter">Filtrer</string><string name="profile">Profil</string>
<string name="latest_max_value">Dernier MAX · %1$s · %2$s kg · %3$s</string><string name="type_value">Type : %1$s</string><string name="aliases_value">Alias : %1$s</string><string name="max_weight">Poids MAX (kg)</string><string name="add_entry_description">Ajouter cette saisie au brouillon.</string>
<string name="neck">Cou</string><string name="chest">Poitrine</string><string name="waist">Tour de taille</string><string name="hips">Hanches</string><string name="limbs">Membres</string>
<string name="left_arm">Bras gauche</string><string name="right_arm">Bras droit</string><string name="left_forearm">Avant-bras gauche</string><string name="right_forearm">Avant-bras droit</string><string name="left_thigh">Cuisse gauche</string><string name="right_thigh">Cuisse droite</string><string name="left_calf">Mollet gauche</string><string name="right_calf">Mollet droit</string><string name="recording">Enregistrement</string>
<string name="body_map">Carte corporelle, %1$s</string><string name="body_front">Face</string><string name="body_back">Dos</string>
<string name="plan_draft_summary">%1$s · %2$d × %3$s%4$s · repos %5$d s</string><string name="possible_exercises_plain">Exercices possibles</string>
<string name="actions">Actions</string><string name="change_profile_question">Changer le profil de %1$s ?</string><string name="historical_context_optional">Contexte historique (optionnel)</string><string name="new_historical_context">Nouveau contexte historique</string>
<string name="organization">Organisation</string><string name="continuous">Continu</string><string name="primary_measurement">Mesure principale</string><string name="cancel_catalog_description">Revenir au catalogue sans modification.</string><string name="contract">Contrat</string><string name="knowledge">Connaissances</string>
<string name="remove_proposal_exercise">Retirer cet exercice de la proposition</string><string name="speech_interrupted">Reconnaissance interrompue (code %1$d).</string>
<string name="draft_missing">Ce brouillon n’est plus disponible.</string><string name="invalid_value">Une valeur est invalide.</string><string name="positive_measure_required">Ajoutez au moins une mesure positive.</string><plurals name="measurement_count"><item quantity="one">%d mesure</item><item quantity="other">%d mesures</item></plurals><plurals name="available_exercise_count"><item quantity="one">%d exercice disponible</item><item quantity="other">%d exercices disponibles</item></plurals>
<string name="max_test_selected">[✓] Test MAX</string><string name="max_test_unselected">[ ] Test MAX</string><string name="feedback_add_count">Ressenti (%1$d) · + Ajouter un ressenti</string><string name="delete_facts_detail">Cette action supprimera :\n- %1$s</string><string name="plan_rest_fact">les objectifs et le repos</string>
<string name="context_name_invalid">Donnez un nom de contexte valide.</string><string name="max_weight_invalid">Saisissez un poids MAX strictement positif (ex. 100 ou 86,5).</string><string name="invalid_values">Valeurs invalides.</string><string name="return_catalog">Revenir au catalogue.</string>
<string name="new_exercise">Nouvel exercice</string><string name="primary_zone_required">Une zone principale est requise pour un nouvel exercice musculaire.</string>
<string name="feedback_voice">🎙 Ressenti</string><string name="draft_max_summary">%1$s · MAX : %2$s kg</string><string name="reps_short">rép.</string><string name="profile_continuous">CONTINU</string><string name="profile_sets">SÉRIES</string><string name="profile_reps">RÉP.</string><string name="profile_duration">DURÉE</string>
<plurals name="point_count"><item quantity="one">%d point</item><item quantity="other">%d points</item></plurals>
<string name="database_save_failed">Enregistrement en base impossible.</string><string name="knowledge_expand">+ Connaissances</string><string name="knowledge_collapse">− Connaissances</string>
<plurals name="seconds_count"><item quantity="one">%d seconde</item><item quantity="other">%d secondes</item></plurals><string name="discard">Abandonner</string>
<plurals name="feedback_count"><item quantity="one">%d ressenti</item><item quantity="other">%d ressentis</item></plurals><string name="during_session">Pendant la séance</string>
<string name="profile_speed_suffix"> · vitesse</string><string name="profile_distance_suffix"> · distance</string>
<string name="zone_full_body">Corps entier</string><string name="zone_upper_body">Membres supérieurs</string><string name="zone_chest">Pectoraux</string><string name="zone_back">Dos</string><string name="zone_shoulders">Épaules</string><string name="zone_arms">Bras</string><string name="zone_core">Abdominaux / tronc</string><string name="zone_lower_body">Membres inférieurs</string><string name="zone_glutes">Fessiers</string><string name="zone_thighs">Cuisses</string><string name="zone_calves">Mollets</string>
<string name="performed_loads">charges réalisées</string><string name="explicit_max_label">MAX explicite</string>
<string name="repository_item_missing">L’élément demandé n’est plus disponible.</string><string name="repository_invalid_data">Les données demandées sont invalides.</string><string name="repository_database_error">L’opération en base de données a échoué.</string><string name="repository_file_access">L’accès aux fichiers est requis.</string><string name="repository_operation_error">L’opération a échoué.</string>
<string name="draft_selection_missing">L’exercice en cours de saisie n’existe plus ; seule la sélection a été annulée. La saisie partielle et les exercices ajoutés sont conservés.</string>
<string name="a11y_reorder_exercise">Réordonner l’exercice</string>
<string name="a11y_move_exercise_up">Déplacer l’exercice vers le haut</string>
<string name="a11y_move_exercise_down">Déplacer l’exercice vers le bas</string>
<string name="exercise_order_saved">Ordre de la séance enregistré.</string>
<string name="exercise_reorder_failed">Réorganisation non enregistrée : %1$s</string>
<string name="invalid_decimal_value">Saisissez une valeur décimale valide avec « , » ou « . ».</string>
</resources>

View file

@ -176,4 +176,140 @@ class TrainingFeedbackRepositoryTest {
SessionDraft(listOf(corrected.copy(sets=listOf(SessionSetDraft(reps=1))),timedCorrected))) is CorrectCompletedSessionResult.DatabaseError)
assertEquals(8,android.getSessionDetail(saved.sessionId)!!.exercises[0].sets[1].reps)
}
@Test fun `completed continuous correction preserves identity and republishes corrected facts`() {
val movement = (android.createExercise(NewExerciseProfile(
"Correction continue", RecordingMode.CONTINUOUS, TrackingMode.DURATION,
ExerciseDataFields.SPEED_KMH or ExerciseDataFields.DISTANCE_KM,
)) as CreateExerciseResult.Created).exercise
val original = SessionExerciseDraft(
entryId = "sxe_00000000-0000-4000-8000-0000000000d1",
exercise = movement,
continuousDurationSeconds = 1800,
distanceKm = 300.0,
speedKmh = 10.0,
)
val saved = android.saveSession(SessionDraft(listOf(original))) as SaveSessionResult.Saved
val corrected = original.copy(continuousDurationSeconds = 1900,
distanceKm = com.labfytools.trainlog.ui.parseFiniteDecimal("0,3"), speedKmh = 9.5)
assertEquals(CorrectCompletedSessionResult.Saved,
android.correctCompletedSession(saved.sessionId, SessionDraft(listOf(corrected))))
val detail = android.getSessionDetail(saved.sessionId)!!
assertEquals(saved.sessionId, detail.summary.sessionId)
assertEquals(original.entryId, detail.exercises.single().entryId)
assertEquals(movement.exerciseId, detail.exercises.single().exerciseId)
assertEquals(1900, detail.exercises.single().continuousDurationSeconds)
assertEquals(0.3, detail.exercises.single().distanceKm!!, 0.0)
assertEquals(9.5, detail.exercises.single().speedKmh!!, 0.0)
val exported = JSONObject(android.buildMobileExportV3Json())
.getJSONArray("sessions").getJSONObject(0)
assertEquals(saved.sessionId, exported.getString("session_id"))
val occurrence = exported.getJSONArray("exercises").getJSONObject(0)
assertEquals(original.entryId, occurrence.getString("entry_id"))
val continuous = occurrence.getJSONObject("continuous")
assertEquals(0.3, continuous.getDouble("distance_km"), 0.0)
assertEquals(9.5, continuous.getDouble("speed_kmh"), 0.0)
assertTrue(android.correctCompletedSession(saved.sessionId,
SessionDraft(listOf(corrected.copy(distanceKm = Double.POSITIVE_INFINITY))))
is CorrectCompletedSessionResult.Invalid)
assertEquals(0.3, android.getSessionDetail(saved.sessionId)!!
.exercises.single().distanceKm!!, 0.0)
}
@Test fun `completed reorder is transient atomic and preserves all occurrence ownership`() {
val reps = (android.createExercise(NewExerciseProfile(
"Reorder completed reps", RecordingMode.SETS, TrackingMode.REPS, 0,
)) as CreateExerciseResult.Created).exercise
val continuous = (android.createExercise(NewExerciseProfile(
"Reorder completed continuous", RecordingMode.CONTINUOUS, TrackingMode.DURATION,
ExerciseDataFields.SPEED_KMH or ExerciseDataFields.DISTANCE_KM,
)) as CreateExerciseResult.Created).exercise
val equipment = (android.createCustomEquipment("Completed reorder machine") as
CreateEquipmentResult.Created).equipment
val first = SessionExerciseDraft(
entryId = "sxe_00000000-0000-4000-8000-0000000000e1", exercise = reps,
equipmentId = equipment.equipmentId,
plan = SessionExercisePlan(2, reps = 8, weightKg = 40.0,
loadMode = SessionLoadMode.EXTERNAL, restSeconds = 90),
sets = listOf(SessionSetDraft(reps = 8, weightKg = 39.0)),
)
val duplicate = first.copy(
entryId = "sxe_00000000-0000-4000-8000-0000000000e2",
sets = listOf(SessionSetDraft(reps = 7, weightKg = 38.0)),
)
val cardio = SessionExerciseDraft(
entryId = "sxe_00000000-0000-4000-8000-0000000000e3", exercise = continuous,
continuousDurationSeconds = 600, speedKmh = 8.5, distanceKm = 1.4,
)
val maximum = SessionExerciseDraft(
entryId = "sxe_00000000-0000-4000-8000-0000000000e4", exercise = reps,
equipmentId = equipment.equipmentId, maxWeightKg = 85.0,
)
val saved = android.saveSession(SessionDraft(
listOf(first, duplicate, cardio, maximum), SessionType.MAX_TEST,
)) as SaveSessionResult.Saved
val feedback = android.saveExerciseFeedback(saved.sessionId, duplicate.entryId, "avant") as SaveFeedbackResult.Saved
assertTrue(android.reviseExerciseFeedback(feedback.stableId, "après") is SaveFeedbackResult.Saved)
val followup = android.saveSessionFollowUp(saved.sessionId, "J+1") as SaveFeedbackResult.Saved
val original = android.getSessionDetail(saved.sessionId)!!
val transient = com.labfytools.trainlog.ui.reorderExerciseOccurrences(
original.exercises.map { it.correctionDraftForTest() }, 3, 0,
)
/* Cancel/Back has no repository call: canonical history remains exact. */
assertEquals(original, android.getSessionDetail(saved.sessionId))
assertEquals(CorrectCompletedSessionResult.Saved, android.correctCompletedSession(
saved.sessionId, SessionDraft(transient, SessionType.MAX_TEST),
))
val reordered = android.getSessionDetail(saved.sessionId)!!
assertEquals(saved.sessionId, reordered.summary.sessionId)
assertEquals(listOf(maximum.entryId, first.entryId, duplicate.entryId, cardio.entryId),
reordered.exercises.map { it.entryId })
assertEquals(listOf(reps.exerciseId, reps.exerciseId, reps.exerciseId, continuous.exerciseId),
reordered.exercises.map { it.exerciseId })
assertEquals(maximum.maxWeightKg, reordered.exercises[0].maxWeightKg)
assertEquals(first.plan, reordered.exercises[1].plan)
assertEquals(first.sets, reordered.exercises[1].sets)
assertEquals(duplicate.sets, reordered.exercises[2].sets)
assertEquals(cardio.continuousDurationSeconds, reordered.exercises[3].continuousDurationSeconds)
assertEquals(cardio.distanceKm, reordered.exercises[3].distanceKm)
assertEquals(cardio.speedKmh, reordered.exercises[3].speedKmh)
assertEquals(listOf(equipment.equipmentId, equipment.equipmentId, equipment.equipmentId, null),
reordered.exercises.map { it.equipmentId })
assertEquals(feedback.stableId, reordered.exercises[2].feedback.single().feedbackId)
assertTrue(reordered.exercises[2].feedback.single().modified)
assertEquals(followup.stableId, reordered.followUps.single().followupId)
android.close()
SQLiteDatabase.openDatabase(context.getDatabasePath(aName).path, null,
SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("CREATE TRIGGER fail_completed_reorder BEFORE INSERT ON session_exercises " +
"BEGIN SELECT RAISE(ABORT,'synthetic completed reorder failure'); END;")
}
android = TrainlogRepository(context, aName)
val failed = com.labfytools.trainlog.ui.reorderExerciseOccurrences(transient, 0, 3)
assertTrue(android.correctCompletedSession(saved.sessionId,
SessionDraft(failed, SessionType.MAX_TEST)) is CorrectCompletedSessionResult.DatabaseError)
assertEquals(reordered, android.getSessionDetail(saved.sessionId))
val export = JSONObject(android.buildMobileExportV3Json()).getJSONArray("sessions")
assertEquals(1, export.length())
assertEquals(saved.sessionId, export.getJSONObject(0).getString("session_id"))
assertEquals(listOf(maximum.entryId, first.entryId, duplicate.entryId, cardio.entryId),
List(4) { export.getJSONObject(0).getJSONArray("exercises").getJSONObject(it).getString("entry_id") })
}
private fun SessionExerciseDetail.correctionDraftForTest() = SessionExerciseDraft(
entryId = entryId,
exercise = ExerciseProfile(exerciseId, exerciseName, exerciseName,
recordingMode, trackingMode, dataFields),
equipmentId = equipmentId,
maxWeightKg = maxWeightKg,
plan = plan,
sets = sets,
continuousDurationSeconds = continuousDurationSeconds,
speedKmh = speedKmh,
distanceKm = distanceKm,
)
}

View file

@ -0,0 +1,124 @@
package com.labfytools.trainlog.ui
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.CreateEquipmentResult
import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraftForm
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.junit.After
import org.junit.Assert.assertEquals
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
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ActiveSessionReorderTest {
private lateinit var context: Context
private lateinit var repository: TrainlogRepository
private lateinit var databaseName: String
@Before fun setUp() {
context = ApplicationProvider.getApplicationContext()
databaseName = "reorder-${UUID.randomUUID()}.db"
repository = TrainlogRepository(context, databaseName)
}
@After fun tearDown() {
repository.close()
context.deleteDatabase(databaseName)
}
@Test fun `reorder is identity preserving durable atomic and finalizes in new order`() {
val exerciseA = (repository.createExercise(NewExerciseProfile(
"Reorder A", RecordingMode.SETS, TrackingMode.REPS, 0,
)) as CreateExerciseResult.Created).exercise
val exerciseB = (repository.createExercise(NewExerciseProfile(
"Reorder B", RecordingMode.SETS, TrackingMode.REPS, 0,
)) as CreateExerciseResult.Created).exercise
val equipment = (repository.createCustomEquipment("Reorder machine") as
CreateEquipmentResult.Created).equipment
val first = SessionExerciseDraft(
entryId = "sxe_00000000-0000-4000-8000-0000000000a1",
exercise = exerciseA,
equipmentId = equipment.equipmentId,
plan = SessionExercisePlan(sets = 2, reps = 8, weightKg = 42.5,
loadMode = com.labfytools.trainlog.model.SessionLoadMode.EXTERNAL, restSeconds = 90),
sets = listOf(SessionSetDraft(reps = 7, weightKg = 41.0)),
)
val duplicate = first.copy(
entryId = "sxe_00000000-0000-4000-8000-0000000000a2",
sets = listOf(SessionSetDraft(reps = 6, weightKg = 39.0)),
)
val different = SessionExerciseDraft(
entryId = "sxe_00000000-0000-4000-8000-0000000000b1",
exercise = exerciseB,
sets = listOf(SessionSetDraft(reps = 12, weightKg = 12.0)),
)
val form = SessionDraftForm(
selectedExercise = exerciseA,
editingExerciseIndex = 0,
editingEntryId = first.entryId,
selectedEquipmentId = equipment.equipmentId,
repsText = "7, 6",
weightText = "41",
)
val initial = ActiveSessionDraft(exercises = listOf(first, duplicate, different), form = form)
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(initial))
val reorderedDraft = reorderActiveSessionDraft(initial, 2, 0)
val reorderedItems = reorderedDraft.exercises
assertEquals(form.copy(editingExerciseIndex = 1), reorderedDraft.form)
assertEquals(listOf(different.entryId, first.entryId, duplicate.entryId), reorderedItems.map { it.entryId })
assertEquals(initial.exercises.toSet(), reorderedItems.toSet())
assertEquals(ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(reorderedDraft))
repository.close()
repository = TrainlogRepository(context, databaseName)
val restored = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(reorderedItems, restored.exercises)
assertEquals(form.repsText, restored.form.repsText)
assertEquals(form.weightText, restored.form.weightText)
assertEquals(form.selectedEquipmentId, restored.form.selectedEquipmentId)
repository.close()
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null,
SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("CREATE TRIGGER fail_reorder BEFORE INSERT ON draft_session_exercises " +
"BEGIN SELECT RAISE(ABORT,'synthetic reorder failure'); END;")
}
repository = TrainlogRepository(context, databaseName)
val failedOrder = restored.copy(exercises = reorderExerciseOccurrences(restored.exercises, 0, 2))
assertTrue(repository.saveActiveSessionDraft(failedOrder) is ActiveDraftMutationResult.Error)
assertEquals(restored, (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft)
repository.close()
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null,
SQLiteDatabase.OPEN_READWRITE).use { it.execSQL("DROP TRIGGER fail_reorder") }
repository = TrainlogRepository(context, databaseName)
val saved = repository.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
val completed = repository.getSessionDetail(saved.sessionId)!!
assertEquals(reorderedItems.map { it.entryId }, completed.exercises.map { it.entryId })
assertEquals(reorderedItems.map { it.exercise.exerciseId }, completed.exercises.map { it.exerciseId })
assertEquals(reorderedItems.map { it.equipmentId }, completed.exercises.map { it.equipmentId })
assertEquals(reorderedItems.map { it.plan }, completed.exercises.map { it.plan })
assertEquals(reorderedItems.map { it.sets }, completed.exercises.map { it.sets })
}
}

View file

@ -74,7 +74,7 @@ class AiSessionDraftUiWiringTest {
} }
compose.onNodeWithText("Brouillons").assertIsDisplayed()
compose.onNodeWithText("0 séance(s) préparée(s)", substring = true).assertIsDisplayed()
compose.onNodeWithText("0 séance préparée", substring = true).assertIsDisplayed()
}
@Test
@ -86,7 +86,7 @@ class AiSessionDraftUiWiringTest {
SessionsHub(null, 2, {}, { manualCalls++ }, { draftCalls++ }, { historyCalls++ })
} }
compose.onNodeWithText("2 séance(s) préparée(s)", substring = true).assertIsDisplayed()
compose.onNodeWithText("2 séances préparées", substring = true).assertIsDisplayed()
compose.onNodeWithText("Nouvelle séance manuelle").performClick()
compose.onNodeWithText("Brouillons").performClick()
compose.onNodeWithText("Séances effectuées").performClick()

View file

@ -5,14 +5,25 @@
*/
package com.labfytools.trainlog.ui
import android.content.Context
import android.content.res.Configuration
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.BodyZoneHomeState
import com.labfytools.trainlog.data.BodyZoneHomeStatus
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Locale
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BodyZoneHomePresentationTest {
private val context: Context = ApplicationProvider.getApplicationContext<Context>().createConfigurationContext(
Configuration(ApplicationProvider.getApplicationContext<Context>().resources.configuration).apply { setLocale(Locale.FRENCH) })
@Test fun detailWordingIsExposureOnlyAndIncludesEvidence() {
val status = BodyZoneHomeStatus(
zoneId = "chest",
@ -30,21 +41,21 @@ class BodyZoneHomePresentationTest {
state = BodyZoneHomeState.PRIORITIZE,
reasons = listOf("Exposition primaire moins récente."),
)
val text = recommendationReason(status)
val text = recommendationReason(status, context)
assertEquals("Dernier travail principal : il y a 10 jours", text)
assertEquals("jamais enregistrée", ageLabel(null))
assertEquals("aujourd’hui", ageLabel(60))
assertEquals("il y a 10 jours", ageLabel(10 * 86400L))
assertEquals("jamais enregistrée", ageLabel(null, context))
assertEquals("aujourd’hui", ageLabel(60, context))
assertEquals("il y a 10 jours", ageLabel(10 * 86400L, context))
listOf("récupération", "fatigue", "prêt", "séance recommandée").forEach {
assertTrue(it !in text.lowercase())
}
}
@Test fun recentWorkLabelsAreNeutralFactualCounts() {
assertEquals("Travail principal sur 7 j : 1", recentWorkLabel(1, "principal"))
assertEquals("Travail principal sur 7 j : 2", recentWorkLabel(2, "principal"))
assertEquals("Travail secondaire sur 7 j : 1", recentWorkLabel(1, "secondaire"))
assertEquals("Travail secondaire sur 7 j : 3", recentWorkLabel(3, "secondaire"))
assertEquals("Travail principal sur 7 j : 1", recentWorkLabel(1, true, context))
assertEquals("Travail principal sur 7 j : 2", recentWorkLabel(2, true, context))
assertEquals("Travail secondaire sur 7 j : 1", recentWorkLabel(1, false, context))
assertEquals("Travail secondaire sur 7 j : 3", recentWorkLabel(3, false, context))
}
@Test fun recentPrioritizedExposureIsPresentedAsRelativePriority() {
@ -65,10 +76,10 @@ class BodyZoneHomePresentationTest {
reasons = emptyList(),
)
assertEquals("Priorité relative", homeStateLabel(status))
assertEquals("Travail principal sur 7 j : 2", recommendationReason(status))
assertFalse(recommendationReason(status).contains("exposition"))
assertFalse(recommendationReason(status).contains("fait(s)"))
assertFalse(recommendationReason(status).contains("pondéré"))
assertEquals("Priorité relative", homeStateLabel(status, context))
assertEquals("Travail principal sur 7 j : 2", recommendationReason(status, context))
assertFalse(recommendationReason(status, context).contains("exposition"))
assertFalse(recommendationReason(status, context).contains("fait(s)"))
assertFalse(recommendationReason(status, context).contains("pondéré"))
}
}

View file

@ -0,0 +1,44 @@
package com.labfytools.trainlog.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FieldInputRefinementTest {
@Test fun `one continuous delta targets distant list positions`() {
assertEquals(1, reorderTargetIndex(origin = 5, totalDragDistancePx = -4f * 48f,
rowStepPx = 48f, itemCount = 6))
assertEquals(5, reorderTargetIndex(origin = 0, totalDragDistancePx = 20f * 48f,
rowStepPx = 48f, itemCount = 6))
assertEquals(0, reorderTargetIndex(origin = 5, totalDragDistancePx = -20f * 48f,
rowStepPx = 48f, itemCount = 6))
val ids = listOf("A", "B", "C", "D", "E", "F")
assertEquals(listOf("A", "F", "B", "C", "D", "E"),
ids.toMutableList().apply { add(1, removeAt(5)) })
/* A canceled gesture restores its stable origin permutation. */
val moved = ids.toMutableList().apply { add(1, removeAt(5)) }
assertEquals(ids, moved.toMutableList().apply { add(5, removeAt(1)) })
}
@Test fun `decimal editor accepts both separators and incomplete typing states`() {
listOf("0.3", "0,3", "0.25", "0,25", "1", "1.0", "1,0",
"0.", "0,", ".3", ",3", "").forEach {
assertTrue("editor rejected $it", decimalEditorTextValid(it))
}
assertFalse(decimalEditorTextValid("1.2.3"))
assertFalse(decimalEditorTextValid("1,2,3"))
assertFalse(decimalEditorTextValid("NaN"))
assertFalse(decimalEditorTextValid("inf"))
}
@Test fun `final decimal parse is locale independent finite and sign checked by domain`() {
mapOf("0.3" to 0.3, "0,3" to 0.3, "0.25" to 0.25, "0,25" to 0.25,
"1" to 1.0, "1.0" to 1.0, "1,0" to 1.0, ".3" to 0.3, ",3" to 0.3)
.forEach { (raw, expected) -> assertEquals(expected, parseFiniteDecimal(raw)!!, 0.0) }
listOf("", ".", ",", "NaN", "inf").forEach { assertNull(parseFiniteDecimal(it)) }
assertTrue(parseFiniteDecimal("-0.3")!! < 0.0)
}
}

View file

@ -0,0 +1,58 @@
package com.labfytools.trainlog.ui
import android.content.Context
import android.content.res.Configuration
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.R
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
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 I18nResourceContractTest {
private val base: Context = ApplicationProvider.getApplicationContext()
private fun context(language: AppLanguage): Context = base.createConfigurationContext(
Configuration(base.resources.configuration).apply { setLocale(Locale.forLanguageTag(language.tag)) },
)
@Test fun `root settings destructive and accessibility strings have both locales`() {
val french = context(AppLanguage.FRENCH)
val english = context(AppLanguage.ENGLISH)
val translated = listOf(
R.string.nav_home, R.string.nav_settings, R.string.settings_language_label,
R.string.dialog_unsaved_changes, R.string.dialog_discard_changes,
R.string.a11y_open_navigation, R.string.a11y_delete_current_session,
)
translated.forEach { id ->
assertTrue(french.getString(id).isNotBlank())
assertTrue(english.getString(id).isNotBlank())
assertTrue("resource $id must be translated", french.getString(id) != english.getString(id))
}
assertEquals("Français", english.getString(R.string.language_french))
assertEquals("English", french.getString(R.string.language_english))
}
@Test fun `catalog names and technical literals are not localized`() {
val userExerciseName = "Développé FY59 personnalisé"
val protocolLiteral = "trainlog-mobile-export-v3.json"
// INVARIANT: resource templates interpolate raw domain names without translating or normalizing them.
assertTrue(context(AppLanguage.ENGLISH).getString(R.string.modify_named, userExerciseName).contains(userExerciseName))
assertEquals(protocolLiteral, protocolLiteral)
}
@Test fun `stable catalog and repository values are classified only at presentation`() {
val french = context(AppLanguage.FRENCH)
val english = context(AppLanguage.ENGLISH)
assertEquals("Pectoraux", localizedBodyZoneName(french, "chest", "Pectoraux"))
assertEquals("Chest", localizedBodyZoneName(english, "chest", "Pectoraux"))
assertEquals("Future catalog label", localizedBodyZoneName(english, "future_zone", "Future catalog label"))
assertEquals("The requested item is no longer available.", localizedRepositoryMessage(english, "Séance introuvable."))
assertEquals("L’opération en base de données a échoué.", localizedRepositoryMessage(french, "Erreur base locale séances."))
}
}

View file

@ -0,0 +1,50 @@
package com.labfytools.trainlog.ui
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Assert.assertEquals
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 LanguageSettingsOwnerTest {
private lateinit var context: Context
@Before fun setUp() {
context = ApplicationProvider.getApplicationContext()
context.getSharedPreferences(LanguageSettingsOwner.PREFERENCES_NAME, Context.MODE_PRIVATE)
.edit().clear().commit()
}
@After fun tearDown() {
context.getSharedPreferences(LanguageSettingsOwner.PREFERENCES_NAME, Context.MODE_PRIVATE)
.edit().clear().commit()
}
@Test fun missingAndInvalidPreferenceDefaultToFrench() {
assertEquals(AppLanguage.FRENCH, LanguageSettingsOwner(context).language)
context.getSharedPreferences(LanguageSettingsOwner.PREFERENCES_NAME, Context.MODE_PRIVATE)
.edit().putString(LanguageSettingsOwner.KEY_LANGUAGE, "de").commit()
assertEquals(AppLanguage.FRENCH, LanguageSettingsOwner(context).language)
}
@Test fun selectionPersistsAndIsRestoredByANewOwner() {
val first = LanguageSettingsOwner(context)
first.select(AppLanguage.ENGLISH)
assertEquals(AppLanguage.ENGLISH, first.language)
assertEquals(AppLanguage.ENGLISH, LanguageSettingsOwner(context).language)
}
@Test fun languagePreferenceIsDedicatedPresentationState() {
LanguageSettingsOwner(context).select(AppLanguage.ENGLISH)
assertEquals(
setOf(LanguageSettingsOwner.KEY_LANGUAGE),
context.getSharedPreferences(LanguageSettingsOwner.PREFERENCES_NAME, Context.MODE_PRIVATE).all.keys,
)
}
}

View file

@ -21,10 +21,10 @@ import org.junit.Test
class SessionGeneratorPreviewControllerTest {
@Test
fun formExposesTheCompleteFrenchChoiceSetAndValidatesCustomDuration() {
fun formExposesTheCompleteStableGoalSetAndValidatesCustomDuration() {
assertEquals(
listOf("Général", "Force", "Hypertrophie", "Endurance locale"),
SessionGeneratorFormController.goals.map { it.second },
listOf("general", "strength", "hypertrophy", "endurance"),
SessionGeneratorFormController.goals,
)
val allowed = 10..120
assertEquals(10, SessionGeneratorFormController.duration("10", allowed))

View file

@ -52,7 +52,7 @@ class SessionSetRowEditorTest {
assertNull(parseRawSetRows(rows))
assertEquals(
"Série 2 : saisissez des répétitions entre 0 et 10000.",
"set_reps:2",
setRowValidationError(rows),
)
assertEquals("10;8x;6", encodeRawReps(rows))
@ -167,7 +167,7 @@ class SessionSetRowEditorTest {
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.",
"max_assistance_unavailable",
assistance.exceptionOrNull()?.message,
)
}

View file

@ -69,8 +69,8 @@ class StatisticsScreenPresentationTest {
"Progression sur six semaines : aucun événement comparable, six points vides",
).fetchSemanticsNode()
compose.onNodeWithText("Données insuffisantes pour établir une progression comparable.").fetchSemanticsNode()
compose.onNodeWithText("2 MAX explicite(s) · ouvrir le détail").fetchSemanticsNode()
compose.onNodeWithText("82.5 kg").fetchSemanticsNode()
compose.onNodeWithText("2 MAX explicites · ouvrir le détail").fetchSemanticsNode()
compose.onNodeWithText("82,5 kg").fetchSemanticsNode()
compose.onNodeWithText("1 relevé · tendance indisponible").fetchSemanticsNode()
assertTrue(compose.onAllNodesWithText("Δ", substring = true).fetchSemanticsNodes().isEmpty())
}
@ -89,9 +89,9 @@ class StatisticsScreenPresentationTest {
}
compose.onNodeWithContentDescription("Fréquence sur six semaines", substring = true).fetchSemanticsNode()
compose.onNodeWithText("Semaine actuelle · 3 séance(s) · 1 MAX inclus").fetchSemanticsNode()
compose.onNodeWithText("Semaine actuelle · 3 séances · 1 MAX inclus").fetchSemanticsNode()
compose.onNodeWithText(
"Les MAX signalent des séances; ils ne sont jamais additionnés une seconde fois.",
"Les MAX signalent des séances ; ils ne sont jamais additionnés une seconde fois.",
).fetchSemanticsNode()
}

View file

@ -184,6 +184,23 @@ class SyncBundlePublicationTest {
assertEquals(0, directory.canonicalCount("foo (1).json"))
}
@Test fun `presentation language cannot change synchronization payload`() = runBlocking {
val exporter = SyncExporter(context, repository) { directory }
assertTrue(exporter.exportMobileBundle() is com.labfytools.trainlog.data.SyncExportResult.Exported)
val frenchPayload = JSONObject(readCanonical("trainlog-mobile-export-v3.json")).apply {
remove("generated_at")
}.toString()
LanguageSettingsOwner(context).select(AppLanguage.ENGLISH)
assertTrue(exporter.exportMobileBundle() is com.labfytools.trainlog.data.SyncExportResult.Exported)
val englishPayload = JSONObject(readCanonical("trainlog-mobile-export-v3.json")).apply {
remove("generated_at")
}.toString()
assertEquals(frenchPayload, englishPayload)
assertFalse(readCanonical("trainlog-mobile-export-v3.json").contains("language_tag"))
}
@Test fun `empty directory creates exact canonical name and final content`() {
val opened = DirectExchangePublisher { directory }.snapshot() as DirectExchangeSnapshotResult.Ready

View file

@ -0,0 +1,57 @@
package com.labfytools.trainlog.ui
import android.content.Context
import android.content.res.Configuration
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.SyncReceiptResult
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
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 SyncReceiptPresentationTest {
private val base: Context = ApplicationProvider.getApplicationContext()
private fun englishContext(): Context = base.createConfigurationContext(
Configuration(base.resources.configuration).apply {
setLocale(Locale.forLanguageTag(AppLanguage.ENGLISH.tag))
},
)
@Test fun `english success never exposes opaque french receipt summary`() {
val raw = "Synchronisation terminée : résumé brut du PC."
val receipt = SyncReceiptResult.Received("sy_test", true, raw)
val visible = visibleReceiptStatus(
englishContext(),
receipt,
"2 new, 3 reconciled, 4 present",
)
assertTrue(visible.contains("Synchronization complete"))
assertTrue(visible.contains("2 new, 3 reconciled, 4 present"))
assertFalse(visible.contains(raw))
assertFalse(visible.contains("Synchronisation"))
/* INVARIANT: presentation neither rewrites nor consumes compatibility
* payload; the parser-owned value remains byte-exact on the receipt. */
assertEquals(raw, receipt.summary)
}
@Test fun `english failure never exposes opaque french receipt summary`() {
val raw = "Échec de synchronisation : diagnostic brut du PC."
val receipt = SyncReceiptResult.Received("sy_test", false, raw)
val visible = visibleReceiptStatus(englishContext(), receipt)
assertTrue(visible.contains("PC synchronization failed"))
assertFalse(visible.contains(raw))
assertFalse(visible.contains("Échec"))
assertEquals(raw, receipt.summary)
}
}

View file

@ -18,6 +18,9 @@ evidence and chronology.
glanceable summaries, permissions, and Android interaction rules.
- [Desktop TUI](tui.md) owns Notcurses navigation, correction, detailed
consultation, analytics, graphs, and long-term follow-up behavior.
- [Architecture](architecture.md) owns the contract for the planned local Web
sibling, including Core/API boundaries, loopback networking, shell,
Dashboard, layout, build/runtime separation, and security invariants.
## Data model

View file

@ -8,6 +8,50 @@ It is a native Kotlin/Jetpack Compose application with local SQLite persistence.
The desktop remains the canonical long-term history and analytics store.
## Interface language (`TRAINLOG_I18N_V0_1_1`)
Trainlog version 0.1.1 is synchronized with the desktop TUI as one product
version; Android does not have an independent interface version. French is the
default interface language. The only alternate language is English, selected
from **Settings → Language**. The selection updates the Compose UI immediately.
`LanguageSettingsOwner` stores exactly one language tag in its dedicated
`trainlog_presentation_settings` SharedPreferences file. An absent or invalid
value deterministically selects French. This is presentation state only: it
does not call the repository and is never stored in Android SQLite, exported,
imported, or synchronized.
Translations apply to Trainlog-owned UI text, accessibility text, and visible
number/date formatting. They never translate user exercise or catalogue names,
stable IDs, JSON/protocol literals, training data, schemas, AI proposals, MAX,
or feedback. Recognized BODY ZONES are presented from their stable IDs; an
unknown future ID deliberately retains its catalogue label rather than changing
domain data.
The current synchronization view renders local typed status and counters. It
does not translate, reinterpret, or inject a raw desktop/core/receipt/history
summary: those protocol and operational bytes remain opaque. This presentation
boundary changes no Android import, export, receipt, or history format.
The active-session editor presents its add action as a full-width semantic
success button. Existing occurrences can be moved from a dedicated right-side
drag handle; the resulting list is persisted once the gesture completes. The
permutation retains `session_id`, `entry_id`, `exercise_id`, equipment, targets,
actual form values, MAX results, and feedback ownership. A failed write reloads
the last durable ordering instead of leaving presentation and SQLite divergent.
The pointer gesture is keyed by stable `entry_id`, accumulates its full vertical
delta, and calculates a direct bounded destination from the gesture origin. A
single drag can therefore cross several positions while the handle follows the
residual pointer offset and surrounding occurrences move away. Cancellation
restores the origin permutation; completion performs one draft write.
Decimal capture fields use a decimal-capable Android keyboard and retain raw
editing text. Both `.` and `,` are accepted independently of interface language,
including intermediate forms such as `0.`, `0,`, `.3`, and `,3`. Commit replaces
`,` with `.` and parses a finite locale-independent number before applying the
existing positive/non-negative domain rule. Distance remains explicitly km; no
unit guessing or automatic correction occurs.
## Session exchange V3
Completed session occurrences persist an `entry_id`; it is never regenerated
@ -79,6 +123,21 @@ Completed Session Detail exposes **Modifier la séance**. Its bounded form is
initialized from occurrence snapshots and writes nothing before Save. Save
replaces all children in one transaction while retaining `session_id`, every
retained `entry_id`, feedback roots/revisions, and the session follow-up parent.
The form edits only facts allowed by each stored occurrence profile, including
repetitions, set load, set duration, continuous duration, distance in kilometres,
and speed in km/h. The existing V3 snapshot republishes those corrected facts,
so synchronization updates the same stable session and occurrences rather than
creating correction IDs.
The same dedicated right-side handle can reorder completed occurrences in this
form. Its list permutation remains transient until Save; Cancel or Back performs
no repository mutation. Stable `entry_id` keys keep Compose editor/focus state
with the same occurrence even when duplicate exercises exchange positions. Save
persists facts and positions together through the same atomic replacement, and
a failed Save restores the original completed order with all child ownership.
Completed decimal fields likewise retain keyed raw text until Save, so typing a
separator is not collapsed into a premature numeric value. Invalid incomplete,
malformed, non-finite, or negative final values block the transaction with a
localized error.
Set and occurrence removal require explicit confirmation; Cancel writes
nothing. Exercise Detail separately exposes **Modifier l'exercice**. A
confirmed incompatible catalogue edit controls future Android occurrences;
@ -607,8 +666,9 @@ trainlog-sync-receipt-v1.json
The receipt is matched by `request_id`.
On success Android then applies the latest PC catalog and displays the final
result.
On success Android then applies the latest PC catalog and renders the final
local typed status/counters. Raw receipt summaries remain opaque operational
bytes and are not displayed as injected cross-device text.
Before applying the PC catalog or its V2 artifacts, Android applies
`trainlog-pc-equipment-definitions-v1.json`. Thus custom definitions are known

View file

@ -2,10 +2,13 @@
## 1. System boundary
Trainlog separates capture, durable history, transport, and presentation.
Trainlog Core is the product authority and separates capture, durable history,
business logic, transport, and presentation. The TUI, Web, and Android are
sibling adapters with different product responsibilities; none owns a parallel
business model.
```text
Android capture client
Android field client
|
| local SQLite
|
@ -23,10 +26,16 @@ Android capture client
desktop SQLite directional PC artifacts
| |
v v
desktop TUI Android
Trainlog Core Android
|
+-- desktop TUI
+-- local Web adapter -> browser
```
The desktop SQLite database is the canonical long-term history.
The desktop SQLite database is the canonical local source of truth and
long-term history. Core owns data, persistence, business logic, calculations,
read models, command services, and versioned contracts. An interface must not
query tables directly to reconstruct its own business rules.
Android local SQLite is a capture store, not a synchronization format.
@ -48,14 +57,23 @@ Responsibilities:
- PC catalog application;
- synchronization request creation;
- synchronization receipt display.
- retrieval of a session prepared through Trainlog;
- future sensor-data acquisition, including BLE heart rate.
Android is not responsible for canonical long-term analytics.
Android is the field adapter for gym use, actual-work capture, feedback, and
returning observations to Trainlog. It is not responsible for canonical
long-term analytics or business truth and never accesses desktop SQLite
directly.
### Desktop core
The C17 core owns:
- data and domain rules;
- desktop SQLite persistence;
- business calculations;
- typed read models and transactional command services;
- versioned contracts;
- exercise/catalog rules;
- generated body-zone taxonomy and relation APIs;
- profile-aware session data;
@ -67,9 +85,10 @@ The C17 core owns:
### TUI
The Notcurses layer owns interaction and rendering. It is confined to the
desktop executable; persistence, synchronization, and core services have no
terminal-library dependency.
The TUI is the administration, inspection, maintenance, import/export, and
technical-tooling adapter. The Notcurses layer owns terminal interaction and
rendering. It is confined to the desktop executable; persistence,
synchronization, and core services have no terminal-library dependency.
It consumes core services for:
@ -100,6 +119,145 @@ layout is compact from 72x20, has a sidebar from 100x26, and expands it from
and run-scoped terminal planes keep Notcurses infrastructure separate from
product semantics.
The current Dashboard implementation retains one known layering violation:
`tui.c` uses SQLite and `database_internal.h` directly to build Dashboard facts
and calculations. This is a blocking debt for the Web Dashboard. Before any
Dashboard HTTP endpoint exists, characterization tests must capture the current
behavior, the query and calculations must move incrementally into a typed Core
read model, and the TUI must consume that read model. This requirement does not
authorize a global `tui.c` refactor.
### Local Web application
`TRAINLOG_WEB_V1` is a sibling adapter of the TUI and Android, never an
extension of the TUI. It is launched by either:
```text
trainlog -w
trainlog --web
```
Trainlog serves the Web application and its versioned HTTP API to a browser.
The Web owns analysis, visualization, program preparation, and session
preparation. Initial API introduction is read-only; later program and session
mutations must call explicit transactional Core command services. Generic CRUD
over SQLite tables is forbidden.
The V1 network contract is:
```text
address = 127.0.0.1
default port = 8080
explicit override = trainlog -w --port <port>
IPv6 = out of scope
```
A requested occupied port is an explicit startup failure. Trainlog never
selects another port silently and never binds to a LAN address implicitly.
The architectural boundary is:
```text
Browser
|
| HTTP / independently versioned /api/v1/
v
Web adapter
|
| typed Core services
v
Trainlog Core
|
v
desktop SQLite
```
The following dependencies are forbidden:
```text
Browser -> SQLite
React -> SQL
Web route -> database_internal.h
Web route -> duplicated TUI calculation
```
The Web API version is independent of the SQLite schema,
`TRAINLOG_FORMAT_V1`, mobile/synchronization/export formats, and the Trainlog
product version. Web evolution must not alter a frozen exchange format.
V1 preserves serialized Core access: one process-owned `TrainlogDatabase` is
opened and migrated once before HTTP listening, used without concurrent direct
SQLite-handle access, and closed by the process owner after server shutdown. A
single-threaded event loop is the preferred server architecture.
Every Web page retains one application shell:
- Header navigation: Dashboard, Analyse, Programmes, Séances, Exercices;
- Body: responsive tile-organized page content;
- Footer: user, date of the latest completed session, and all primary BODY
ZONES worked in that completed session.
The Unix login is the V1 user fallback until Trainlog owns an explicit display
name. Footer zones come only from the latest completed session; missing facts
remain unavailable rather than being invented. Header and Footer remain
present during navigation.
The sole V1 theme is Catppuccin Mocha with Lavender as its primary accent.
There is no V1 light theme. The browser is the client and Electron is outside
the architecture. React, TypeScript, Vite, and Apache ECharts are the intended
frontend stack. Node and npm are build-only dependencies; installed Trainlog
must not require either. All production assets are local, built ahead of time,
and incorporated into the `trainlog` binary from the sibling root `web/`
directory. The grid library remains open pending a dedicated spike.
The Dashboard is a canonical 12-column grid containing exactly seven V1 tiles:
1. Prochaine séance;
2. Activité;
3. Progression;
4. Dernière séance;
5. Records / MAX;
6. Répartition musculaire;
7. Cardio / récupération.
V1 supports drag and drop, horizontal and vertical resizing, tile-specific
minimum/maximum sizes, automatic reorganization, responsive projection,
persistence, and restoration of the Trainlog default. Dragging and resizing
are enabled only by the explicit `Modifier l'agencement` mode. A larger tile
may reveal more information, but tile size never changes a metric's business
definition.
The single persisted source layout is the canonical desktop 12-column layout:
```text
$XDG_CONFIG_HOME/trainlog/web/dashboard-layout-v1.json
```
with fallback:
```text
$HOME/.config/trainlog/web/dashboard-layout-v1.json
```
Its separately versioned format is interface configuration, not training data.
It does not enter business SQLite V1, `TRAINLOG_FORMAT_V1`, scientific or AI
exports, or session synchronization. Small-screen layouts are derived
responsive projections, not a second persisted truth.
Dashboard values come only from real Trainlog data or documented deterministic
Core calculations. Missing data produces an unavailable/empty state. Prochaine
séance displays only a genuinely prepared and persisted session; a generated,
AI, hidden, or unaccepted proposal is not a validated session. Cardio /
récupération remains unavailable until real relevant data exists. The exact
Activité and Progression mathematics remain open and must be specified before
their read models are implemented.
V1 security invariants are: loopback-only binding, Host validation, no
permissive CORS, foreign-origin/CSRF protection for mutations, explicit bounds
for headers, bodies, connections and timeouts, local assets only, no CDN, no
implicit telemetry, a Content Security Policy, and orderly `SIGINT`/`SIGTERM`
shutdown.
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
@ -463,20 +621,21 @@ known errors with generic placeholders.
## 11. Layering rule
```text
Notcurses / Compose rendering
|
v
application workflow
|
+-- domain model
+-- synchronization
+-- validation
|
v
persistence / MTP transport
Notcurses / Web / Compose adapters
|
v
typed Core application services
|
+-- domain model and calculations
+-- read models and command services
+-- synchronization and validation
|
v
persistence / MTP transport
```
Rendering does not own persistence rules.
Rendering does not own persistence rules, SQL, or duplicate business
calculations.
Persistence and MTP code do not depend on Notcurses rendering.

View file

@ -1,6 +1,6 @@
# Current implementation state
Snapshot date: **2026-09-15**.
Snapshot date: **2026-09-16**.
This document owns the current implemented state. Historical checkpoints and
closed incidents belong in [reviews](reviews/) and the
@ -10,11 +10,14 @@ closed incidents belong in [reviews](reviews/) and the
Android is the field companion: it captures training and body data, preserves
the active draft, receives AI proposals, triggers synchronization, and shows
quick summaries. The C17/Notcurses TUI is the detailed consultation,
correction, catalogue, analytics, graphing, and long-term tracking surface.
quick summaries. The C17/Notcurses TUI is the administration, inspection,
maintenance, import/export, and technical-tooling surface. The documented but
not yet implemented local Web sibling will own analysis, visualization, and
program/session preparation through Trainlog Core.
Android captures and summarizes. The TUI analyzes and tracks over time. The
desktop SQLite database remains the canonical long-term history.
Trainlog Core owns business truth. The desktop SQLite database remains the
canonical local source of truth and long-term history; no interface owns a
parallel implementation of its rules.
## Versions and compatibility
@ -25,16 +28,36 @@ desktop SQLite database remains the canonical long-term history.
| Android SQLite | schema v17 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.1` stable, synchronized across Android and desktop (no independent interface versions) |
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
| Training knowledge | `TRAINING_KNOWLEDGE_V1=PASS` |
| Session generator | `SESSION_GENERATOR_V1=PASS`, hidden pending V2 |
| Application shell | `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2` |
| Statistics | `STATS_V1=IMPLEMENTED` |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` |
Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
Language is device-local presentation state. Android keeps it in dedicated
SharedPreferences; desktop keeps it in the XDG configuration file
`trainlog/presentation.conf`. A successful selection updates the UI
immediately. It does not enter either SQLite database, training data, stable
IDs, schemas, exchange artifacts, synchronization protocols, AI proposals,
MAX, or feedback. User-defined exercise and catalogue names are never
automatically translated. Where a recognized stable body-zone ID has a local
presentation label, only that label is localized; unknown IDs retain their
catalogue label. Numbers, dates, and other visible formatting are owned by the
selected presentation language.
Synchronization remains a protocol boundary: core, receipt, and persisted
history summaries stay opaque operational bytes. Android and the TUI render
their current synchronization state from local typed status and counters; they
never inject a raw cross-device French summary into the visible interface. No
raw exchange or protocol format changed for interface language support.
## Storage and synchronization
- Android stores capture data in its private SQLite database.
@ -59,9 +82,12 @@ Android currently provides:
- metadata-driven SETS+REPS, SETS+DURATION, and CONTINUOUS+DURATION capture;
- actual per-set values, optional occurrence equipment, and explicit MAX rows;
- exactly one durable active session draft with resume, confirmed discard, and
atomic completion;
- completed-session consultation and correction;
- exactly one durable active session draft with identity-preserving continuous
multi-position drag reorder,
resume, confirmed discard, and atomic completion;
- completed-session consultation, transient drag reorder, and atomic factual or
order correction while retaining session/occurrence/feedback identities for
synchronization;
- exercise catalogue editing with stable `exercise_id` and referenced-profile
protection;
- supplied/custom equipment and machine-exercise metadata;
@ -70,8 +96,10 @@ Android currently provides:
follow-ups;
- pending AI proposal collection, explicit start/delete, and durable tombstones;
- 7/30/90-day, year, and all-history quick statistics;
- direct-storage snapshot publication, sync triggering, receipt display, and
companion application.
- direct-storage snapshot publication, sync triggering, locally rendered typed
receipt status/counters, and companion application.
- French-default/English-selectable presentation from **Settings → Language**,
applied immediately without changing repository or synchronization state.
Android does not own canonical long-term analytics. The V1 session generator is
implemented but intentionally hidden from normal navigation pending V2.
@ -90,10 +118,25 @@ The desktop is a strict C17 application using Notcurses. It currently provides:
- statistics derived from canonical occurrence-owned actual work, including
exact calendar-week/month buckets, performance, body, frequency, exercise,
and BODY ZONES views;
- detailed synchronization status and history through the shared engine.
- detailed locally rendered typed synchronization status/history through the
shared engine.
- French-default/English-selectable presentation from **Settings → Language**,
including selected-language formatting without changing canonical data.
Rendering does not own SQL or business rules. Statistics are read-only
projections; they are not persisted as facts.
Rendering is not permitted to own SQL or business rules. Statistics are
read-only projections; they are not persisted as facts. The current TUI
Dashboard still has a known exception: its direct SQLite/
`database_internal.h` fact projection must be characterized, extracted into a
typed Core read model, and consumed by the TUI before any Web Dashboard
endpoint is created. This is an incremental extraction, not authorization for
a global `tui.c` refactor.
## Local Web
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
browser shell, Dashboard, layout ownership, build/runtime separation, and
security invariants are now canonical. No Web server, frontend, CLI option,
HTTP endpoint, embedded asset, or layout persistence is implemented yet.
## Data semantics
@ -113,10 +156,19 @@ projections; they are not persisted as facts.
## Validation status
The durable commands are owned by [tests.md](tests.md). On 2026-09-15 this audit
passed desktop compilation, **58/58 Meson tests**, Android unit tests, Android
debug assembly, the JSON validator, and the import-contract validator. Link and
diff safety checks also pass in the final audit evidence.
The durable commands are owned by [tests.md](tests.md). On 2026-09-15 final
validation passed desktop compilation, **60/60 normal Meson tests** and
**60/60 ASan/UBSan Meson tests**, Android debug assembly and **202 Android
tests (198 passed, 4 skipped, 0 failed)**, the JSON validator, and the
import-contract validator. The source-derived TUI
`TRANSLATABLE_UI=0` check and Android resource parity also passed. Link and
diff safety checks pass in the final audit evidence.
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
formatting, layout-invariance and source-derived text-boundary tests, plus
Android resource-parity, language-owner, typed sync-presentation, and
stable-data presentation tests. A real-device/manual visual language-switch
smoke remains a manual validation; it does not change the validated status.
Hardware-dependent MTP and the final real Drive plus Android-triggered AI-draft
smoke test are not automated. The latter is why
@ -124,6 +176,8 @@ smoke test are not automated. The latter is why
## Active limitations
- `TRAINLOG_WEB_V1` is contract-only; its implementation starts with Dashboard
characterization and Core extraction, not React or HTTP infrastructure.
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
- AI proposal exchange still awaits one real Drive plus Android-triggered
bidirectional smoke test.

View file

@ -7,9 +7,10 @@ in [reviews](reviews/) and [CHANGELOG.md](../CHANGELOG.md).
## Product boundary
```text
Android captures and summarizes.
The TUI analyzes and tracks over time.
Desktop SQLite is canonical long-term history.
Trainlog Core owns business truth and canonical desktop persistence.
The TUI administers, inspects, maintains, and imports/exports.
The local Web analyzes, visualizes, and prepares programs/sessions.
Android executes in the field, captures actual work/feedback, and returns data.
```
Future work must preserve the frozen Trainlog JSON V1 contract, stable
@ -23,16 +24,45 @@ Desktop schema v18, Android schema v17, Notcurses, direct
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft
exchange and APP_SHELL_V1 retain their explicit manual validation/review gates.
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`; its local-only
architecture is canonical, but no Web code exists yet.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=REAL_DATA_BASELINE_V1
CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_CHARACTERIZATION_V1
```
Collect real body observations and training sessions through Android, sync them
to desktop, and record MAX only through explicit `max_test` results. Do not seed
fictitious user history into canonical databases.
Characterize the existing TUI Dashboard before moving its direct SQLite and
`database_internal.h` projection into a typed Core read model. Then adapt the
TUI to consume that read model. This incremental sequence is a blocking
prerequisite for a Web Dashboard endpoint and does not authorize a global
`tui.c` refactor.
## TRAINLOG_WEB_V1 implementation gate
Implementation order is fixed as:
```text
WEB_DASHBOARD_CHARACTERIZATION_V1
-> WEB_DASHBOARD_CORE_READ_MODEL_V1
-> WEB_TUI_READ_MODEL_ADOPTION_V1
-> WEB_CLI_HTTP_INFRASTRUCTURE_V1
-> WEB_FRONTEND_SHELL_V1
-> WEB_DASHBOARD_V1
```
The Web must not start with React. Characterization proves current behavior;
Core extraction establishes one business implementation; TUI adoption proves
reuse; only then may `trainlog -w`, the loopback HTTP adapter, embedded assets,
and browser UI be introduced. Initial API delivery is read-only. Later program
and session writes require explicit transactional Core command services.
`TRAINLOG_WEB_V1` preserves `/api/v1/` as an independently versioned boundary,
the `127.0.0.1:8080` default, explicit `--port`, no silent port fallback, a
single serialized Core/SQLite owner, build-only Node/npm, and no change to
business SQLite or frozen exchange formats. Dashboard Activité and Progression
mathematics remain specification gates before their read models.
## Next
@ -75,7 +105,11 @@ normal capture.
## Canonical order
```text
REAL_DATA_BASELINE_V1
WEB_DASHBOARD_CHARACTERIZATION_V1
-> WEB_DASHBOARD_CORE_READ_MODEL_V1
-> WEB_TUI_READ_MODEL_ADOPTION_V1
-> TRAINLOG_WEB_V1
-> REAL_DATA_BASELINE_V1
-> GYM_CATALOG_V1
-> EXERCISE_METADATA_V1
-> SESSION_GENERATOR_V2
@ -101,3 +135,6 @@ filesystem, exercise-name identity heuristics, fake sets for continuous work,
synthetic uniform actuals, ordinary training promoted to measured MAX,
assistance interpreted as external load, or estimates presented as direct
measurements. Incompatible exchange semantics require a new version.
Web UI configuration remains outside training data, scientific/AI exports,
session synchronization, and business SQLite. Browser, React, TUI, and Android
must not query desktop tables or duplicate Core calculations.

View file

@ -29,6 +29,23 @@ It validates:
A negative fixture passes only when Trainlog rejects it.
Android field-workflow regression coverage additionally permutes distinct and
duplicate active occurrences, checks stable IDs/equipment/targets/actuals and raw
form state, recreates the repository, injects a transactional write failure, and
finalizes the reordered draft. Completed-session tests edit reps, loads, set and
continuous durations, distance, and speed; preserve feedback/follow-up roots;
exercise atomic rollback; and inspect the corrected V3 export identity.
Completed-order coverage also exercises duplicate exercises, SETS, CONTINUOUS,
MAX, planning, equipment, feedback revisions, J+1 follow-up, Cancel/no-write,
successful position persistence, injected Save rollback, and desktop V3
reconciliation without a duplicate canonical session.
Field-input coverage maps one accumulated drag directly from index 5 to 1 and
between both list extremes, restores the origin permutation on cancellation,
and checks decimal editing/final parsing for both separators. It covers `0.3`,
`0,3`, quarter-unit values, whole values, incomplete separator states, malformed
text, non-finite spellings, negative final distance, stored numeric equality,
and locale-independent V3 JSON publication.
Semantic validation includes:
```text
@ -118,6 +135,7 @@ body_analytics
terminal_input_event_type_policy
tui_workflows
app_shell
presentation_i18n
```
The desktop suite includes APP_SHELL_V1 production-transition coverage and the
@ -150,6 +168,26 @@ The desktop executable is additionally smoke-checked in isolated tmux PTYs at
recover before a clean keyboard quit. Notcurses is verified as the executable's
direct terminal dependency with `readelf -d`.
`presentation_i18n` covers the deterministic French default, French/English
catalogue lookup, immediate in-process language switching, selected-language
decimal/date formatting even under a contrary host numeric locale, unchanged
layout at the 72x20 minimum, and local XDG configuration persistence/failure
semantics. It also proves that persisted catalogue names are not translation
keys, the source-derived `TRANSLATABLE_UI=0` boundary, and that synchronization
presentation uses local typed status/counters rather than raw opaque
core/receipt/history summaries.
Android Robolectric coverage verifies both resource locales, Settings language
ownership and dedicated SharedPreferences persistence, French fallback for an
absent/invalid preference, and localized presentation of known stable BODY ZONE
IDs without changing unknown catalogue labels or repository values. Resource
parity and typed synchronization-receipt presentation are also covered; raw
protocol/operational summaries remain opaque. Final i18n validation passed
60/60 normal desktop tests, 60/60 ASan/UBSan desktop tests, and 202 Android
tests (198 passed, 4 skipped, 0 failed), with both JSON validators passing.
Automated coverage does not replace a real device/manual visual language-switch
smoke; that remains explicit manual validation.
Notable regression coverage:
- transactional persisted-session replacement;
@ -238,6 +276,24 @@ Notable regression coverage:
Stable-release validation additionally builds the signed release variant and
checks the packaged product versions and binary linkage:
The release machine supplies all four `TRAINLOG_RELEASE_*` environment
variables, or a mode-0600 private file at
`~/.config/trainlog/release-signing.properties` with this shape:
```text
storeFile=/absolute/private/path/trainlog-release.jks
storePassword=<local secret>
keyAlias=<local alias>
keyPassword=<local secret>
```
`TRAINLOG_RELEASE_CREDENTIALS_FILE` may select another private properties file.
Environment variables take precedence over file values. The keystore and
credentials are the durable Android update identity: back them up through an
approved encrypted external mechanism, never Git or release assets. Missing
configuration makes `packageRelease` fail explicitly rather than producing an
apparently publishable unsigned APK.
```bash
meson compile -C build
meson test -C build --print-errorlogs
@ -246,10 +302,13 @@ cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleRelease
file trainlog-tui-linux-x86_64-v0.1.0
ldd trainlog-tui-linux-x86_64-v0.1.0
sha256sum trainlog-android-v0.1.0.apk \
trainlog-tui-linux-x86_64-v0.1.0
apksigner verify --verbose --print-certs \
app/build/outputs/apk/release/app-release.apk
file trainlog-tui-linux-x86_64-v<version>
ldd trainlog-tui-linux-x86_64-v<version>
sha256sum trainlog-android-v<version>.apk \
trainlog-tui-linux-x86_64-v<version>
```
An unsigned release APK is build evidence only and must not be published as the

View file

@ -115,6 +115,32 @@ Minimum terminal size:
Smaller terminals display a clear fallback instead of corrupt layout.
## Interface language (`TRAINLOG_I18N_V0_1_1`)
Trainlog version 0.1.1 is synchronized with Android as one product version;
the TUI has no independent interface version. French is the default. English is
the only alternate language and is selected through **Settings → Language**.
The active TUI rerenders immediately after a successful selection.
The choice is local presentation state in the XDG configuration file
`trainlog/presentation.conf` (under `$XDG_CONFIG_HOME`, or
`$HOME/.config` when the XDG variable is unavailable). Invalid, absent, or
unreadable preference data falls back to French. A failed persistence attempt
does not claim a committed choice. The setting never enters the desktop SQLite
database, training data, stable IDs, schemas, exchange artifacts, transport
protocols, AI proposals, MAX, or feedback.
Trainlog-owned strings and selected-language number/date formatting are
localized. Persisted/user exercise and catalogue names are byte-exact display
data, never translation keys. Stable BODY ZONE IDs select localized known-zone
labels where applicable; unknown IDs retain their catalogue label. Layout and
minimum-terminal behavior remain language-invariant.
The current synchronization screen renders local typed status and counters. It
does not translate, reinterpret, or inject Android/core/receipt/history raw
summaries: those protocol and operational bytes remain opaque. No exchange,
receipt, or synchronization-history format changed for this presentation work.
## 3. Visual rules
The TUI uses centralized semantic theme roles.
@ -174,7 +200,8 @@ Unicode-aware normalized-name uniqueness prevents duplicate logical names.
`3 Exercices` also owns Body Zones V1. Creation opens a keyboard-only manifest
selector: `p` chooses the sole primary, Space toggles secondaries, `n` selects
the explicit unclassified state and clears all relations, Enter validates and Escape
cancels without mutation. Only French display names are shown; stable IDs such
cancels without mutation. Visible names use the selected presentation language
where Trainlog owns the label; stable IDs such
as `chest` remain internal. New set-based exercises require a primary zone.
The exercise list supports `/` prefix search and `z` cycling through all

View file

@ -1,7 +1,7 @@
project(
'trainlog',
'c',
version: '0.1.0',
version: '0.1.1',
meson_version: '>=1.3.0',
default_options: [
'c_std=c17',

View file

@ -135,6 +135,29 @@ def main():
assert roundtrip["sessions"][0]["exercises"][2]["target"] == {"sets": 1, "reps": 12}
assert roundtrip["sessions"][0]["exercises"][3]["target"] is None
assert roundtrip["sessions"][1]["exercises"][0]["max_weight_kg"] == 100.0
# CONTRACT: Android completed-session reorder is a V3 correction of
# positions under the same session_id/entry_id identities, never a new
# session. Facts and occurrence owners survive the reconciliation.
reordered = copy.deepcopy(payload())
entries = reordered["sessions"][0]["exercises"]
entries.insert(0, entries.pop(3))
for position, entry in enumerate(entries):
entry["position"] = position
reordered_path = root / "reordered.json"
reordered_path.write_text(json.dumps(reordered), encoding="utf-8")
reconciled = run([sys.executable, str(IMPORTER), str(reordered_path), "--database", str(db)])
assert "sessions_reconciled=1" in reconciled.stdout
with sqlite3.connect(db) as con:
assert con.execute("SELECT COUNT(*) FROM sessions WHERE session_id='se_v3'").fetchone()[0] == 1
assert con.execute(
"SELECT entry_id FROM session_exercises WHERE session_row_id="
"(SELECT id FROM sessions WHERE session_id='se_v3') ORDER BY position"
).fetchall() == [("sxe_walk",), ("sxe_reps",), ("sxe_duration",), ("sxe_unweighted",)]
assert con.execute(
"SELECT COUNT(DISTINCT entry_id) FROM session_exercises WHERE session_row_id="
"(SELECT id FROM sessions WHERE session_id='se_v3')"
).fetchone()[0] == 4
legacy = copy.deepcopy(payload()); legacy["version"] = 2
for session in legacy["sessions"]:
for entry in session["exercises"]:

View file

@ -0,0 +1,76 @@
/*
* Trainlog local presentation preferences and translations.
*/
#ifndef TRAINLOG_PRESENTATION_H
#define TRAINLOG_PRESENTATION_H
#include <stdbool.h>
#include <stddef.h>
#include <stdarg.h>
typedef enum TrainlogPresentationLanguage {
TRAINLOG_PRESENTATION_FRENCH = 0,
TRAINLOG_PRESENTATION_ENGLISH
} TrainlogPresentationLanguage;
typedef enum TrainlogPresentationSaveResult {
TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED = 0,
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABLE,
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABILITY_UNCERTAIN
} TrainlogPresentationSaveResult;
/* WHY: language is a local presentation choice, not workout data.
* CONTRACT: init always selects French. load accepts only the versioned v1
* file and leaves French selected when it is absent or invalid. Neither
* function reads or mutates the canonical database.
* INVARIANT: the only runtime values are French and English. */
void trainlog_presentation_init(void);
bool trainlog_presentation_load(void);
TrainlogPresentationSaveResult trainlog_presentation_save(void);
void trainlog_presentation_resolve_save(
TrainlogPresentationLanguage previous_language,
TrainlogPresentationSaveResult result);
TrainlogPresentationLanguage trainlog_presentation_language(void);
bool trainlog_presentation_set_language(TrainlogPresentationLanguage language);
const char *trainlog_presentation_language_code(
TrainlogPresentationLanguage language);
const char *trainlog_presentation_language_name(
TrainlogPresentationLanguage language);
/* WHY: stable keys keep screen code independent of wording and language.
* CONTRACT: returned storage is static and must not be freed. Unknown/NULL
* keys return a visible deterministic fallback and never user/catalog data.
* INVARIANT: translations have no side effects. */
const char *trainlog_presentation_text(const char *key);
/* Transitional centralized lookup for presentation format strings. It exists
* so large legacy renderers can cross one translation boundary without
* language conditionals; new UI must use stable keys above. Unknown strings
* are returned byte-for-byte, preserving user and protocol literals. */
const char *trainlog_presentation_source(const char *source);
const char *trainlog_presentation_month_label(const char *source,
char *output, size_t output_size);
/* Format one translated presentation string into caller-owned storage. This
* is the serialization-safe adapter used by strict renderer APIs: format
* placeholders are identical in both catalog columns. */
int trainlog_presentation_vformat(char *output, size_t capacity,
const char *source, va_list arguments);
int trainlog_presentation_format(char *output, size_t capacity,
const char *source, ...);
/* Locale-neutral formatting for editable numeric seed text. This is not
* presentation: both UI languages and every host locale receive ASCII '.'. */
int trainlog_presentation_format_input(char *output, size_t capacity,
const char *format, ...) __attribute__((format(printf, 3, 4)));
int trainlog_presentation_format_key(char *output, size_t capacity,
const char *key, ...);
/* Format YYYY-MM-DD as app-owned compact presentation text. Stored timestamp
* bytes are borrowed and never changed; French uses DD/MM, English MM/DD. */
bool trainlog_presentation_compact_date(const char *iso_date, bool with_year,
char *output, size_t capacity);
/* Exposed for focused persistence tests; normal callers use load/save. */
bool trainlog_presentation_config_path(char *output, size_t capacity);
#endif

View file

@ -141,6 +141,7 @@ trainlog_core_dep = declare_dependency(
trainlog_tui_sources = files(
'src/main.c',
'src/app_shell.c',
'src/presentation.c',
'src/chart.c',
'src/sync_screen_action.c',
'src/terminal.c',
@ -598,6 +599,7 @@ test_sync_screen_action = executable(
'test_sync_screen_action',
'tests/test_sync_screen_action.c',
'src/sync_screen_action.c',
'src/presentation.c',
dependencies: [trainlog_core_dep, notcurses_dep],
c_args: strict_c_args,
)
@ -654,6 +656,7 @@ test_tui_workflows = executable(
'tests/test_tui_workflows.c',
'src/app_shell.c',
'src/chart.c',
'src/presentation.c',
'src/theme.c',
'src/sync_screen_action.c',
dependencies: trainlog_core_dep,
@ -666,6 +669,7 @@ test_chart = executable(
'test_chart',
'tests/test_chart.c',
'src/chart.c',
'src/presentation.c',
include_directories: trainlog_include,
dependencies: m_dep,
c_args: strict_c_args,
@ -736,8 +740,28 @@ test_app_shell = executable(
'test_app_shell',
'tests/test_app_shell.c',
'src/app_shell.c',
'src/presentation.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('app_shell', test_app_shell)
test_presentation = executable(
'test_presentation',
'tests/test_presentation.c',
'src/presentation.c',
'src/app_shell.c',
'src/sync_screen_action.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('presentation_i18n', test_presentation)
test(
'presentation_catalog_completeness',
python3,
args: [meson.project_source_root() / 'tui/tests/test_presentation_catalog.py'],
workdir: meson.project_source_root(),
)

View file

@ -11,6 +11,7 @@
#include <utf8proc.h>
#include "trainlog/terminal.h"
#include "trainlog/presentation.h"
static void copy_id(char output[TRAINLOG_SHELL_STABLE_ID_CAPACITY],
const char *value)
@ -501,37 +502,24 @@ TrainlogFormResult trainlog_form_handle(TrainlogFormField *field, int key)
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_TRAINING: return "Statistiques / Entraînement";
case TRAINLOG_ROUTE_STATS_EXERCISE: return "Statistiques / Par exercice";
case TRAINLOG_ROUTE_STATS_EXERCISE_DETAIL: return "Statistiques / Exercice";
case TRAINLOG_ROUTE_STATS_ZONES: return "Statistiques / Zones";
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";
}
static const char *const keys[TRAINLOG_ROUTE_COUNT] = {
"nav.home", "nav.sessions", "route.session.current",
"route.session.generator", "route.session.manual",
"route.session.completed", "route.session.detail",
"route.exercise.catalog", "route.exercise.detail",
"route.exercise.knowledge", "route.exercise.performance",
"route.exercise.max", "route.equipment.catalog",
"route.equipment.detail", "nav.statistics", "route.stats.training",
"route.stats.exercise", "route.stats.exercise.detail",
"route.stats.zones", "route.body", "route.body.detail",
"route.body.metric", "route.body.trends", "route.body.global",
"route.body.analytics", "route.max", "nav.sync", "nav.settings"
};
/* INVARIANT: enum order is the stable route identity; wording is resolved
* only at this presentation boundary. */
if (route >= TRAINLOG_ROUTE_HOME && route < TRAINLOG_ROUTE_COUNT)
return trainlog_presentation_text(keys[route]);
return "Trainlog";
}
TrainlogAppRoute trainlog_route_section(TrainlogAppRoute route)

View file

@ -9,10 +9,26 @@
#include <limits.h>
#include <math.h>
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "trainlog/presentation.h"
static void chart_surface_printf(TrainlogSurface *surface, int row, int column,
const char *format, ...)
{
va_list arguments;
char value[512];
va_start(arguments, format);
(void)trainlog_presentation_vformat(value, sizeof(value), format, arguments);
va_end(arguments);
trainlog_surface_printf(surface, row, column, "%s", value);
}
#define trainlog_surface_printf chart_surface_printf
#define CHART_LABEL_WIDTH 12
#define CHART_MIN_WIDTH 24
#define CHART_MIN_HEIGHT 6
@ -160,21 +176,18 @@ static void short_date(const TrainlogChartPoint *point, char output[6])
{
time_t seconds;
struct tm value;
char iso_date[11];
if (point != NULL && point->timestamp_label != NULL &&
strlen(point->timestamp_label) >= 10U &&
point->timestamp_label[4] == '-' && point->timestamp_label[7] == '-') {
output[0] = point->timestamp_label[8];
output[1] = point->timestamp_label[9];
output[2] = '/';
output[3] = point->timestamp_label[5];
output[4] = point->timestamp_label[6];
output[5] = '\0';
return;
if (trainlog_presentation_compact_date(point->timestamp_label, false,
output, 6U)) return;
}
seconds = point != NULL ? (time_t)point->timestamp : (time_t)-1;
if (point == NULL || (int64_t)seconds != point->timestamp ||
gmtime_r(&seconds, &value) == NULL ||
strftime(output, 6U, "%d/%m", &value) != 5U)
strftime(iso_date, sizeof(iso_date), "%Y-%m-%d", &value) != 10U ||
!trainlog_presentation_compact_date(iso_date, false, output, 6U))
(void)snprintf(output, 6U, "--/--");
}
@ -184,12 +197,13 @@ static void render_empty(TrainlogSurface *surface, TrainlogChartRect rect,
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
trainlog_surface_printf(surface, rect.top, rect.left, "%s",
title != NULL ? title : "ÉVOLUTION DANS LE TEMPS");
title != NULL ? trainlog_presentation_source(title)
: trainlog_presentation_text("chart.timeline"));
trainlog_surface_set_role(surface, TRAINLOG_COLOR_MUTED,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_NORMAL);
if (rect.height > 2)
trainlog_surface_printf(surface, rect.top + 2, rect.left,
"Aucune donnée disponible.");
"%s", trainlog_presentation_text("empty.no_data"));
}
void trainlog_chart_render(TrainlogSurface *surface, TrainlogChartRect rect,
@ -229,7 +243,8 @@ void trainlog_chart_render_line(TrainlogSurface *surface, TrainlogChartRect rect
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
trainlog_surface_printf(surface, rect.top, rect.left, "%s",
title != NULL ? title : "ÉVOLUTION DANS LE TEMPS");
title != NULL ? trainlog_presentation_source(title)
: trainlog_presentation_text("chart.timeline"));
if (rect.width < CHART_MIN_WIDTH || rect.height < CHART_MIN_HEIGHT) {
trainlog_surface_set_role(surface, TRAINLOG_COLOR_ACCENT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
@ -359,10 +374,12 @@ void trainlog_chart_render_bars(TrainlogSurface *surface, TrainlogChartRect rect
trainlog_surface_set_role(surface, TRAINLOG_COLOR_DEFAULT,
TRAINLOG_RGB_BASE, TRAINLOG_TEXT_BOLD);
trainlog_surface_printf(surface, rect.top, rect.left, "%s",
title != NULL ? title : "TOTAUX PAR PÉRIODE");
title != NULL ? trainlog_presentation_source(title)
: trainlog_presentation_text("chart.totals"));
if (rect.width < CHART_MIN_WIDTH || rect.height < CHART_MIN_HEIGHT) {
trainlog_surface_printf(surface, rect.top + 2, rect.left,
"%zu périodes · %.2f %s", count, points[count - 1U].value, safe_unit);
"%zu périodes · %.2f %s", count,
points[count - 1U].value, safe_unit);
return;
}
plot_left = rect.left + CHART_LABEL_WIDTH;
@ -410,9 +427,13 @@ void trainlog_chart_render_bars(TrainlogSurface *surface, TrainlogChartRect rect
if (column < plot_left) column = plot_left;
if (column > plot_left + plot_width - 11)
column = plot_left + plot_width - 11;
if (label != NULL)
if (label != NULL) {
char localized_label[32];
trainlog_surface_printf(surface,
plot_top + plot_height + 1, column, "%.11s", label);
plot_top + plot_height + 1, column, "%.11s",
trainlog_presentation_month_label(label, localized_label,
sizeof(localized_label)));
}
}
} else {
short_date(&first_domain, first); short_date(&last_domain, last);

1233
tui/src/presentation.c Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@
#include "trainlog/sync_screen_action.h"
#include "trainlog/terminal.h"
#include "trainlog/presentation.h"
static TrainlogSyncScreenAction sync_screen_action(
TrainlogSyncScreenEffect effect,
@ -143,14 +144,14 @@ const char *trainlog_sync_screen_confirmation(
)
{
if (direction == TRAINLOG_SYNC_ANDROID_TO_PC) {
return "Importer Android vers le PC maintenant ?";
return trainlog_presentation_text("sync.confirm.import");
}
if (direction == TRAINLOG_SYNC_PC_TO_ANDROID) {
return "Publier le catalogue du PC vers Android maintenant ?";
return trainlog_presentation_text("sync.confirm.publish");
}
return "Synchroniser Android et PC dans les deux sens maintenant ?";
return trainlog_presentation_text("sync.confirm.both");
}
const char *trainlog_sync_screen_footer(
@ -158,8 +159,8 @@ const char *trainlog_sync_screen_footer(
)
{
if (terminal_columns >= 100) {
return "s Synchroniser maintenant (PC↔Android) r Actualiser appareil Échap retour";
return trainlog_presentation_text("sync.footer.wide");
}
return "s Synchroniser PC↔Android r Actualiser Échap retour";
return trainlog_presentation_text("sync.footer.compact");
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,226 @@
#include <assert.h>
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "trainlog/app_shell.h"
#include "trainlog/presentation.h"
#include "trainlog/sync_screen_action.h"
static void test_default_and_catalog(void)
{
TrainlogShellLayout french;
TrainlogShellLayout english;
char date[9];
char dynamic[512];
trainlog_presentation_init();
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH);
assert(strcmp(trainlog_presentation_text("nav.settings"), "Paramètres") == 0);
assert(strcmp(trainlog_route_title(TRAINLOG_ROUTE_STATS_TRAINING),
"Statistiques / Entraînement") == 0);
assert(trainlog_presentation_compact_date("2026-09-15", true,
date, sizeof(date)));
assert(strcmp(date, "15/09/26") == 0);
trainlog_shell_layout_compute(72, 20, &french);
assert(french.usable);
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
assert(strcmp(trainlog_presentation_text("nav.settings"), "Settings") == 0);
assert(strcmp(trainlog_route_title(TRAINLOG_ROUTE_STATS_TRAINING),
"Statistics / Training") == 0);
assert(strcmp(trainlog_sync_screen_confirmation(TRAINLOG_SYNC_BIDIRECTIONAL),
"Synchronize Android and PC in both directions now?") == 0);
assert(strstr(trainlog_sync_screen_footer(100), "Refresh device") != NULL);
assert(strcmp(trainlog_presentation_source("Aucune donnée disponible."),
"No data available.") == 0);
assert(trainlog_presentation_format_key(dynamic, sizeof(dynamic),
"msg.actual.required", "Squat Français") > 0);
assert(strcmp(dynamic, "Enter an actual result for Squat Français.") == 0);
assert(trainlog_presentation_format_key(dynamic, sizeof(dynamic),
"sync.history.legacy", "2026-09-15T10:30:00Z",
trainlog_presentation_text("sync.state.success")) > 0);
assert(strstr(dynamic, "Historical PC↔Android synchronization") != NULL);
assert(strstr(dynamic, "Timestamp: 2026-09-15T10:30:00Z") != NULL);
assert(strstr(dynamic, "Status: successful") != NULL);
/* Representative generator/profile/body/MAX/search/merge/feedback UI
* formats must render English while preserving caller-provided values. */
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
"Objectif : %s", "Force") > 0);
assert(strcmp(dynamic, "Goal: Force") == 0);
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
"%zu exercice(s) · cible %d min · estimation %d min", (size_t)3,
45, 42) > 0);
assert(strcmp(dynamic, "3 exercise(s) · target 45 min · estimate 42 min") == 0);
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
"Tour de taille %.1f cm · variation %+.1f cm", 81.5, -1.0) > 0);
assert(strcmp(dynamic, "Waist circumference 81.5 cm · change -1.0 cm") == 0);
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
"Historique des MAX") > 0);
assert(strcmp(dynamic, "MAX history") == 0);
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
"Recherche cible : %s_", "Squat") > 0);
assert(strcmp(dynamic, "Search target: Squat_") == 0);
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
"Contexte : %s · %s · %zu point%s", "Squat", "MAX", (size_t)1,
"") > 0);
assert(strcmp(dynamic, "Context: Squat · MAX · 1 point") == 0);
assert(trainlog_presentation_compact_date("2026-09-15", true,
date, sizeof(date)));
assert(strcmp(date, "09/15/26") == 0);
/* Persisted and catalog names are not keys and must remain byte-exact. */
assert(strcmp(trainlog_presentation_source("Développé incliné — Alice"),
"Développé incliné — Alice") == 0);
trainlog_shell_layout_compute(72, 20, &english);
assert(memcmp(&french, &english, sizeof(french)) == 0);
assert(!trainlog_presentation_set_language(
(TrainlogPresentationLanguage)99));
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH));
/* Decimal localization is scoped to the float conversion: persisted/user
* text is not parsed or mutated merely because it contains digits. */
assert(trainlog_presentation_format(dynamic, sizeof(dynamic),
"Valeur %.1f · modèle %s", 12.5, "Model 1.2") > 0);
assert(strcmp(dynamic, "Valeur 12,5 · modèle Model 1.2") == 0);
assert(strcmp(trainlog_presentation_source("Pendant la séance"),
"Pendant la séance") == 0);
assert(strcmp(trainlog_presentation_month_label("févr. 2026", dynamic,
sizeof(dynamic)), "févr. 2026") == 0);
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
assert(strcmp(trainlog_presentation_source("Pendant la séance"),
"During session") == 0);
assert(strcmp(trainlog_presentation_month_label("févr. 2026", dynamic,
sizeof(dynamic)), "Feb 2026") == 0);
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH));
assert(trainlog_presentation_format_key(dynamic, sizeof(dynamic),
"msg.actual.required", "Squat Français") > 0);
assert(strcmp(dynamic, "Saisissez un résultat réel pour Squat Français.") == 0);
}
static void test_selected_language_owns_decimals(void)
{
char output[128];
char *original = setlocale(LC_NUMERIC, NULL);
char saved[128];
if (original != NULL) (void)snprintf(saved, sizeof(saved), "%s", original);
else saved[0] = '\0';
trainlog_presentation_init();
assert(trainlog_presentation_format(output, sizeof(output),
"Valeur %.2f · delta %+.1f", 12.5, 0.25) > 0);
assert(strcmp(output, "Valeur 12,50 · delta +0,2") == 0);
assert(trainlog_presentation_format(output, sizeof(output),
"%+.1f%%", 3.25) > 0);
assert(strcmp(output, "+3,2%") == 0);
assert(trainlog_presentation_format(output, sizeof(output),
"%2$*3$.*4$f · %1$s · %%", "Model 1.2", 12.5, 7, 2) > 0);
assert(strcmp(output, " 12,50 · Model 1.2 · %") == 0);
assert(trainlog_presentation_format(output, sizeof(output),
"%Lf · %zu", (long double)1.25L, (size_t)4) > 0);
assert(strcmp(output, "1,250000 · 4") == 0);
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
assert(trainlog_presentation_format(output, sizeof(output),
"Value %.2f", 12.5) > 0);
assert(strcmp(output, "Value 12.50") == 0);
/* Contrary-locale integration proof when the host supplies French. The
* assertions above remain the locale-independent fallback proof. */
if (setlocale(LC_NUMERIC, "fr_FR.UTF-8") != NULL ||
setlocale(LC_NUMERIC, "fr_FR.utf8") != NULL) {
assert(trainlog_presentation_format(output, sizeof(output),
"Value %.2f", 12.5) > 0);
assert(strcmp(output, "Value 12.50") == 0);
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH));
assert(trainlog_presentation_format(output, sizeof(output),
"Valeur %.2f", 12.5) > 0);
assert(strcmp(output, "Valeur 12,50") == 0);
}
if (saved[0] != '\0') assert(setlocale(LC_NUMERIC, saved) != NULL);
}
static void test_persistence(void)
{
char root_template[] = "/tmp/trainlog-presentation-XXXXXX";
char *root = mkdtemp(root_template);
char path[4096];
char directory[4096];
char invalid[] = "version=1\nlanguage=de\n";
FILE *file;
assert(root != NULL);
assert(setenv("XDG_CONFIG_HOME", root, 1) == 0);
assert(trainlog_presentation_config_path(path, sizeof(path)));
assert(strstr(path, "/trainlog/presentation.conf") != NULL);
assert(unsetenv("XDG_CONFIG_HOME") == 0);
assert(setenv("HOME", root, 1) == 0);
assert(trainlog_presentation_config_path(path, sizeof(path)));
assert(strstr(path, "/.config/trainlog/presentation.conf") != NULL);
assert(setenv("XDG_CONFIG_HOME", root, 1) == 0);
assert(trainlog_presentation_config_path(path, sizeof(path)));
trainlog_presentation_init();
assert(trainlog_presentation_load());
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH);
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
(void)snprintf(directory, sizeof(directory), "%s/trainlog", root);
assert(mkdir(directory, 0700) == 0);
{
char stale[4128];
FILE *stale_file;
(void)snprintf(stale, sizeof(stale), "%s.tmp.%ld", path,
(long)getpid());
stale_file = fopen(stale, "wb");
assert(stale_file != NULL);
assert(fclose(stale_file) == 0);
assert(trainlog_presentation_save() ==
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABLE);
assert(unlink(stale) == 0);
}
trainlog_presentation_init();
assert(trainlog_presentation_load());
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_ENGLISH);
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_FRENCH));
assert(trainlog_presentation_save() ==
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABLE);
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
assert(trainlog_presentation_load());
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH);
file = fopen(path, "wb");
assert(file != NULL);
assert(fwrite(invalid, 1U, strlen(invalid), file) == strlen(invalid));
assert(fclose(file) == 0);
assert(!trainlog_presentation_load());
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH);
assert(unlink(path) == 0);
(void)snprintf(directory, sizeof(directory), "%s/trainlog", root);
assert(rmdir(directory) == 0);
file = fopen(directory, "wb");
assert(file != NULL);
assert(fclose(file) == 0);
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
assert(trainlog_presentation_save() ==
TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED);
trainlog_presentation_resolve_save(TRAINLOG_PRESENTATION_FRENCH,
TRAINLOG_PRESENTATION_SAVE_NOT_COMMITTED);
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_FRENCH);
assert(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
trainlog_presentation_resolve_save(TRAINLOG_PRESENTATION_FRENCH,
TRAINLOG_PRESENTATION_SAVE_COMMITTED_DURABILITY_UNCERTAIN);
assert(trainlog_presentation_language() == TRAINLOG_PRESENTATION_ENGLISH);
assert(unlink(directory) == 0);
assert(rmdir(root) == 0);
}
int main(void)
{
test_default_and_catalog();
test_selected_language_owns_decimals();
test_persistence();
puts("presentation tests passed");
return 0;
}

View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Source-derived, default-fail catalogue audit for active TUI renderers."""
import pathlib, re, sys
ROOT = pathlib.Path(__file__).resolve().parents[2]
# WHY: implementation-directory membership is not presentation reachability;
# scanning core SQL, synchronization codecs, and catalogs confuses durable raw
# diagnostics/identifiers with renderer copy.
# CONTRACT: this reviewed allowlist contains every active renderer plus the two
# known presentation-producing helpers. A new renderer must be added here and
# defaults to failure as soon as it contains uncatalogued French copy.
# INVARIANT: database/sync/sync_history/catalog implementations are not UI-copy
# owners and cannot become implicit exceptions through this audit.
RENDERER_SOURCES = (
"tui/src/tui.c",
"tui/src/chart.c",
"tui/src/app_shell.c",
"tui/src/sync_screen_action.c",
)
PRESENTATION_PRODUCERS = (
"tui/src/timeutil.c",
"tui/src/statistics.c",
)
SOURCES = RENDERER_SOURCES + PRESENTATION_PRODUCERS
assert not {"tui/src/database.c", "tui/src/sync.c", "tui/src/sync_history.c",
"tui/src/catalog.c"}.intersection(SOURCES)
RENDERER_MARKER = re.compile(
r"trainlog_(?:surface|terminal)_(?:printf|put|draw|set)|"
r"trainlog_presentation_(?:text|source|format)"
)
INFRASTRUCTURE = {"tui/src/presentation.c", "tui/src/terminal.c"}
discovered_renderers = {
str(path.relative_to(ROOT))
for path in (ROOT / "tui/src").glob("*.c")
if RENDERER_MARKER.search(path.read_text(encoding="utf-8"))
} - INFRASTRUCTURE
unreviewed_renderers = discovered_renderers - set(RENDERER_SOURCES)
if unreviewed_renderers:
sys.stderr.write("UNREVIEWED presentation renderer(s):\n" +
"\n".join(f" {path}" for path in sorted(unreviewed_renderers)) + "\n")
sys.exit(1)
P = (ROOT / "tui/src/presentation.c").read_text(encoding="utf-8")
CATALOG = set(re.findall(r'\{\s*"(?:[^"\\]|\\.)*"\s*,\s*"((?:[^"\\]|\\.)*)"\s*,\s*"', P))
LIT = re.compile(r'"((?:[^"\\]|\\.)*)"')
FR = re.compile(r'[À-ÿ]|\b(?:aucun|aucune|abandonner|annuler|choisir|durée|entrée|exercice|fusion|historique|impossible|mesure|objectif|profil|recherche|séance|synchron|taille|zone|série|charge)\b', re.I)
# Exact source-bound exceptions, never pattern exemptions. Each is a
# non-copy token: punctuation/layout, a protocol token, or an internal key.
EXCLUDED = {
"tui/src/tui.c": {
1083:"—",1495:"— kg",3970:"J−%zu",3973:"M−%zu",3976:"P−%zu",4759:"msg.zone.required",5204:"statistics.zone.previous",5206:"statistics.zone.next",5270:"generator.warning.zone",5612:"—",5655:"— ",5660:"— ",5678:"— ",5693:"—",5696:"—",5820:"—",5823:"—",5826:"—",5881:"—",6312:"%-22s —",6612:"▶",6615:"▶",6643:"▶",6652:"▶",6665:"▶",6668:"▶",6692:"▶",6697:"▶",6888:"%02d/%02d … %02d/%02d",6930:"— ",7040:"PC↔Android",7086:"▶",7310:"%s · %s",7318:"—",7319:"—",7395:"—",7398:"—",7567:"—",7595:"—",7597:"—",7599:"—",7717:"aucun",7719:"aucun",7884:"—",7935:"—",8065:" · ↑",8066:" · ↓"},
"tui/src/chart.c": {254:"◆ %.2f %s"},
"tui/src/app_shell.c": {317:"…"},
"tui/src/sync_screen_action.c": {132:"Android→PC",136:"PC→Android",139:"PC↔Android"},
"tui/src/statistics.c": {230:("août", "déc.")},
}
def candidates(text, path):
"""Independent source scanner: adding uncatalogued French copy fails."""
out=[]
for line_no,line in enumerate(text.splitlines(),1):
for match in LIT.finditer(line):
value=match.group(1)
if FR.search(value): out.append((path,line_no,value))
return out
# Non-tautology proof: this never appears in the catalogue/manifest.
probe=candidates('trainlog_surface_printf(s, 1, 1, "Bonjour séance");','fixture.c')
assert probe == [('fixture.c',1,'Bonjour séance')]
assert probe[0][2] not in CATALOG
assert RENDERER_MARKER.search('trainlog_surface_printf(s, 1, 1, "new copy")')
unknown=[]; used=set()
for path in SOURCES:
for candidate in candidates((ROOT/path).read_text(encoding='utf-8'),path):
_,line,value=candidate
if value in CATALOG: continue
allowed = EXCLUDED.get(path,{}).get(line)
if allowed == value or isinstance(allowed, tuple) and value in allowed:
used.add(candidate); continue
unknown.append(candidate)
if unknown:
for kind, rows in (("TRANSLATABLE_UI catalogue omissions",unknown),):
if rows: sys.stderr.write(kind+':\n'+'\n'.join(' %s:%s: %s'%r for r in rows)+'\n')
sys.exit(1)
print('TRANSLATABLE_UI=0 unexplained; source-derived exclusions:',len(used))

View file

@ -835,7 +835,9 @@ static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger,
sync_last_direction = direction;
CHECK(sync_running_app != NULL && sync_running_app->sync_controller.running);
CHECK(strstr(sync_running_app->terminal->output,
"Synchronisation en cours") != NULL);
"Synchronisation en cours") != NULL ||
strstr(sync_running_app->terminal->output,
"Synchronization in progress") != NULL);
/* A re-entrant launch attempt is consumed while the sole run is active. */
CHECK(app_shell_dispatch_sync(sync_running_app, 's'));
CHECK(app_shell_dispatch_sync(sync_running_app, TRAINLOG_KEY_ENTER));
@ -843,13 +845,23 @@ static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger,
(void)memset(output, 0, sizeof(*output));
output->success = run_number != 2;
output->direction = direction;
output->exercises_imported = 2U;
output->exercises_reconciled = 3U;
output->exercises_skipped = 4U;
output->sessions_imported = 5U;
output->sessions_skipped = 6U;
output->body_imported = 7U;
output->body_skipped = 8U;
output->equipment_definitions_imported = 9U;
output->equipment_definitions_skipped = 10U;
output->catalog_published = 11U;
(void)snprintf(output->sync_id, sizeof(output->sync_id),
"sy_00000000-0000-4000-8000-%012d", run_number);
(void)snprintf(output->summary, sizeof(output->summary), "%s",
output->success ? "sync test réussie" : "sync test échouée");
output->success ? "RÉSUMÉ BRUT succès" : "RÉSUMÉ BRUT échec");
if (!output->success)
(void)snprintf(output->error, sizeof(output->error), "%s",
"Appareil déconnecté pendant la synchronisation.");
"DIAGNOSTIC BRUT appareil déconnecté.");
CHECK(snprintf(directory, sizeof(directory), "%s/trainlog",
sync_journal_root) > 0);
CHECK(mkdir(directory, 0700) == 0 || errno == EEXIST);
@ -864,6 +876,53 @@ static TrainlogStatus mock_sync_run(TrainlogSyncTrigger trigger,
return output->success ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR;
}
static bool test_sync_result_presenter_maps_typed_statuses_in_both_languages(void)
{
static const struct {
TrainlogStatus status;
const char *fr;
const char *en;
} cases[] = {
{TRAINLOG_STATUS_CONFLICT, "une autre exécution est active",
"another run is active"},
{TRAINLOG_STATUS_NOT_FOUND, "aucun appareil MTP Trainlog détecté",
"no Trainlog MTP device detected"},
{TRAINLOG_STATUS_INVALID_ARGUMENT, "données ou demande invalides",
"invalid data or request"},
{TRAINLOG_STATUS_DATABASE_ERROR, "erreur de base de données locale",
"local database error"},
{TRAINLOG_STATUS_SCHEMA_UNSUPPORTED, "version de données non prise en charge",
"unsupported data version"},
{TRAINLOG_STATUS_SYSTEM_ERROR, "erreur d’accès appareil ou fichier",
"device or file access error"},
{TRAINLOG_STATUS_OK, "sans rapport de réussite",
"without a success report"},
};
TrainlogSyncController sync;
char presented[768];
size_t index;
(void)memset(&sync, 0, sizeof(sync));
(void)snprintf(sync.report.summary, sizeof(sync.report.summary),
"%s", "RÉSUMÉ BRUT interdit");
(void)snprintf(sync.report.error, sizeof(sync.report.error),
"%s", "DIAGNOSTIC BRUT interdit");
for (index = 0U; index < sizeof(cases) / sizeof(cases[0]); ++index) {
sync.latest_status = cases[index].status;
trainlog_presentation_init();
sync_controller_present_result(&sync, presented, sizeof(presented));
CHECK(strstr(presented, cases[index].fr) != NULL);
CHECK(strstr(presented, "BRUT") == NULL);
CHECK(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
sync_controller_present_result(&sync, presented, sizeof(presented));
CHECK(strstr(presented, cases[index].en) != NULL);
CHECK(strstr(presented, "BRUT") == NULL);
}
CHECK(strcmp(sync.report.summary, "RÉSUMÉ BRUT interdit") == 0);
CHECK(strcmp(sync.report.error, "DIAGNOSTIC BRUT interdit") == 0);
trainlog_presentation_init();
return true;
}
static bool test_shell_body_controller_preserves_identity_and_failed_edit(void)
{
TrainlogDatabase *database = NULL;
@ -993,7 +1052,18 @@ static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void)
CHECK(app.sync_controller.history_count == 1U);
terminal.output_used = 0U; terminal.output[0] = '\0';
app_shell_render_sync(&app);
CHECK(strstr(terminal.output, "sync test réussie") != NULL);
CHECK(strstr(terminal.output, "Synchronisation réussie") != NULL);
CHECK(strstr(terminal.output, "exercices +2, réconciliés 3, présents 4") != NULL);
CHECK(strstr(terminal.output, "catalogue publié 11") != NULL);
CHECK(strstr(terminal.output, "RÉSUMÉ BRUT") == NULL);
CHECK(strcmp(app.sync_controller.report.summary, "RÉSUMÉ BRUT succès") == 0);
CHECK(trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH));
terminal.output_used = 0U; terminal.output[0] = '\0';
app_shell_render_sync(&app);
CHECK(strstr(terminal.output, "Synchronization successful") != NULL);
CHECK(strstr(terminal.output, "exercises +2, reconciled 3, present 4") != NULL);
CHECK(strstr(terminal.output, "catalog published 11") != NULL);
CHECK(strstr(terminal.output, "RÉSUMÉ BRUT") == NULL);
/* Repeating the action creates the next legitimate journal event. */
app_shell_dispatch(&app, 's');
@ -1002,11 +1072,17 @@ static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void)
CHECK(sync_run_calls == 2);
CHECK(sync_last_direction == TRAINLOG_SYNC_BIDIRECTIONAL);
CHECK(!app.sync_controller.report.success);
CHECK(app.sync_controller.latest_status == TRAINLOG_STATUS_SYSTEM_ERROR);
CHECK(app.sync_controller.history_count == 2U);
terminal.output_used = 0U; terminal.output[0] = '\0';
app_shell_render_sync(&app);
CHECK(strstr(terminal.output,
"Appareil déconnecté pendant la synchronisation.") != NULL);
"Synchronization stopped: device or file access error.") != NULL);
CHECK(strstr(terminal.output, "DIAGNOSTIC BRUT") == NULL);
CHECK(strstr(terminal.output, "RÉSUMÉ BRUT") == NULL);
CHECK(strcmp(app.sync_controller.report.error,
"DIAGNOSTIC BRUT appareil déconnecté.") == 0);
CHECK(strcmp(app.sync_controller.report.summary, "RÉSUMÉ BRUT échec") == 0);
CHECK(app_shell_dispatch_sync(&app, 'r'));
CHECK(sync_probe_calls == 3 && sync_run_calls == 2);
if (saved_data_home != NULL) {
@ -1025,6 +1101,7 @@ static bool test_shell_sync_f7_runs_bidirectional_and_refreshes_history(void)
CHECK(rmdir(temporary) == 0);
}
sync_running_app = NULL;
trainlog_presentation_init();
trainlog_database_close(database);
return true;
}
@ -1110,6 +1187,21 @@ static bool test_shell_sync_history_neutralizes_legacy_directions(void)
app.sync_controller.showing_detail = false;
}
trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH);
app.sync_controller.selected = 0U;
terminal.output_used = 0U;
terminal.output[0] = '\0';
CHECK(app_shell_dispatch_sync(&app, TRAINLOG_KEY_ENTER));
app_shell_render_sync(&app);
CHECK(strstr(terminal.output,
"Historical PC↔Android synchronization") != NULL);
CHECK(strstr(terminal.output, "Timestamp:") != NULL);
CHECK(strstr(terminal.output, "Status:") != NULL);
CHECK(strstr(terminal.output, "successful") != NULL ||
strstr(terminal.output, "failed") != NULL);
CHECK(strstr(terminal.output, "Horodatage") == NULL);
trainlog_presentation_init();
if (saved_data_home != NULL) {
CHECK(setenv("XDG_DATA_HOME", saved_data_home, 1) == 0);
free(saved_data_home);
@ -2223,11 +2315,11 @@ static bool test_body_profile_snapshot_and_evolution_are_separate(void)
dashboard_prepare_render(&app, &terminal, &surface, 72, 20, 72, 16);
app_shell_render_body_profile(&app);
CHECK(strstr(terminal.output, "Poids : 80.00 kg") != NULL);
CHECK(strstr(terminal.output, "Poids : 80,00 kg") != NULL);
CHECK(strstr(terminal.output, "Poitrine") != NULL);
CHECK(strstr(terminal.output, "100.0 cm") != NULL);
CHECK(strstr(terminal.output, "100,0 cm") != NULL);
CHECK(strstr(terminal.output, "Tour de taille") != NULL);
CHECK(strstr(terminal.output, "85.0 cm") != NULL);
CHECK(strstr(terminal.output, "85,0 cm") != NULL);
CHECK(strstr(terminal.output, "Épaules") == NULL);
CHECK(rendered_draw_column(&terminal, 0x25c6U, 0U) >= 0);
CHECK(rendered_draw_column(&terminal, 0x2586U, 0U) >= 0);
@ -2257,9 +2349,9 @@ static bool test_body_profile_snapshot_and_evolution_are_separate(void)
CHECK(app.list.selected_index == 1U);
dashboard_prepare_render(&app, &terminal, &surface, 80, 24, 80, 20);
app_shell_render_body_profile(&app);
CHECK(strstr(terminal.output, "Poids : 78.00 kg") != NULL);
CHECK(strstr(terminal.output, "90.0 cm") != NULL);
CHECK(strstr(terminal.output, "100.0 cm") == NULL);
CHECK(strstr(terminal.output, "Poids : 78,00 kg") != NULL);
CHECK(strstr(terminal.output, "90,0 cm") != NULL);
CHECK(strstr(terminal.output, "100,0 cm") == NULL);
app.body_metric_selected = 0U;
app_shell_move_available_body_metric(&app, 1);
@ -2305,7 +2397,7 @@ static bool test_statistics_center_renders_and_cycles_factual_views(void)
app_shell_render_statistics_training(&app);
CHECK(strstr(terminal.output, "ACTIVITÉ") != NULL);
CHECK(strstr(terminal.output, "TRAVAIL") != NULL);
CHECK(strstr(terminal.output, "Volume 3200.0 kg") != NULL);
CHECK(strstr(terminal.output, "Volume 3200,0 kg") != NULL);
CHECK(strstr(terminal.output,
"Volume chargé · mois calendaires") != NULL);
CHECK(!terminal.coordinate_overflow && !terminal.text_overflow);
@ -2366,6 +2458,14 @@ static bool test_statistics_center_renders_and_cycles_factual_views(void)
CHECK(strstr(terminal.output, "Développé couché") != NULL);
CHECK(strstr(terminal.output, "Charge observée") != NULL);
CHECK(!terminal.coordinate_overflow && !terminal.text_overflow);
trainlog_presentation_set_language(TRAINLOG_PRESENTATION_ENGLISH);
terminal.output_used = 0U;
terminal.output[0] = '\0';
app_shell_render_exercise_statistics(&app);
CHECK(strstr(terminal.output, "Développé couché") != NULL);
CHECK(strstr(terminal.output, "Observed load") != NULL);
CHECK(strstr(terminal.output, "Charge observée") == NULL);
trainlog_presentation_init();
return true;
}
@ -2386,6 +2486,7 @@ int main(void)
!test_shell_compact_navigation_focus_contract() ||
!test_shell_detail_back_restores_stable_list_selection() ||
!test_shell_body_controller_preserves_identity_and_failed_edit() ||
!test_sync_result_presenter_maps_typed_statuses_in_both_languages() ||
!test_shell_sync_f7_runs_bidirectional_and_refreshes_history() ||
!test_shell_sync_history_neutralizes_legacy_directions() ||
!test_equipment_collector_finds_match_after_first_pages() ||