diff --git a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt index ef3bd09..a4c5a26 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt @@ -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() + 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, - ) + ) + } } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 88e4a56..7288338 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -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) ) ) } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt index f084827..91b95fe 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt @@ -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) } } }, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionAdapter.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionAdapter.kt index 505cb78..45fb32f 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionAdapter.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidSpeechRecognitionAdapter.kt @@ -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 diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt index 38ebe43..3bde5cc 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt @@ -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. diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt index c862095..c3d6b89 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/BodyScreen.kt @@ -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) ) } }, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt index c59e93c..20b77b5 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/CompletedSessionCorrectionScreen.kt @@ -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?>(null) } var pendingOccurrenceRemoval by remember { mutableStateOf(null) } var message by remember { mutableStateOf(null) } + val decimalText = remember(sessionId) { mutableStateMapOf() } - 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, + 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), diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt index 69ccaaf..0327f53 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/ExerciseScreen.kt @@ -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, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackDictation.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackDictation.kt index 27be6d2..4ac4075 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackDictation.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackDictation.kt @@ -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 } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackEditor.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackEditor.kt index 6c4004a..d006168 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackEditor.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/FeedbackEditor.kt @@ -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) } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt index 90408f9..a2d10de 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HistoryScreen.kt @@ -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) } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt index 5808067..fce2934 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt @@ -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, val accessibilityBounds: Map, ) { 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) } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/LanguageSettings.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/LanguageSettings.kt new file mode 100644 index 0000000..2903215 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/LanguageSettings.kt @@ -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 { + /* 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) +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt index 30e49d2..59bcfd4 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt @@ -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(null) } var pendingDeletion by remember { mutableStateOf?>(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(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) } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt index d2558cd..d729a79 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt @@ -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(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) } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt index fa413df..7b52f85 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt @@ -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('_', ' ') +}) diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index 572613d..e1263e3 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -13,6 +13,7 @@ package com.labfytools.trainlog.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Arrangement @@ -25,14 +26,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.labfytools.trainlog.data.ActiveDraftLoadResult import com.labfytools.trainlog.data.ActiveDraftMutationResult @@ -57,6 +68,7 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.TrainlogTypography import java.text.Normalizer import java.util.Locale +import com.labfytools.trainlog.R @Composable fun SessionScreen( @@ -68,6 +80,7 @@ fun SessionScreen( ) { val colors = LocalTrainlogColors.current + val strings = localizedContext() val exercises = remember( @@ -97,13 +110,13 @@ fun SessionScreen( >( when (initialLoad) { is ActiveDraftLoadResult.Error -> - initialLoad.message + localizedRepositoryMessage(strings, initialLoad.message) ActiveDraftLoadResult.None -> - "Aucune séance en cours." + strings.getString(R.string.current_session_none) is ActiveDraftLoadResult.Loaded -> - initialLoad.warning + initialLoad.warning?.let { localizedRepositoryMessage(strings, it) } } ) } @@ -118,6 +131,7 @@ fun SessionScreen( var pendingRemoval by remember { mutableStateOf?>(null) } var pendingDestructiveEdit by remember { mutableStateOf?>(null) } var feedbackRevision by remember { mutableStateOf(0) } + var reorderedDuringGesture by remember { mutableStateOf(false) } val persistDraft: (ActiveSessionDraft, String?) -> Unit = @@ -136,18 +150,17 @@ fun SessionScreen( is ActiveDraftMutationResult.Error -> { message = - "Brouillon non sauvegardé : " + - result.message + strings.getString(R.string.draft_unsaved, localizedRepositoryMessage(strings, result.message)) } } } TrainlogScreen( - subtitle = "Séance en cours" + subtitle = strings.getString(R.string.route_session_editor) ) { if (activeDraft == null) { TrainlogFrame( - title = "Erreur" + title = strings.getString(R.string.error_title) ) { TrainlogInfo( text = message.orEmpty(), @@ -161,14 +174,16 @@ fun SessionScreen( val lastWriteFailed = message?.startsWith( - "Brouillon non sauvegardé" + strings.getString(R.string.draft_unsaved_title) + ) == true || message?.startsWith( + strings.getString(R.string.exercise_reorder_failed, "") ) == true TrainlogInfo( text = if (lastWriteFailed) { - "Dernière modification non sauvegardée." + strings.getString(R.string.last_change_unsaved) } else { - "Séance sauvegardée localement." + strings.getString(R.string.session_saved_locally) }, color = if (lastWriteFailed) { @@ -179,7 +194,7 @@ fun SessionScreen( ) TrainlogFrame( - title = "Type de séance" + title = strings.getString(R.string.session_type) ) { TrainlogAction( label = @@ -187,12 +202,12 @@ fun SessionScreen( currentDraft.sessionType == SessionType.TRAINING ) { - "[✓] Entraînement" + strings.getString(R.string.training_selected) } else { - "[ ] Entraînement" + strings.getString(R.string.training_unselected) }, description = - "Séance normale de travail.", + strings.getString(R.string.normal_session_description), accent = if ( currentDraft.sessionType == @@ -219,12 +234,12 @@ fun SessionScreen( currentDraft.sessionType == SessionType.MAX_TEST ) { - "[✓] Test max" + strings.getString(R.string.max_test_selected) } else { - "[ ] Test max" + strings.getString(R.string.max_test_unselected) }, description = - "Séance explicitement dédiée à une mesure de max.", + strings.getString(R.string.max_session_description), accent = if ( currentDraft.sessionType == @@ -262,19 +277,19 @@ fun SessionScreen( ) TrainlogFrame( - title = "Séance en cours" + title = strings.getString(R.string.route_session_editor) ) { TrainlogInfo( text = - "Type : " + + strings.getString(R.string.session_type_value, if ( currentDraft.sessionType == SessionType.MAX_TEST ) { - "TEST MAX" + strings.getString(R.string.session_max_test) } else { - "ENTRAÎNEMENT" - }, + strings.getString(R.string.session_training) + }), color = if ( currentDraft.sessionType == @@ -290,7 +305,7 @@ fun SessionScreen( currentDraft.exercises.isEmpty() ) { TrainlogInfo( - "Aucun exercice ajouté." + strings.getString(R.string.exercise_none_added) ) } else { currentDraft.exercises @@ -298,32 +313,68 @@ fun SessionScreen( index, draft -> - TrainlogInfo( - text = - "${index + 1}. " + - draftSummary( - draft - ), - color = - colors.text, - ) + key(draft.entryId) { + var reorderVisualOffset by remember(draft.entryId) { mutableStateOf(0f) } + Column(Modifier.graphicsLayer { translationY = reorderVisualOffset }) { + + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + BasicText( + text = "${index + 1}. ${draftSummary(draft)}", + modifier = Modifier.weight(1f).padding(vertical = 12.dp), + style = TrainlogTypography.normal.copy(color = colors.text), + ) + ExerciseReorderHandle( + entryId = draft.entryId, + index = index, + itemCount = currentDraft.exercises.size, + onMove = { from, to -> + activeDraft = reorderActiveSessionDraft(currentDraft, from, to) + reorderedDuringGesture = true + }, + onCommit = { + if (reorderedDuringGesture) { + val reordered = activeDraft + if (reordered != null) when (val result = repository.saveActiveSessionDraft(reordered)) { + ActiveDraftMutationResult.Saved -> { + message = strings.getString(R.string.exercise_order_saved) + } + is ActiveDraftMutationResult.Error -> { + activeDraft = when (val durable = repository.loadActiveSessionDraft()) { + is ActiveDraftLoadResult.Loaded -> durable.draft + else -> currentDraft + } + message = strings.getString( + R.string.exercise_reorder_failed, + localizedRepositoryMessage(strings, result.message), + ) + } + } + reorderedDuringGesture = false + } + }, + onVisualOffset = { reorderVisualOffset = it }, + ) + } draft.plan?.let { plan -> TrainlogInfo( - "Plan : ${plan.sets} × ${plan.reps ?: plan.durationSeconds} · " + - "repos ${plan.restSeconds} s · " + - (plan.weightKg?.let { "charge cible $it kg" } - ?: "aucune charge numérique proposée"), + 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_proposed)), color = colors.muted, ) if (draft.sets.isEmpty()) TrainlogInfo( - "Aucune série réalisée saisie : ajoutez les valeurs réellement effectuées.", + strings.getString(R.string.performed_sets_none), color = colors.warning, ) } TrainlogAction( - label = "Modifier ${draft.exercise.name}", - description = "Corriger cet exercice sans le supprimer de la séance.", + label = strings.getString(R.string.modify_name, draft.exercise.name), + description = strings.getString(R.string.edit_exercise_description), accent = colors.accent, onClick = { persistDraft( @@ -332,11 +383,11 @@ fun SessionScreen( ), null, ) - message = "Éditeur ouvert pour ${draft.exercise.name} — les champs préremplis apparaissent ci-dessous." + message = strings.getString(R.string.editor_opened, draft.exercise.name) }, ) if (currentDraft.form.editingEntryId == draft.entryId) { - TrainlogInfo("Modification en cours — faites défiler jusqu’à SAISIE — ${draft.exercise.name}.", color = colors.accent) + TrainlogInfo(strings.getString(R.string.editing_scroll_hint, draft.exercise.name), color = colors.accent) } val draftFeedback = remember(feedbackRevision, draft.entryId) { @@ -344,26 +395,27 @@ fun SessionScreen( } draftFeedback.forEach { feedback -> TrainlogInfo( - text = "Ressenti · ${feedback.rawText}${if (feedback.modified) " · Modifié" else ""}", + text = strings.getString(R.string.feedback_value, feedback.rawText, + if (feedback.modified) strings.getString(R.string.modified_suffix) else ""), color = colors.muted, ) - TrainlogAction("Modifier", "Corriger le texte sans changer l’observation.", + TrainlogAction(strings.getString(R.string.modify), strings.getString(R.string.feedback_edit_description), onClick = { editingFeedbackId = feedback.feedbackId }, accent = colors.accent) if (editingFeedbackId == feedback.feedbackId) FeedbackEditor( initialText = feedback.rawText, editing = true, onSave = { text -> when (val result = repository.reviseDraftExerciseFeedback(feedback.feedbackId, text)) { is SaveFeedbackResult.Saved -> { feedbackRevision++; editingFeedbackId = null; 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 }) } TrainlogAction( label = if (draftFeedback.isEmpty()) { - "🎙 Ressenti" + strings.getString(R.string.feedback_voice) } else { - "Ressenti (${draftFeedback.size}) · + Ajouter un ressenti" + strings.getString(R.string.feedback_add_count, draftFeedback.size) }, - description = "Dicter ou saisir un ressenti maintenant, avant la fin de la séance.", + description = strings.getString(R.string.feedback_now_description), accent = colors.success, modifier = Modifier.testTag("draft-feedback-${draft.entryId}"), onClick = { feedbackEntryId = draft.entryId }, @@ -375,11 +427,11 @@ fun SessionScreen( is SaveFeedbackResult.Saved -> { feedbackRevision += 1 feedbackEntryId = null - message = "Ressenti sauvegardé dans la séance en cours." + message = strings.getString(R.string.feedback_saved_current) 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 }, @@ -388,13 +440,15 @@ fun SessionScreen( if (currentDraft.sourceSessionId == null) { TrainlogDeleteButton( - contentDescription = "Supprimer cet exercice", + contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_exercise), modifier = Modifier.testTag("delete-draft-exercise-${draft.entryId}"), onClick = { pendingRemoval = index to draft }, ) } + } + } } } } @@ -459,23 +513,23 @@ fun SessionScreen( form = SessionDraftForm(), ), - if (editIndex == null) "Exercice ajouté à la séance." else "Exercice modifié.", + if (editIndex == null) strings.getString(R.string.exercise_added) else strings.getString(R.string.exercise_modified), ) }, ) } TrainlogFrame( - title = "Actions", + title = strings.getString(R.string.actions), active = currentDraft.exercises.isNotEmpty(), ) { Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("active-session-actions-row"), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TrainlogButton("Créer un nouvel exercice", onCreateExercise, + TrainlogButton(strings.getString(R.string.create_new_exercise), onCreateExercise, Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"), containerColor = colors.surfaceAlt, maxLines = 2) - TrainlogButton("Enregistrer la séance", onClick = { + TrainlogButton(strings.getString(R.string.session_save), onClick = { when ( val result = repository.finalizeActiveSessionDraft() @@ -486,22 +540,22 @@ fun SessionScreen( } is FinalizeActiveDraftResult.Invalid -> { - message = result.message + message = localizedRepositoryMessage(strings, result.message) } is FinalizeActiveDraftResult.DatabaseError -> { message = - "Échec de finalisation, brouillon conservé : " + - result.message + strings.getString(R.string.finalize_failed, localizedRepositoryMessage(strings, result.message)) } } }, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"), containerColor = colors.success, maxLines = 2) } - TrainlogInfo("${currentDraft.exercises.size} exercice(s) dans la séance.", colors.muted) + TrainlogInfo(strings.getString(R.string.session_exercise_total, + strings.resources.getQuantityString(R.plurals.exercise_count, currentDraft.exercises.size, currentDraft.exercises.size)), colors.muted) TrainlogDeleteButton( - contentDescription = "Supprimer la séance en cours", + contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_current_session), modifier = Modifier.testTag("delete-active-draft"), onClick = { confirmingDiscard = true @@ -510,11 +564,12 @@ fun SessionScreen( if (confirmingDiscard) { DestructiveConfirmationDialog( - title = "Abandonner la séance en cours ?", - detail = "Cette action supprimera le brouillon, ses ${currentDraft.exercises.size} exercice(s) et toutes leurs données enregistrées.", - confirmLabel = "Abandonner", + title = strings.getString(R.string.discard_session_question), + detail = strings.getString(R.string.discard_session_detail, + strings.resources.getQuantityString(R.plurals.exercise_count, currentDraft.exercises.size, currentDraft.exercises.size)), + confirmLabel = strings.getString(R.string.discard), onCancel = { confirmingDiscard = false }, - deleteContentDescription = "Supprimer la séance en cours", + deleteContentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_current_session), onConfirm = { when ( val result = @@ -527,7 +582,7 @@ fun SessionScreen( } is ActiveDraftMutationResult.Error -> { - message = result.message + message = localizedRepositoryMessage(strings, result.message) } } }, @@ -543,9 +598,10 @@ fun SessionScreen( color = if ( message == - "Exercice ajouté à la séance." || + strings.getString(R.string.exercise_added) || message == - "Exercice retiré de la séance." + strings.getString(R.string.exercise_removed) || + message == strings.getString(R.string.exercise_order_saved) ) { colors.success } else { @@ -558,21 +614,21 @@ fun SessionScreen( pendingRemoval?.let { (index, draft) -> val feedbackCount = repository.listDraftExerciseFeedback(draft.entryId).size val facts = buildList { - if (draft.sets.isNotEmpty()) add("${draft.sets.size} série(s)") - if (draft.continuousDurationSeconds > 0) add("l’activité continue") - if (draft.maxWeightKg != null) add("la donnée MAX") - if (feedbackCount > 0) add("$feedbackCount ressenti(s)") + if (draft.sets.isNotEmpty()) add(strings.resources.getQuantityString(R.plurals.performed_set_count, draft.sets.size, draft.sets.size)) + if (draft.continuousDurationSeconds > 0) add(strings.getString(R.string.continuous_fact)) + if (draft.maxWeightKg != null) add(strings.getString(R.string.max_fact)) + if (feedbackCount > 0) add(strings.resources.getQuantityString(R.plurals.feedback_count, feedbackCount, feedbackCount)) } DestructiveConfirmationDialog( - title = "Supprimer \"${draft.exercise.name}\" de la séance ?", - detail = if (facts.isEmpty()) "Cette occurrence sera retirée." else - "Cette action supprimera :\n- " + facts.joinToString("\n- "), - confirmLabel = "Supprimer", + title = strings.getString(R.string.remove_exercise_question, draft.exercise.name), + detail = if (facts.isEmpty()) strings.getString(R.string.remove_occurrence_detail) else + strings.getString(R.string.delete_facts_detail, facts.joinToString("\n- ")), + confirmLabel = strings.getString(R.string.delete), onCancel = { pendingRemoval = null }, - deleteContentDescription = "Supprimer cet exercice", + deleteContentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_exercise), onConfirm = { persistDraft(currentDraft.copy(exercises = currentDraft.exercises.filterIndexed { i, _ -> i != index }), - "Exercice retiré de la séance.") + strings.getString(R.string.exercise_removed)) pendingRemoval = null }, ) @@ -580,18 +636,18 @@ fun SessionScreen( pendingDestructiveEdit?.let { (index, replacement) -> val previous=currentDraft.exercises[index] val losses=buildList { - if(previous.sets.isNotEmpty())add("${previous.sets.size} série(s)") - if(previous.continuousDurationSeconds>0)add("l’activité continue") - if(previous.maxWeightKg!=null)add("la donnée MAX") - if(previous.plan!=null)add("les objectifs et le repos") + if(previous.sets.isNotEmpty())add(strings.resources.getQuantityString(R.plurals.performed_set_count, previous.sets.size, previous.sets.size)) + if(previous.continuousDurationSeconds>0)add(strings.getString(R.string.continuous_fact)) + if(previous.maxWeightKg!=null)add(strings.getString(R.string.max_fact)) + if(previous.plan!=null)add(strings.getString(R.string.plan_rest_fact)) } DestructiveConfirmationDialog( - title="Changer le profil de ${previous.exercise.name} ?", - detail="Ce changement incompatible remplacera :\n- "+losses.joinToString("\n- ")+"\nLes ressentis restent attachés au même entry_id.", - confirmLabel="Remplacer", + title=strings.getString(R.string.change_profile_question, previous.exercise.name), + detail=strings.getString(R.string.incompatible_change_detail, losses.joinToString("\n- ")), + confirmLabel=strings.getString(R.string.replace), onCancel={pendingDestructiveEdit=null}, onConfirm={ - persistDraft(currentDraft.copy(exercises=currentDraft.exercises.mapIndexed{i,item->if(i==index)replacement else item},form=SessionDraftForm()),"Exercice modifié.") + persistDraft(currentDraft.copy(exercises=currentDraft.exercises.mapIndexed{i,item->if(i==index)replacement else item},form=SessionDraftForm()),strings.getString(R.string.exercise_modified)) pendingDestructiveEdit=null }, ) @@ -682,28 +738,29 @@ private fun ExercisePicker( onExerciseSelected: (ExerciseProfile) -> Unit, ) { val colors = LocalTrainlogColors.current + val strings = localizedContext() var searchOpen by remember(selectedExercise?.exerciseId) { mutableStateOf(selectedExercise == null) } var query by remember(selectedExercise?.exerciseId) { mutableStateOf("") } val results = remember(exercises, query) { exercisePrefixMatches(exercises, query) } - TrainlogFrame(title = "Exercice", active = exercises.isNotEmpty()) { + TrainlogFrame(title = strings.getString(R.string.exercise_title), active = exercises.isNotEmpty()) { if (exercises.isEmpty()) { - TrainlogInfo("Aucun exercice.") + TrainlogInfo(strings.getString(R.string.exercise_none)) return@TrainlogFrame } TrainlogAction( - label = selectedExercise?.name ?: "Choisir un exercice", - description = "Exercice sélectionné. Appuyer pour afficher les suggestions.", + label = selectedExercise?.name ?: strings.getString(R.string.choose_exercise), + description = strings.getString(R.string.exercise_picker_description), accent = if (selectedExercise == null) colors.muted else colors.success, modifier = Modifier.testTag("exercise-picker-open"), onClick = { searchOpen = true }, ) TrainlogInputField( - label = "Rechercher un exercice…", + label = strings.getString(R.string.search_exercise), value = query, onValueChange = { query = it @@ -717,7 +774,7 @@ private fun ExercisePicker( } if (results.isEmpty()) { - TrainlogInfo("Aucun exercice trouvé", color = colors.muted) + TrainlogInfo(strings.getString(R.string.exercise_none_found_short), color = colors.muted) } else { /* The viewport stays keyboard-friendly while LazyColumn preserves * access to every prefix result instead of truncating it. */ @@ -741,8 +798,8 @@ private fun ExercisePicker( if (selectedExercise != null) { TrainlogAction( - label = "Annuler la recherche", - description = "Conserver ${selectedExercise.name} et les valeurs saisies.", + label = strings.getString(R.string.cancel_search), + description = strings.getString(R.string.keep_named_values, selectedExercise.name), accent = colors.muted, onClick = { query = "" @@ -793,22 +850,22 @@ internal fun buildManualTargetPlan( ManualTargetChoice.KG -> directKgText.trim().replace(',', '.').toDoubleOrNull() ?.takeIf { it.isFinite() && it > 0.0 } ?: return Result.failure(IllegalArgumentException( - "Saisissez une charge cible strictement positive.")) + "target_positive")) ManualTargetChoice.PERCENT_MAX -> (percentResult as? ManualPercentMaxResult.Available)?.targetWeightKg ?: return Result.failure(IllegalArgumentException( (percentResult as? ManualPercentMaxResult.Unavailable)?.message - ?: "MAX compatible indisponible.")) + ?: "max_unavailable")) ManualTargetChoice.NONE -> error("handled above") } val mode = when (equipmentSemantics) { EquipmentLoadSemantics.EXTERNAL -> SessionLoadMode.EXTERNAL EquipmentLoadSemantics.ASSISTANCE -> if (choice == ManualTargetChoice.PERCENT_MAX) return Result.failure(IllegalArgumentException( - "Le %MAX est indisponible pour une assistance ; choisissez une résistance externe.")) + "max_assistance_unavailable")) else SessionLoadMode.ASSISTANCE else -> return Result.failure(IllegalArgumentException( - "Choisissez un équipement compatible avec une charge cible.")) + "compatible_load_equipment_required")) } val dose = existing ?: when (draft.exercise.trackingMode) { TrackingMode.REPS -> SessionExercisePlan( @@ -826,7 +883,7 @@ internal fun buildManualTargetPlan( (draft.exercise.trackingMode == TrackingMode.DURATION && (dose.durationSeconds == null || dose.durationSeconds <= 0))) return Result.failure(IllegalArgumentException( - "Définissez une dose cible positive avant la charge cible.")) + "positive_dose_required")) return Result.success(dose.copy(weightKg = weight, loadMode = mode)) } @@ -846,6 +903,7 @@ private fun SessionExerciseForm( ) { val colors = LocalTrainlogColors.current + val strings = localizedContext() var setCountText by remember(key) { @@ -876,7 +934,7 @@ private fun SessionExerciseForm( /* CONTRACT: SETS + REPS is edited as occurrence-owned rows. The raw * strings (including blanks and invalid fragments) are mirrored into the * durable form after every mutation, while the saved occurrence is only - * replaced when the user confirms with "Ajouter à la séance". */ + * replaced when the user confirms the add-to-session action. */ var setRows by remember(key) { mutableStateOf( @@ -939,10 +997,10 @@ private fun SessionExerciseForm( TrainlogFrame( title = - "SAISIE — ${exercise.name}" + strings.getString(R.string.entry_title, exercise.name) ) { TrainlogInfo( - text = if (sessionType == SessionType.MAX_TEST) "TEST MAX" else exerciseProfileLabel(exercise), + text = if (sessionType == SessionType.MAX_TEST) strings.getString(R.string.session_max_test) else exerciseProfileLabel(exercise), color = colors.accent, ) @@ -950,18 +1008,18 @@ private fun SessionExerciseForm( /* Legacy/custom/unresolved rows retain the compatibility selector; * resolved fixed machines never require a second user choice. */ TrainlogInputField( - label = "Contexte historique (optionnel)", + label = strings.getString(R.string.historical_context_optional), value = equipmentSearch, onValueChange = { equipmentSearch = it }, ) TrainlogInputField( - label = "Nouveau contexte historique", + label = strings.getString(R.string.new_historical_context), value = customEquipmentName, onValueChange = { customEquipmentName = it }, ) TrainlogAction( - label = "Créer le contexte", - description = "Compatibilité temporaire pour un exercice custom ou non résolu.", + label = strings.getString(R.string.create_context), + description = strings.getString(R.string.context_compatibility), accent = colors.success, onClick = { when (val result = repository.createCustomEquipment(customEquipmentName)) { @@ -971,9 +1029,9 @@ private fun SessionExerciseForm( selectedEquipmentId = result.equipment.equipmentId onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText)) } - CreateEquipmentResult.Invalid -> error = "Donnez un nom de contexte valide." - CreateEquipmentResult.Conflict -> error = "Ce contexte existe déjà." - is CreateEquipmentResult.DatabaseError -> error = "Contexte non créé : ${result.message}" + CreateEquipmentResult.Invalid -> error = strings.getString(R.string.context_name_invalid) + CreateEquipmentResult.Conflict -> error = strings.getString(R.string.context_exists) + is CreateEquipmentResult.DatabaseError -> error = strings.getString(R.string.context_create_error, localizedRepositoryMessage(strings, result.message)) } }, ) @@ -993,7 +1051,7 @@ private fun SessionExerciseForm( if (selectedEquipment != null && fixedEquipmentId == null) { TrainlogAction( label = "✓ ${selectedEquipment.displayName}", - description = selectedEquipment.labelName.ifBlank { "Équipement sélectionné." }, + description = selectedEquipment.labelName.ifBlank { strings.getString(R.string.equipment_selected) }, accent = colors.success, onClick = { selectedEquipmentId = null @@ -1015,8 +1073,9 @@ private fun SessionExerciseForm( if (sessionType == SessionType.MAX_TEST) { SessionNumberField( - label = "Poids max (kg)", + label = strings.getString(R.string.max_weight), value = maxWeightText, + decimal = true, onValueChange = { maxWeightText = it error = null @@ -1030,73 +1089,74 @@ private fun SessionExerciseForm( }, ) TrainlogInfo( - text = "Valeur strictement positive, virgule française acceptée.", + text = strings.getString(R.string.positive_decimal_note), color = colors.muted, ) } else if ( exercise.recordingMode == RecordingMode.SETS ) { - TrainlogInfo("Charge cible prévue — séparée des charges réellement effectuées.", colors.muted) + TrainlogInfo(strings.getString(R.string.target_load_note), colors.muted) TrainlogChoiceChips( listOf( - ManualTargetChoice.KG.name to "Valeur en kg", - ManualTargetChoice.PERCENT_MAX.name to "% de mon MAX", - ManualTargetChoice.NONE.name to "Aucune", + ManualTargetChoice.KG.name to strings.getString(R.string.load_kg_value), + ManualTargetChoice.PERCENT_MAX.name to strings.getString(R.string.percent_my_max), + ManualTargetChoice.NONE.name to strings.getString(R.string.value_none_feminine), ), targetChoice.name, ) { selected -> targetChoice = ManualTargetChoice.valueOf(selected) } when (targetChoice) { ManualTargetChoice.KG -> SessionNumberField( - label = "Charge cible (kg)", value = targetKgText, + label = strings.getString(R.string.field_target_weight), value = targetKgText, + decimal = true, onValueChange = { targetKgText = it; error = null }, ) ManualTargetChoice.PERCENT_MAX -> { SessionNumberField( - label = "Pourcentage de mon MAX (1 à 100)", + label = strings.getString(R.string.percent_my_max_field), value = targetPercentText, onValueChange = { targetPercentText = it; error = null }, ) when (val result = percentResult) { is ManualPercentMaxResult.Available -> { TrainlogInfo( - "MAX compatible : ${formatMaxWeight(result.maxWeightKg)} kg · ${result.maxStartedAt}", + strings.getString(R.string.compatible_max, presentationNumber(result.maxWeightKg, 3), formatDate(result.maxStartedAt)), colors.muted, ) TrainlogInfo( - "Cible calculée : ${formatMaxWeight(result.targetWeightKg)} kg", + strings.getString(R.string.calculated_target, presentationNumber(result.targetWeightKg, 3)), colors.success, ) } is ManualPercentMaxResult.Unavailable -> - TrainlogInfo(result.message, colors.error) + TrainlogInfo(localizedTargetError(result.message, strings), colors.error) null -> Unit } } ManualTargetChoice.NONE -> - TrainlogInfo("Aucune charge cible ne sera enregistrée.", colors.muted) + TrainlogInfo(strings.getString(R.string.target_none), colors.muted) } if ( exercise.trackingMode == TrackingMode.REPS ) { TrainlogInfo( - text = "Chaque série conserve ses propres répétitions et sa propre charge.", + text = strings.getString(R.string.set_independent_values), color = colors.muted, ) val loadLabel = if (selectedEquipment?.loadSemantics == EquipmentLoadSemantics.ASSISTANCE) { - "Assistance (kg)" + strings.getString(R.string.assistance_kg) } else { - "Charge (kg)" + strings.getString(R.string.field_weight) } setRows.forEachIndexed { index, row -> TrainlogInfo( - text = "Série ${index + 1}", + text = strings.getString(R.string.set_number, index + 1), color = colors.accent, ) SessionNumberField( - label = "Série ${index + 1} — Répétitions", + label = strings.getString(R.string.set_reps_field, index + 1), value = row.repsText, testTag = "session-set-$index-reps", onValueChange = { value -> @@ -1114,8 +1174,9 @@ private fun SessionExerciseForm( }, ) SessionNumberField( - label = "Série ${index + 1} — $loadLabel", + label = strings.getString(R.string.set_load_field, index + 1, loadLabel), value = row.weightText, + decimal = true, testTag = "session-set-$index-weight", onValueChange = { value -> val updated = setRows.replaceAt(index, row.copy(weightText = value)) @@ -1132,7 +1193,7 @@ private fun SessionExerciseForm( }, ) TrainlogDeleteButton( - contentDescription = "Supprimer cette série", + contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set), modifier = Modifier.testTag("delete-set-${index + 1}"), onClick = { pendingSetRemoval = index @@ -1140,8 +1201,8 @@ private fun SessionExerciseForm( ) } TrainlogAction( - label = "Ajouter une série", - description = "Ajouter une ligne vide sans modifier les autres séries.", + label = strings.getString(R.string.add_set_plain), + description = strings.getString(R.string.add_set_row_description), accent = colors.success, onClick = { if (setRows.size < MAX_SESSION_SETS) { @@ -1160,13 +1221,13 @@ private fun SessionExerciseForm( }, ) TrainlogInfo( - text = "Charge facultative ; virgule française acceptée. Une case vide n'est pas zéro.", + text = strings.getString(R.string.optional_load_note), color = colors.muted, ) } else { SessionNumberField( label = - "Nombre de séries", + strings.getString(R.string.sets_count), value = setCountText, onValueChange = { @@ -1189,7 +1250,7 @@ private fun SessionExerciseForm( SessionNumberField( label = - "Durée par série (secondes)", + strings.getString(R.string.set_duration_seconds), value = durationText, onValueChange = { @@ -1213,7 +1274,7 @@ private fun SessionExerciseForm( } else { SessionNumberField( label = - "Durée (minutes)", + strings.getString(R.string.duration_minutes), value = durationText, onValueChange = { @@ -1241,9 +1302,10 @@ private fun SessionExerciseForm( ) { SessionNumberField( label = - "Vitesse km/h", + strings.getString(R.string.speed_kmh), value = speedText, + decimal = true, onValueChange = { speedText = it error = null @@ -1270,9 +1332,10 @@ private fun SessionExerciseForm( ) { SessionNumberField( label = - "Distance km", + strings.getString(R.string.distance_km), value = distanceText, + decimal = true, onValueChange = { distanceText = it error = null @@ -1293,13 +1356,10 @@ private fun SessionExerciseForm( } } - TrainlogAction( - label = - "Ajouter à la séance", - description = - "Ajouter cette saisie au brouillon.", - accent = - colors.success, + TrainlogButton( + label = strings.getString(R.string.add_to_session), + modifier = Modifier.fillMaxWidth().testTag("add-to-session"), + containerColor = colors.success, onClick = { val draft = buildSessionExerciseDraft( @@ -1326,14 +1386,14 @@ private fun SessionExerciseForm( if (draft == null) { error = if (sessionType == SessionType.MAX_TEST) { - "Saisissez un poids max strictement positif (ex. 100 ou 86,5)." + strings.getString(R.string.max_weight_invalid) } else if ( exercise.recordingMode == RecordingMode.SETS && exercise.trackingMode == TrackingMode.REPS ) { - setRowValidationError(setRows) + localizedSetRowValidationError(setRowValidationError(setRows), strings) } else { - "Valeurs invalides." + strings.getString(R.string.invalid_values) } } else if (sessionType == SessionType.TRAINING && exercise.recordingMode == RecordingMode.SETS) { @@ -1343,7 +1403,7 @@ private fun SessionExerciseForm( ) plan.fold( onSuccess = { onAdd(draft.copy(plan = it)) }, - onFailure = { error = it.message ?: "Charge cible invalide." }, + onFailure = { error = localizedTargetError(it.message, strings) }, ) } else { onAdd(draft.copy(plan = null)) @@ -1353,9 +1413,9 @@ private fun SessionExerciseForm( TrainlogAction( label = - "Annuler la saisie", + strings.getString(R.string.cancel_entry), description = - "Revenir au catalogue.", + strings.getString(R.string.return_catalog), accent = colors.muted, onClick = @@ -1373,11 +1433,11 @@ private fun SessionExerciseForm( } pendingSetRemoval?.let { index -> DestructiveConfirmationDialog( - title = "Supprimer la série ${index + 1} ?", - detail = "Ses répétitions, sa durée et sa charge saisies seront supprimées.", - confirmLabel = "Supprimer", + title = strings.getString(R.string.delete_numbered_set_question, index + 1), + detail = strings.getString(R.string.delete_set_entered_detail), + confirmLabel = strings.getString(R.string.delete), onCancel = { pendingSetRemoval = null }, - deleteContentDescription = "Supprimer cette série", + deleteContentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set), onConfirm = { val updated=setRows.filterIndexed { rowIndex, _ -> rowIndex != index } setRows=updated;repsText=encodeRawReps(updated);weightText=encodeRawWeights(updated);error=null @@ -1389,6 +1449,137 @@ private fun SessionExerciseForm( } } +/** + * WHY: gym execution order changes frequently, while occurrence identity and + * its captured facts must not. The dedicated handle avoids accidental row + * drags while the user opens an editor. + * CONTRACT: movement is a pure list permutation and persistence is requested + * only after the gesture completes. Accessibility actions use the same path. + * INVARIANT: the exact [SessionExerciseDraft] objects are moved; none of their + * entry, exercise, equipment, target, actual, MAX, or feedback keys is rebuilt. + */ +@Composable +internal fun ExerciseReorderHandle( + entryId: String, + index: Int, + itemCount: Int, + onMove: (from: Int, to: Int) -> Unit, + onCommit: () -> Unit, + onVisualOffset: (Float) -> Unit = {}, +) { + val strings = localizedContext() + val currentIndex by rememberUpdatedState(index) + val currentItemCount by rememberUpdatedState(itemCount) + val currentMove by rememberUpdatedState(onMove) + val currentCommit by rememberUpdatedState(onCommit) + val currentVisualOffset by rememberUpdatedState(onVisualOffset) + var dragOrigin by remember(entryId) { mutableStateOf(null) } + var totalDragDistance by remember(entryId) { mutableStateOf(0f) } + var residualDragDistance by remember(entryId) { mutableStateOf(0f) } + BasicText( + text = "≡", + modifier = Modifier + .heightIn(min = 48.dp) + .padding(horizontal = 14.dp, vertical = 10.dp) + .testTag("reorder-exercise-$entryId") + .semantics { + contentDescription = strings.getString(R.string.a11y_reorder_exercise) + customActions = buildList { + if (index > 0) add(CustomAccessibilityAction( + strings.getString(R.string.a11y_move_exercise_up), + ) { currentMove(currentIndex, currentIndex - 1); currentCommit(); true }) + if (index + 1 < itemCount) add(CustomAccessibilityAction( + strings.getString(R.string.a11y_move_exercise_down), + ) { currentMove(currentIndex, currentIndex + 1); currentCommit(); true }) + } + } + /* CONTRACT: entry_id, unlike position, remains stable while rows + * move. Keeping this pointer coroutine alive is what permits one + * gesture to cross any number of positions. */ + .pointerInput(entryId) { + val stepPx = 48.dp.toPx() + detectDragGestures( + onDragStart = { + dragOrigin = currentIndex + totalDragDistance = 0f + residualDragDistance = 0f + currentVisualOffset(0f) + }, + onDragEnd = { + dragOrigin = null + totalDragDistance = 0f + residualDragDistance = 0f + currentVisualOffset(0f) + currentCommit() + }, + onDragCancel = { + dragOrigin?.let { origin -> + if (currentIndex != origin) currentMove(currentIndex, origin) + } + dragOrigin = null + totalDragDistance = 0f + residualDragDistance = 0f + currentVisualOffset(0f) + }, + ) { change, amount -> + change.consume() + val origin = dragOrigin ?: currentIndex.also { dragOrigin = it } + totalDragDistance += amount.y + val target = reorderTargetIndex( + origin, + totalDragDistance, + stepPx, + currentItemCount, + ) + if (target != currentIndex) { + currentMove(currentIndex, target) + } + residualDragDistance = totalDragDistance - (target - origin) * stepPx + currentVisualOffset(residualDragDistance) + } + }, + style = TrainlogTypography.title.copy(color = LocalTrainlogColors.current.muted), + ) +} + +/** Deterministic direct destination for one continuous pointer gesture. */ +internal fun reorderTargetIndex( + origin: Int, + totalDragDistancePx: Float, + rowStepPx: Float, + itemCount: Int, +): Int { + if (itemCount <= 0 || rowStepPx <= 0f || !totalDragDistancePx.isFinite()) return origin + val crossedRows = kotlin.math.round(totalDragDistancePx / rowStepPx).toInt() + return (origin + crossedRows).coerceIn(0, itemCount - 1) +} + +/** Pure stable permutation used by gesture UI and identity regression tests. */ +internal fun reorderExerciseOccurrences( + items: List, + from: Int, + to: Int, +): List { + if (from !in items.indices || to !in items.indices || from == to) return items + return items.toMutableList().apply { add(to, removeAt(from)) } +} + +/** Keep the durable editor anchored to its stable entry after a permutation. */ +internal fun reorderActiveSessionDraft( + draft: ActiveSessionDraft, + from: Int, + to: Int, +): ActiveSessionDraft { + val reordered = reorderExerciseOccurrences(draft.exercises, from, to) + val editingIndex = draft.form.editingEntryId?.let { entryId -> + reordered.indexOfFirst { it.entryId == entryId }.takeIf { it >= 0 } + } + return draft.copy( + exercises = reordered, + form = draft.form.copy(editingExerciseIndex = editingIndex), + ) +} + private fun currentForm( exercise: ExerciseProfile, setCountText: String, @@ -1418,16 +1609,31 @@ private fun SessionNumberField( value: String, onValueChange: (String) -> Unit, testTag: String? = null, + decimal: Boolean = false, ) { TrainlogInputField( label = label, value = value, - onValueChange = - onValueChange, + onValueChange = { raw -> + if (!decimal || decimalEditorTextValid(raw)) onValueChange(raw) + }, testTag = testTag, + keyboardOptions = if (decimal) { + KeyboardOptions(keyboardType = KeyboardType.Decimal) + } else { + KeyboardOptions(keyboardType = KeyboardType.Number) + }, ) } +/** Permissive typing grammar; numeric/domain validation belongs to commit. */ +internal fun decimalEditorTextValid(raw: String): Boolean = + raw.matches(Regex("^-?(?:[0-9]+(?:[.,][0-9]*)?|[.,][0-9]*)?$")) + +/** Locale-independent finite parse shared by capture and correction forms. */ +internal fun parseFiniteDecimal(raw: String): Double? = + raw.trim().replace(',', '.').toDoubleOrNull()?.takeIf(Double::isFinite) + private const val MAX_SESSION_SETS = 64 private const val MAX_REPS_PER_SET = 10000 @@ -1506,20 +1712,49 @@ internal fun parseRawSetRows(rows: List): List? { } internal fun setRowValidationError(rows: List): String { - if (rows.isEmpty()) return "Ajoutez au moins une série." + if (rows.isEmpty()) return "set_required" rows.forEachIndexed { index, row -> val reps = row.repsText.trim().toIntOrNull() if (reps == null || reps !in 0..MAX_REPS_PER_SET) { - return "Série ${index + 1} : saisissez des répétitions entre 0 et $MAX_REPS_PER_SET." + return "set_reps:${index + 1}" } if (row.weightText.isNotBlank()) { val weight = row.weightText.trim().replace(',', '.').toDoubleOrNull() if (weight == null || !weight.isFinite() || weight < 0.0) { - return "Série ${index + 1} : saisissez une charge non négative ou laissez la case vide." + return "set_load:${index + 1}" } } } - return "Valeurs de séries invalides." + return "sets_invalid" +} + +private fun localizedSetRowValidationError(code: String, context: android.content.Context): String = when { + code == "set_required" -> context.getString(R.string.validation_set_required) + code.startsWith("set_reps:") -> context.getString(R.string.validation_set_reps, + code.substringAfter(':').toInt(), MAX_REPS_PER_SET) + code.startsWith("set_load:") -> context.getString(R.string.validation_set_load, + code.substringAfter(':').toInt()) + else -> context.getString(R.string.validation_sets_invalid) +} + +/** CONTRACT: repository diagnostics remain unchanged; this presentation-only + * classifier maps stable known outcomes without putting locale into storage. + * TECHNICAL_LITERAL: French strings below are legacy diagnostic classification + * keys and are never emitted directly to the user. */ +private fun localizedTargetError(raw: String?, context: android.content.Context): String = when (raw) { + "target_positive" -> context.getString(R.string.target_positive) + "max_unavailable" -> context.getString(R.string.max_unavailable) + "max_assistance_unavailable", "Le %MAX est indisponible pour une assistance ; choisissez une résistance externe." -> context.getString(R.string.max_assistance_unavailable) + "compatible_load_equipment_required" -> context.getString(R.string.compatible_load_equipment_required) + "positive_dose_required" -> context.getString(R.string.positive_dose_required) + "Le pourcentage doit être compris entre 1 et 100." -> context.getString(R.string.percent_range) + "MAX compatible indisponible : choisissez un équipement à résistance externe." -> context.getString(R.string.max_choose_external) + "Exercice introuvable." -> context.getString(R.string.exercise_not_found) + "Le %MAX est indisponible sans équipement connu à résistance externe." -> context.getString(R.string.max_known_external_required) + "MAX compatible indisponible pour cet exercice et cet équipement exacts." -> context.getString(R.string.max_exact_unavailable) + "Lecture du MAX impossible." -> context.getString(R.string.max_read_failed) + null -> context.getString(R.string.invalid_target_load) + else -> raw } private fun parseRepSequence( @@ -1851,11 +2086,14 @@ internal fun formForExistingExercise( distanceText = draft.distanceKm?.toString().orEmpty(), ) +@Composable private fun draftSummary( draft: SessionExerciseDraft, ): String { + val strings = localizedContext() + val locale = presentationLocale() return if (draft.maxWeightKg != null) { - "${draft.exercise.name} · Max : ${formatMaxWeight(draft.maxWeightKg)} kg" + strings.getString(R.string.draft_max_summary, draft.exercise.name, presentationNumber(draft.maxWeightKg, 3)) } else if ( draft.exercise.recordingMode == RecordingMode.CONTINUOUS @@ -1871,15 +2109,13 @@ private fun draftSummary( draft.speedKmh?.let { append( - " · %.1f km/h" - .format(it) + " · %.1f km/h".format(locale, it) ) } draft.distanceKm?.let { append( - " · %.2f km" - .format(it) + " · %.2f km".format(locale, it) ) } } @@ -1902,16 +2138,16 @@ private fun draftSummary( ( "${draft.exercise.name} · " + "${reps.size} × " + - "${reps.first()} reps" + "${reps.first()} ${strings.getString(R.string.reps_short)}" ) } else { ( "${draft.exercise.name} · " + - "${reps.size} séries · " + + strings.resources.getQuantityString(R.plurals.performed_set_count, reps.size, reps.size) + " · " + draft.sets.joinToString(separator = " ; ") { set -> buildString { - append("${set.reps} reps") - set.weightKg?.let { append(" @ ${formatMaxWeight(it)} kg") } + append("${set.reps} ${strings.getString(R.string.reps_short)}") + set.weightKg?.let { append(" @ ${formatPresentationNumber(it, locale)} kg") } } } ) @@ -1929,11 +2165,15 @@ private fun draftSummary( } private fun formatMaxWeight(value: Double): String = - java.math.BigDecimal.valueOf(value) - .stripTrailingZeros() - .toPlainString() - .replace('.', ',') + java.math.BigDecimal.valueOf(value).stripTrailingZeros().toPlainString().replace('.', ',') +private fun formatPresentationNumber(value: Double, locale: java.util.Locale): String = + java.text.NumberFormat.getNumberInstance(locale).apply { + isGroupingUsed = false + maximumFractionDigits = 3 + }.format(value) + +@Composable private fun exerciseProfileLabel( exercise: ExerciseProfile, ): String { @@ -1943,9 +2183,9 @@ private fun exerciseProfileLabel( exercise.recordingMode == RecordingMode.CONTINUOUS ) { - "CONTINU" + uiString(R.string.profile_continuous) } else { - "SERIES" + uiString(R.string.profile_sets) } ) @@ -1956,9 +2196,9 @@ private fun exerciseProfileLabel( exercise.trackingMode == TrackingMode.REPS ) { - "REPS" + uiString(R.string.profile_reps) } else { - "DUREE" + uiString(R.string.profile_duration) } ) @@ -1967,7 +2207,7 @@ private fun exerciseProfileLabel( ExerciseDataFields .SPEED_KMH != 0 ) { - append(" · VITESSE") + append(uiString(R.string.profile_speed_suffix)) } if ( @@ -1975,7 +2215,7 @@ private fun exerciseProfileLabel( ExerciseDataFields .DISTANCE_KM != 0 ) { - append(" · DISTANCE") + append(uiString(R.string.profile_distance_suffix)) } } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/StatisticsScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/StatisticsScreen.kt index 44278c2..b15aaaf 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/StatisticsScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/StatisticsScreen.kt @@ -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(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) { 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, 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 } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt index a1de1fd..85a3b47 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt @@ -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( diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 3ec4f49..f2737f9 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -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)) } }, ) } diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt index 7bed439..ead21cd 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogComponents.kt @@ -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( diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..a52a1de --- /dev/null +++ b/android/app/src/main/res/values-en/strings.xml @@ -0,0 +1,399 @@ + + + Trainlog + Home + Sessions + Exercises + Equipment + Statistics + Synchronization + Settings + Current session + Plan a session + Drafts + Completed sessions + Session details + Edit session + Exercise details + Edit exercise + Create exercise + Equipment details + Create equipment + Body measurements + Latest MAX + Open navigation + Back + Navigation + LANGUAGE + Language + Français + English + EXCHANGE FOLDER + Settings + Cancel + Keep and leave + Discard proposal + Discard changes + Unaccepted proposal + Unsaved changes + The proposal can remain in memory while you visit another section. + The fields remain in memory until you explicitly discard them. + Delete this exercise + Delete the current session + Delete this set + Remove the machine from this exercise + Delete this draft + Session + Status and actions + Body tracking + File access granted. + File access is still required. + Exchange folder: Documents/Trainlog\nFile access: granted + File access required + Open access settings + Authorize + Allow access to the Trainlog folder in Android. + Reload PC catalog + Explicitly apply the catalog in the authorized folder. + No PC catalog received. + The folder is no longer authorized. + Trainlog folder not authorized. + Synchronization complete · Android catalog: %1$s. + PC synchronization failed. Check the PC log for diagnostics. + PC synchronization completed, but the received catalog could not be found. + PC synchronization completed, but the Trainlog folder is not authorized. + PC synchronization completed; Android import: %1$s + The PC did not respond within 60 seconds. + File access granted · Documents/Trainlog is ready. + File access is required. Enable the permission in Android. + Access granted · PC catalog: %1$s. + Access granted · no PC catalog received. + Synchronize + The Android snapshot is maintained automatically. + Synchronization in progress… + Synchronize now + Android → PC then PC → Android, in one operation. + File access is required. Authorize Documents/Trainlog first. + Request sent · waiting for the PC… + Android is not supported. + EXCHANGE FOLDER + Authorize access to the Trainlog folder + Allow access to manage all files in Android. + Manual recovery action if needed. + PC catalog: %1$s. + File access is required for Documents/Trainlog. + Status + Android → PC preparation: %1$s + %d new%d new + %d reconciled%d reconciled + %d present%d present + No sessions recorded. + No explicit MAX recorded. + Equipment: %1$s + none + TRAINING + MAX TEST + %d exercise%d exercises + MM/dd/yyyy h:mm a + MM/dd/yyyy + Previous profile interpretations are preserved. The identifiers of the session and retained occurrences do not change. + Target set count + Target repetitions + Target duration per set (s) + Rest (s) + Target load (kg) + Explicit MAX (kg) + Continuous duration (s) + Speed (km/h) + Distance (km) + Repetitions + Duration (s) + Load (kg) + Set %1$d + + Add a set + Explicitly add a performed fact. + Delete this occurrence + Save + Apply the entire correction in one transaction. + Leave without changing the database. + Delete this set? + This performed fact will disappear from the corrected session. + Delete + Delete this occurrence? + Delete this occurrence + %d performed set%d performed sets + Impact: %1$s%2$s%3$s. Its feedback and revisions will be deleted with their parent. + , continuous activity + , MAX + Session not found. + Feedback dictation + ● Recording + Stop dictation + Text + Resume dictation + Start dictation + Save changes + Create a new immutable revision. + Create an immutable historical record. + Save nothing. + Microphone permission denied: manual input remains available. + Speech recognition is unavailable: use manual input. + Empty feedback cannot be saved. + No session in progress. + Prepare + New manual session + Create or resume + %d prepared session%d prepared sessions + No imported drafts. + Delete this draft? + CAPABILITIES / MAX + Equipment catalog + Catalog + No equipment found. + Load: %1$s + Possible exercises + No verified association. Anatomy is not inferred from the machine name. + Folder authorized · %1$s. + Catalog reloaded · %1$s. + Save body measurements + Body measurements saved. + Local database error. + No measurements recorded. + Save exercise + Existing exercises + No exercises saved. + Edit · %1$s + View latest MAX + No rule depends on the name. + Change tracking mode? + Edit + Exercise not found. + Loading profile… + Primary muscles: %1$s + Secondary muscles: %1$s + Stabilizers: %1$s + Unclassified + None established + No exercises found. + Edit the name, profile, and zones according to the existing rules. + No scientific knowledge is linked to this identifier. + Open the existing aggregated list in Statistics. + None + Duration + Speed + Distance + Error + Draft not saved: %1$s + Draft not saved + Latest change not saved. + Session saved locally. + Normal training session. + Session explicitly dedicated to a MAX measurement. + No exercises added. + No performed sets entered: add the values actually performed. + Edit %1$s + Correct the text without changing the observation. + Exercise added to the session. + Exercise edited. + Save session + Exercise + No exercises. + Choose an exercise + Exercise selected. Tap to show suggestions. + Search for an exercise… + No exercises found + Cancel search + Keep %1$s and the entered values. + ENTRY — %1$s + Planned target load — separate from loads actually performed. + Value in kg + %% of my MAX + Percentage of my MAX (1 to 100) + Compatible MAX: %1$s kg · %2$s + No target load will be saved. + Set %1$d — Repetitions + Load is optional; both decimal separators are accepted. An empty field is not zero. + Number of sets + Duration per set (seconds) + Duration (minutes) + Speed km/h + Distance km + Invalid target load. + Cancel entry + Delete set %1$d? + Type: %1$s + Resume this MAX test + Continue the same session while preserving its identifier and date. + Equipment: %1$s + Plan: %1$d × %2$s · rest %3$d s · %4$s + target load %1$s kg + no numeric load + Search for a machine + Edit equipment + Choose, replace, or remove the machine for this entry. + MAX: %1$s kg + · Edited + Correct the text without moving the observation. + Add feedback + Post-session follow-up + Correct the text without changing the H+ marker. + Add a record + Remove the machine from this entry? + Replace the associated machine? + The currently recorded machine association will be lost. Sets, MAX, and feedback remain unchanged. + Remove + Replace + Mode: sets · repetitions + Mode: sets · duration + Assistance (kg) + Set | Repetitions | %1$s + Set %1$d: %2$s + Mode: continuous + Duration: %1$s + Speed: %1$.1f km/h + Distance: %1$.2f km + %d min%d min + %d sec%d sec + 7 days30 days90 days1 yearAll + Some invalid historical data was ignored; valid statistics remain available. + EXPLOREDistribution + Zones and exercise usage, only when they are classified. + Global summaryCautious indicators derived from available data. + SUMMARY · %1$sSetsExplicit MAX + PROGRESSIONInsufficient data to establish comparable progress. + %1$d training improvements · %2$d MAX records%1$d explicit MAX · open details + No body measurements in this period.1 measurement · trend unavailable + FREQUENCYCurrent week · %1$s · %2$d MAX included + MAX values mark sessions; they are never added a second time. + ← Back to dashboardNo comparable data in this period. + %d session%d sessions + Each detail row preserves a canonical exercise, comparable equipment, and mode; the dashboard never adds kilograms together. + No interpolation: only measurements actually recorded are shown. + Frequencysessions + Number of sessions with actual work per week; MAX is a marker, not a second session. + Detailed distribution appears when classified occurrences are available in this view. + %1$d sessions · %2$d sets · %3$d exercises · %4$d explicit MAX + Body zoneGoal + Custom duration (%1$d to %2$d min) + GenerateAnalyze history read-only and prepare the proposal. + Duration must be between %1$d and %2$d minutes. + Target duration: %1$d min · estimate: %2$d min + The proposal is significantly shorter than the target; no exercise is added merely to fill time. + Warm-up and cool-down are not generated in V1. + Informational warning: history shows recent exposure. This does not measure physiological recovery. + Continue despite warningKeep the proposal and allow it to be accepted. + Equipment: %1$sTarget load: no numeric load proposed.Target load: %1$s kg + Similar recent history detected; informational only. + SetsLoad: user choice, never a recommendation. + AutomaticNoneApply + Edit targetEdit sets, repetitions, rest, or load. + RegenerateAccept and enter actual values + Cancel proposalReturn without writing a draft. + ProcessingAnalysis in progress…Message + Resume%1$s · the durable draft is preserved. + Session proposalPlanned for %1$s + %d repetition%d repetitions + StartCopy this proposal into the current session. + A session is already in progress. The imported draft was preserved. + Draft “%1$s” will be deleted.\nThis action cannot be undone. + TrainingResume current session + Explicitly create a durable draft.Latest sessionQuick access + Zones to workRecent exposureNo training history yet. + Insufficient data: a historical timestamp could not be interpreted. + Selection: %1$sPrioritiesNo zone with a resolved exercise is available. + These colors describe recently recorded work. They do not measure physiological recovery.Got it + View exercisesView statistics + Session type[✓] Training[ ] Training + Edit this exercise without removing it from the session.Editor opened for %1$s — prefilled fields appear below. + Editing — scroll to ENTRY — %1$s.Feedback · %1$s%2$s + Dictate or enter feedback now, before the session ends.Feedback saved in the current session. + Create a new exerciseFinalization failed, draft preserved: %1$s + %1$s in the session.Discard the current session? + This will delete the draft, its %1$s, and all their recorded data. + Remove “%1$s” from the session?This occurrence will be removed. + the continuous activitythe MAX value + Create contextTemporary compatibility for a custom or unresolved exercise. + This context already exists.Context not created: %1$sEquipment selected. + Strictly positive value; both decimal separators are accepted.Calculated target: %1$s kg + Each set retains its own repetitions and load.Add a set + Add an empty row without changing the other sets.Add to session + Its entered repetitions, duration, and load will be deleted. + Add at least one set.Set %1$d: enter repetitions between 0 and %2$d. + Set %1$d: enter a non-negative load or leave the field empty.Invalid set values. + Exercise removed from the session. + Enter a strictly positive target load.Compatible MAX unavailable. + %%MAX is unavailable for assistance; choose external resistance. + Choose equipment compatible with a target load.Define a positive target dose before the target load. + Percentage must be between 1 and 100.Unable to read MAX. + Compatible MAX unavailable: choose external-resistance equipment.%%MAX is unavailable without known external-resistance equipment.Compatible MAX unavailable for this exact exercise and equipment. + SetsSupplemental dataNot specifiedNot specified + Past sessions and the occurrence already in the draft remain unchanged. The new profile applies to future uses. + Create and return to sessionPreserve identity and update the catalog. + An exercise with this name already exists.Another exercise already has this name.The profile cannot change after use. + Prefix searchPrevious sessions and the active draft remain unchanged. New uses follow the new profile. + Transition confirmed. Save to apply the profile to future uses. + Scientific classification unresolved.Conditional interpretation · confirm: %1$s + Movements: %1$sCompatible equipment: %1$sNot established + Add a profile to the local catalog.Compatible equipmentCompatibility not established. + Latest result: %1$s kg · %2$sScientific classification · %1$s confidence + Conditional classification · explicit uncertaintyhighmoderatelow + Zone: Not specified + uncertainPrimary zone: %1$s · Secondary zones: %1$s · Group: %1$s + GeneralStrengthHypertrophyLocal endurance + Target load must be a finite positive number.Proposal + Coverage is incomplete because resolved contexts are unavailable%1$s + Observed exposure: %1$d primary and %2$d secondary sets over 24 h; %3$d primary and %4$d secondary over 72 h.Latest observed exposure: %1$s + Load derived from a dose actually observed on %1$s; its applicability today remains uncertain. + MAX percentage (1 to 100)Manual target load (empty = automatic) + Re-estimate duration and reclassify the observed load.Move upMove this exercise before the previous one.Move downMove this exercise after the next one. + Run again with the same inputs and reference instant.Create the normal draft without prefilling performed sets.Confirm the recent-exposure warning first. + observed repeated doseobserved maximum without numeric prescriptionuser-selected MAX percentagecompatible MAX unavailableassistance load omittednumeric load absentmanually entered loadpreferred exerciserequested primary zonerequested secondary zonedistinct movementsame exercise observed recentlysimilar movement observed recently + missing upper regionmissing lower regionmissing coremissing upper-body pushmissing upper-body pullmissing lower-body extensionmissing lower-body flexionfewer distinct exercises available + Create equipmentAdd a personal machine.Search by name, label, or alias + Unknown reference · %1$sDefinitionPersonalProvidedLabel: %1$s + %1$s confidenceVerified sources: %1$dPersonal equipmentCreate + Create a personal machine with the current semantics.Enter a name of 1 to 120 characters.Equipment already has this name.Creation failed: %1$s. + no loadexternal loadassistancebodyweightcardio + A session is already in progress. Resume it or return to the preserved proposal.no numeric load proposed + This incompatible change will replace:\n- %1$s\nFeedback remains attached to the same entry_id.Set %1$d — %2$s + , selected, not selectedSelect %1$s + Progress over six weeks: no comparable event, six empty pointsProgress over six weeks: %1$s events + Not enough data for a chart yet.%1$d comparable series · open details + GeneralShouldersEmpty fields are ignored.Latest measurements + PrioritizeLittle recent workRecent workHigh recent exposureInsufficient dataNo resolved exercise availableRelative priority + No primary exposure recordedLatest primary work: %1$sPrimary work over 7 days: %1$dSecondary work over 7 days: %1$d + never recordedtoday1 day ago%d day ago%d days ago + Latest primary work: %1$s + Frequency over six weeks: %1$s%1$s %2$s including %3$d MAXcurrent + WeightTarget: %1$d × %2$s · rest %3$d sPrimary zone: %1$sMovement: %1$sReasons: %1$sRest (seconds) + NamePrimary zoneSecondary zonesAdd this profile to the local catalog.Invalid profile or name.Invalid name or profile.Filter by zoneAll zones + Confidence: %1$sScientific zones: %1$s%2$s · secondary: %1$sLimitation: %1$sSource: %1$s · %2$s%3$sExercise catalogFilterProfile + Latest MAX · %1$s · %2$s kg · %3$sType: %1$sAliases: %1$sMAX weight (kg)Add this entry to the draft. + NeckChestWaistHipsLimbs + Left armRight armLeft forearmRight forearmLeft thighRight thighLeft calfRight calfRecording + Body map, %1$sFrontBack + %1$s · %2$d × %3$s%4$s · rest %5$d sPossible exercises + ActionsChange %1$s’s profile?Historical context (optional)New historical context + OrganizationContinuousPrimary measurementReturn to the catalog without changes.ContractKnowledge + Remove this exercise from the proposalRecognition interrupted (code %1$d). + This draft is no longer available.A value is invalid.Add at least one positive measurement.%d measurement%d measurements%d exercise available%d exercises available + [✓] MAX test[ ] MAX testFeedback (%1$d) · + Add feedbackThis will delete:\n- %1$stargets and rest + Enter a valid context name.Enter a strictly positive MAX weight (e.g. 100 or 86.5).Invalid values.Return to catalog. + New exerciseA primary zone is required for a new muscular exercise. + 🎙 Feedback%1$s · MAX: %2$s kgrepsCONTINUOUSSETSREPSDURATION + %d point%d points + Unable to save to the database.+ Knowledge− Knowledge + %d second%d secondsDiscard + %d feedback entry%d feedback entriesDuring the session + · speed · distance + Full bodyUpper bodyChestBackShouldersArmsAbdominals / coreLower bodyGlutesThighsCalves + performed loadsexplicit MAX + The requested item is no longer available.The requested data is invalid.The database operation failed.File access is required.The operation failed. + The exercise being entered no longer exists; only the selection was cleared. Partial input and added exercises were preserved. + Reorder exercise + Move exercise up + Move exercise down + Session order saved. + Reorder was not saved: %1$s + Enter a valid decimal value using “,” or “.”. + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 31da2ce..b1c7c5e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,399 @@ Trainlog + Accueil + Séances + Exercices + Équipements + Statistiques + Synchronisation + Paramètres + Séance en cours + Programmer une séance + Brouillons + Séances effectuées + Détail de la séance + Modifier la séance + Fiche exercice + Modifier l’exercice + Créer un exercice + Fiche équipement + Créer un équipement + Mensurations + Derniers MAX + Ouvrir la navigation + Retour + Navigation + LANGUE + Langue + Français + English + DOSSIER D’ÉCHANGE + Paramètres + Annuler + Conserver et quitter + Abandonner la proposition + Abandonner les modifications + Proposition non acceptée + Modifications non enregistrées + La proposition peut rester en mémoire pendant que vous changez de rubrique. + Les champs restent en mémoire tant que vous ne les abandonnez pas explicitement. + Supprimer cet exercice + Supprimer la séance en cours + Supprimer cette série + Retirer la machine de cet exercice + Supprimer ce brouillon + Séance + État et actions + Suivi corporel + Accès fichiers autorisé. + Accès fichiers toujours requis. + Dossier d’échange : Documents/Trainlog\nAccès fichiers : autorisé + Accès fichiers requis + Ouvrir les réglages d’accès + Autoriser + Autoriser l’accès au dossier Trainlog dans Android. + Relire le catalogue PC + Appliquer explicitement le catalogue présent dans le dossier autorisé. + Aucun catalogue PC reçu. + Le dossier n’est plus autorisé. + Dossier Trainlog non autorisé. + Synchronisation terminée · Catalogue Android : %1$s. + La synchronisation PC a échoué. Consultez le journal du PC pour le diagnostic. + Synchronisation PC terminée, mais catalogue reçu introuvable. + Synchronisation PC terminée, dossier Trainlog non autorisé. + Synchronisation PC terminée, import Android : %1$s + Le PC n’a pas répondu dans les 60 secondes. + Accès fichiers autorisé · Documents/Trainlog prêt. + Accès fichiers requis. Activez l’autorisation dans Android. + Accès autorisé · catalogue PC : %1$s. + Accès autorisé · aucun catalogue PC reçu. + Synchroniser + Le snapshot Android est maintenu automatiquement. + Synchronisation en cours… + Synchroniser maintenant + Android → PC puis PC → Android, en une seule opération. + Accès fichiers requis. Autorisez d’abord Documents/Trainlog. + Demande envoyée · attente du PC… + Android non supporté. + DOSSIER D’ÉCHANGE + Autoriser l’accès au dossier Trainlog + Autoriser l’accès pour gérer tous les fichiers dans Android. + Action de récupération manuelle si nécessaire. + Catalogue PC : %1$s. + Accès fichiers requis pour Documents/Trainlog. + État + Préparation Android → PC : %1$s + %d nouveau%d nouveaux + %d réconcilié%d réconciliés + %d présent%d présents + Aucune séance enregistrée. + Aucun MAX explicite enregistré. + Équipement : %1$s + aucun + ENTRAÎNEMENT + TEST MAX + %d exercice%d exercices + dd/MM/yyyy HH:mm + dd/MM/yyyy + Les anciennes interprétations de profil sont conservées. Les identifiants de la séance et des occurrences retenues ne changent pas. + Nombre de séries cible + Répétitions cibles + Durée cible par série (s) + Repos (s) + Charge cible (kg) + MAX explicite (kg) + Durée continue (s) + Vitesse (km/h) + Distance (km) + Répétitions + Durée (s) + Charge (kg) + Série %1$d + + Ajouter une série + Ajouter explicitement un fait réalisé. + Supprimer cette occurrence + Enregistrer + Appliquer toute la correction dans une transaction. + Quitter sans modifier la base. + Supprimer cette série ? + Ce fait réalisé disparaîtra de la séance corrigée. + Supprimer + Supprimer cette occurrence ? + Supprimer cette occurrence + %d série réalisée%d séries réalisées + Impact : %1$s%2$s%3$s. Ses ressentis et révisions seront supprimés avec leur parent. + , activité continue + , MAX + Séance introuvable. + Dictée du ressenti + ● Enregistrement en cours + Arrêter la dictée + Texte + Reprendre la dictée + Démarrer la dictée + Enregistrer les modifications + Créer une nouvelle révision immuable. + Créer un enregistrement historique immuable. + Ne rien enregistrer. + Microphone refusé : la saisie manuelle reste disponible. + Reconnaissance vocale indisponible : utilisez la saisie manuelle. + Un ressenti vide ne peut pas être enregistré. + Aucune séance en cours. + Préparer + Nouvelle séance manuelle + Créer ou reprendre + %d séance préparée%d séances préparées + Aucun brouillon importé. + Supprimer ce brouillon ? + CAPACITÉS / MAX + Catalogue d’équipements + Catalogue + Aucun équipement trouvé. + Charge : %1$s + Exercices possibles + Aucune association vérifiée. L’anatomie n’est pas déduite du nom de la machine. + Dossier autorisé · %1$s. + Catalogue relu · %1$s. + Enregistrer les mensurations + Mensurations enregistrées. + Erreur de base locale. + Aucun relevé enregistré. + Enregistrer l’exercice + Exercices existants + Aucun exercice enregistré. + Modifier · %1$s + Voir les derniers MAX + Aucune règle ne dépend du nom. + Modifier le mode de suivi ? + Modifier + Exercice introuvable. + Chargement du profil… + Muscles principaux : %1$s + Muscles secondaires : %1$s + Stabilisateurs : %1$s + Non classés + Aucun établi + Aucun exercice trouvé. + Modifier le nom, le profil et les zones selon les règles existantes. + Aucune connaissance scientifique liée à cet identifiant. + Ouvrir la liste agrégée existante dans Statistiques. + Aucune + Durée + Vitesse + Distance + Erreur + Brouillon non sauvegardé : %1$s + Brouillon non sauvegardé + Dernière modification non sauvegardée. + Séance sauvegardée localement. + Séance normale de travail. + Séance explicitement dédiée à une mesure de MAX. + Aucun exercice ajouté. + Aucune série réalisée saisie : ajoutez les valeurs réellement effectuées. + Modifier %1$s + Corriger le texte sans changer l’observation. + Exercice ajouté à la séance. + Exercice modifié. + Enregistrer la séance + Exercice + Aucun exercice. + Choisir un exercice + Exercice sélectionné. Appuyer pour afficher les suggestions. + Rechercher un exercice… + Aucun exercice trouvé + Annuler la recherche + Conserver %1$s et les valeurs saisies. + SAISIE — %1$s + Charge cible prévue — séparée des charges réellement effectuées. + Valeur en kg + %% de mon MAX + Pourcentage de mon MAX (1 à 100) + MAX compatible : %1$s kg · %2$s + Aucune charge cible ne sera enregistrée. + Série %1$d — Répétitions + Charge facultative ; les deux séparateurs décimaux sont acceptés. Une case vide n’est pas zéro. + Nombre de séries + Durée par série (secondes) + Durée (minutes) + Vitesse km/h + Distance km + Charge cible invalide. + Annuler la saisie + Supprimer la série %1$d ? + Type : %1$s + Reprendre ce test MAX + Continuer la même séance en conservant son identifiant et sa date. + Équipement : %1$s + Plan : %1$d × %2$s · repos %3$d s · %4$s + charge cible %1$s kg + sans charge numérique + Rechercher une machine + Modifier l’équipement + Choisir, remplacer ou retirer la machine de cette entrée. + MAX : %1$s kg + · Modifié + Corriger le texte sans déplacer l’observation. + Ajouter un ressenti + Suivi après séance + Corriger le texte sans changer le repère H+. + Ajouter un enregistrement + Retirer la machine de cette entrée ? + Remplacer la machine associée ? + L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés. + Retirer + Remplacer + Mode : séries · répétitions + Mode : séries · durée + Assistance (kg) + Série | Répétitions | %1$s + Série %1$d : %2$s + Mode : continu + Durée : %1$s + Vitesse : %1$.1f km/h + Distance : %1$.2f km + %d min%d min + %d s%d s + 7 j30 j90 j1 anTout + Certaines données historiques invalides ont été ignorées ; les statistiques valides restent disponibles. + EXPLORERRépartition + Zones et utilisation des exercices, uniquement lorsqu’elles sont classifiées. + Résumé globalIndicateurs prudents dérivés des données disponibles. + RÉSUMÉ · %1$sSériesMAX explicites + PROGRESSIONDonnées insuffisantes pour établir une progression comparable. + %1$d progressions de travail · %2$d records MAX%1$d MAX explicites · ouvrir le détail + Aucune mensuration dans cette période.1 relevé · tendance indisponible + FRÉQUENCESemaine actuelle · %1$s · %2$d MAX inclus + Les MAX signalent des séances ; ils ne sont jamais additionnés une seconde fois. + ← Retour au tableau de bordAucune donnée comparable dans cette période. + %d séance%d séances + Chaque ligne de détail conserve un exercice canonique, un équipement et un mode comparables ; le tableau de bord ne somme jamais des kg. + Aucune interpolation : seuls les relevés réellement enregistrés sont affichés. + Fréquenceséances + Nombre de séances avec travail réel par semaine ; MAX est un marqueur, pas une seconde séance. + La répartition détaillée arrive lorsque les occurrences classifiées sont disponibles dans cette vue. + %1$d séances · %2$d séries · %3$d exercices · %4$d MAX explicites + Zone corporelleObjectif + Durée personnalisée (%1$d à %2$d min) + GénérerAnalyser l’historique en lecture seule et préparer la proposition. + La durée doit être comprise entre %1$d et %2$d minutes. + Durée cible : %1$d min · estimation : %2$d min + La proposition est nettement plus courte que la cible ; aucun exercice n’est ajouté pour remplir artificiellement le temps. + Échauffement et retour au calme ne sont pas générés en V1. + Attention informative : l’historique montre une exposition récente. Cela ne mesure pas la récupération physiologique. + Continuer malgré l’avertissementConserver la proposition et autoriser son acceptation. + Équipement : %1$sCharge cible : aucune charge numérique proposée.Charge cible : %1$s kg + Historique récent similaire détecté ; information uniquement. + SériesCharge : choix utilisateur, jamais une recommandation. + AutomatiqueAucuneAppliquer + Modifier la cibleModifier séries, répétitions, repos ou charge. + RégénérerAccepter et saisir les valeurs réelles + Annuler la propositionRevenir sans écrire de brouillon. + TraitementAnalyse en cours…Message + Reprendre%1$s · le brouillon durable est conservé. + Proposition de séancePrévue le %1$s + %d répétition%d répétitions + DémarrerCopier cette proposition dans la séance en cours. + Une séance est déjà en cours. Le brouillon importé a été conservé. + Le brouillon « %1$s » sera supprimé.\nCette action est irréversible. + EntraînementReprendre la séance en cours + Créer explicitement un brouillon durable.Dernière séanceAccès rapides + Zones à travaillerExposition récentePas encore d’historique d’entraînement. + Données insuffisantes : un horodatage historique ne peut pas être interprété. + Sélection : %1$sPrioritésAucune zone disposant d’un exercice résolu n’est disponible. + Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.Compris + Voir les exercicesVoir les statistiques + Type de séance[✓] Entraînement[ ] Entraînement + Corriger cet exercice sans le supprimer de la séance.Éditeur ouvert pour %1$s — les champs préremplis apparaissent ci-dessous. + Modification en cours — faites défiler jusqu’à SAISIE — %1$s.Ressenti · %1$s%2$s + Dicter ou saisir un ressenti maintenant, avant la fin de la séance.Ressenti sauvegardé dans la séance en cours. + Créer un nouvel exerciceÉchec de finalisation, brouillon conservé : %1$s + %1$s dans la séance.Abandonner la séance en cours ? + Cette action supprimera le brouillon, ses %1$s et toutes leurs données enregistrées. + Supprimer « %1$s » de la séance ?Cette occurrence sera retirée. + l’activité continuela donnée MAX + Créer le contexteCompatibilité temporaire pour un exercice personnalisé ou non résolu. + Ce contexte existe déjà.Contexte non créé : %1$sÉquipement sélectionné. + Valeur strictement positive ; les deux séparateurs décimaux sont acceptés.Cible calculée : %1$s kg + Chaque série conserve ses propres répétitions et sa propre charge.Ajouter une série + Ajouter une ligne vide sans modifier les autres séries.Ajouter à la séance + Ses répétitions, sa durée et sa charge saisies seront supprimées. + Ajoutez au moins une série.Série %1$d : saisissez des répétitions entre 0 et %2$d. + Série %1$d : saisissez une charge non négative ou laissez la case vide.Valeurs de séries invalides. + Exercice retiré de la séance. + Saisissez une charge cible strictement positive.MAX compatible indisponible. + Le %%MAX est indisponible pour une assistance ; choisissez une résistance externe. + Choisissez un équipement compatible avec une charge cible.Définissez une dose cible positive avant la charge cible. + Le pourcentage doit être compris entre 1 et 100.Lecture du MAX impossible. + MAX compatible indisponible : choisissez un équipement à résistance externe.Le %%MAX est indisponible sans équipement connu à résistance externe.MAX compatible indisponible pour cet exercice et cet équipement exacts. + SériesDonnées complémentairesNon renseignéeNon renseignés + 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. + Créer et revenir à la séanceConserver l’identité et mettre à jour le catalogue. + Un exercice portant ce nom existe déjà.Un autre exercice porte déjà ce nom.Le profil ne peut pas changer après utilisation. + Recherche par préfixeLes anciennes séances et le brouillon actif resteront inchangés. Les nouvelles utilisations suivront le nouveau profil. + Transition confirmée. Enregistrez pour appliquer le profil aux prochaines utilisations. + Classification scientifique non résolue.Interprétation conditionnelle · à confirmer : %1$s + Mouvements : %1$sÉquipement compatible : %1$sNon établi + Ajouter un profil au catalogue local.Équipements compatiblesCompatibilité non établie. + Dernier résultat : %1$s kg · %2$sClassification scientifique · confiance %1$s + Classification conditionnelle · incertitude expliciteélevéemodéréefaible + Zone : Non renseignée + incertaineZone principale : %1$s · Zones secondaires : %1$s · Groupe : %1$s + GénéralForceHypertrophieEndurance locale + La charge cible doit être un nombre positif fini.Proposition + La couverture est incomplète faute de contextes résolus disponibles%1$s + 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.Dernière exposition observée : %1$s + Charge issue d’une dose réellement observée le %1$s ; son applicabilité aujourd’hui reste incertaine. + Pourcentage du MAX (1 à 100)Charge cible manuelle (vide = automatique) + Réestimer la durée et requalifier la charge observée.MonterDéplacer cet exercice avant le précédent.DescendreDéplacer cet exercice après le suivant. + Relancer les mêmes entrées et le même instant de référence.Créer le brouillon normal sans préremplir les séries réalisées.Confirmez d’abord l’avertissement d’exposition récente. + dose répétée observéemaximum observé sans prescription numériquepourcentage de MAX choisi par l’utilisateurMAX compatible indisponiblecharge d’assistance omisecharge numérique absentecharge saisie manuellementexercice préférézone principale demandéezone secondaire demandéemouvement distinctmême exercice observé récemmentmouvement similaire observé récemment + région supérieure manquanterégion inférieure manquantetronc manquantpoussée du haut du corps manquantetirage du haut du corps manquantextension du bas du corps manquanteflexion du bas du corps manquantemoins d’exercices distincts disponibles + Créer un équipementAjouter une machine personnelle.Rechercher par nom, étiquette ou alias + Référence inconnue · %1$sDéfinitionPersonnelFourniÉtiquette : %1$s + Confiance %1$sSources vérifiées : %1$dÉquipement personnelCréer + Créer une machine personnelle avec la sémantique actuelle.Saisissez un nom de 1 à 120 caractères.Un équipement porte déjà ce nom.Création impossible : %1$s. + sans chargecharge externeassistancepoids du corpscardio + Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée.aucune charge numérique proposée + Ce changement incompatible remplacera :\n- %1$s\nLes ressentis restent attachés au même entry_id.Série %1$d — %2$s + , sélectionnée, non sélectionnéeSélectionner %1$s + Progression sur six semaines : aucun événement comparable, six points videsProgression sur six semaines : %1$s événements + Pas encore assez de données pour une courbe.%1$d séries comparables · ouvrir le détail + GénéralÉpaulesLes champs vides sont ignorés.Derniers relevés + À privilégierPeu travaillé récemmentTravaillé récemmentForte exposition récenteDonnées insuffisantesAucun exercice résolu disponiblePriorité relative + Aucune exposition principale enregistréeDernier travail principal : %1$sTravail principal sur 7 j : %1$dTravail secondaire sur 7 j : %1$d + jamais enregistréeaujourd’huiil y a 1 jouril y a %d jouril y a %d jours + Dernier travail principal : %1$s + Fréquence sur six semaines : %1$s%1$s %2$s dont %3$d MAXactuelle + PoidsCible : %1$d × %2$s · repos %3$d sZone principale : %1$sMouvement : %1$sRaisons : %1$sRepos (secondes) + NomZone principaleZones secondairesAjouter ce profil au catalogue local.Profil ou nom invalide.Nom ou profil invalide.Filtre par zoneToutes les zones + Confiance : %1$sZones scientifiques : %1$s%2$s · secondaires : %1$sLimite : %1$sSource : %1$s · %2$s%3$sCatalogue d’exercicesFiltrerProfil + Dernier MAX · %1$s · %2$s kg · %3$sType : %1$sAlias : %1$sPoids MAX (kg)Ajouter cette saisie au brouillon. + CouPoitrineTour de tailleHanchesMembres + Bras gaucheBras droitAvant-bras gaucheAvant-bras droitCuisse gaucheCuisse droiteMollet gaucheMollet droitEnregistrement + Carte corporelle, %1$sFaceDos + %1$s · %2$d × %3$s%4$s · repos %5$d sExercices possibles + ActionsChanger le profil de %1$s ?Contexte historique (optionnel)Nouveau contexte historique + OrganisationContinuMesure principaleRevenir au catalogue sans modification.ContratConnaissances + Retirer cet exercice de la propositionReconnaissance interrompue (code %1$d). + Ce brouillon n’est plus disponible.Une valeur est invalide.Ajoutez au moins une mesure positive.%d mesure%d mesures%d exercice disponible%d exercices disponibles + [✓] Test MAX[ ] Test MAXRessenti (%1$d) · + Ajouter un ressentiCette action supprimera :\n- %1$sles objectifs et le repos + Donnez un nom de contexte valide.Saisissez un poids MAX strictement positif (ex. 100 ou 86,5).Valeurs invalides.Revenir au catalogue. + Nouvel exerciceUne zone principale est requise pour un nouvel exercice musculaire. + 🎙 Ressenti%1$s · MAX : %2$s kgrép.CONTINUSÉRIESRÉP.DURÉE + %d point%d points + Enregistrement en base impossible.+ Connaissances− Connaissances + %d seconde%d secondesAbandonner + %d ressenti%d ressentisPendant la séance + · vitesse · distance + Corps entierMembres supérieursPectorauxDosÉpaulesBrasAbdominaux / troncMembres inférieursFessiersCuissesMollets + charges réaliséesMAX explicite + L’élément demandé n’est plus disponible.Les données demandées sont invalides.L’opération en base de données a échoué.L’accès aux fichiers est requis.L’opération a échoué. + 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. + Réordonner l’exercice + Déplacer l’exercice vers le haut + Déplacer l’exercice vers le bas + Ordre de la séance enregistré. + Réorganisation non enregistrée : %1$s + Saisissez une valeur décimale valide avec « , » ou « . ». diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt index 9903c6f..7cfa514 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainingFeedbackRepositoryTest.kt @@ -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, + ) } diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/ActiveSessionReorderTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/ActiveSessionReorderTest.kt new file mode 100644 index 0000000..1edc17a --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/ActiveSessionReorderTest.kt @@ -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 }) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt index b443088..bcdfab2 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/AiSessionDraftUiWiringTest.kt @@ -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() diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomePresentationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomePresentationTest.kt index 6dde12d..ec8fb8b 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomePresentationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/BodyZoneHomePresentationTest.kt @@ -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().createConfigurationContext( + Configuration(ApplicationProvider.getApplicationContext().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é")) } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/FieldInputRefinementTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/FieldInputRefinementTest.kt new file mode 100644 index 0000000..c592e26 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/FieldInputRefinementTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/I18nResourceContractTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/I18nResourceContractTest.kt new file mode 100644 index 0000000..7d73c45 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/I18nResourceContractTest.kt @@ -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.")) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/LanguageSettingsOwnerTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/LanguageSettingsOwnerTest.kt new file mode 100644 index 0000000..ee3e3ab --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/LanguageSettingsOwnerTest.kt @@ -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, + ) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/SessionGeneratorPreviewControllerTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/SessionGeneratorPreviewControllerTest.kt index 98e6336..48fe97b 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/SessionGeneratorPreviewControllerTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/SessionGeneratorPreviewControllerTest.kt @@ -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)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt index 82beefb..9bd3e5c 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/SessionSetRowEditorTest.kt @@ -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, ) } diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/StatisticsScreenPresentationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/StatisticsScreenPresentationTest.kt index 8cb4198..a58b654 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/StatisticsScreenPresentationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/StatisticsScreenPresentationTest.kt @@ -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() } diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt index 1bd5d2a..3335ff0 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/SyncBundlePublicationTest.kt @@ -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 diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/SyncReceiptPresentationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/SyncReceiptPresentationTest.kt new file mode 100644 index 0000000..27cee72 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/SyncReceiptPresentationTest.kt @@ -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) + } +} diff --git a/tests/test_session_exchange_v3.py b/tests/test_session_exchange_v3.py index ebc88df..eb8ece3 100644 --- a/tests/test_session_exchange_v3.py +++ b/tests/test_session_exchange_v3.py @@ -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"]: