feat(android): refine field workflows and bilingual UI

This commit is contained in:
fy59 2026-09-16 07:45:54 +02:00
parent 3200358641
commit 1674059203
36 changed files with 2790 additions and 840 deletions

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

@ -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"]: