feat(android): refine field workflows and bilingual UI
This commit is contained in:
parent
3200358641
commit
1674059203
36 changed files with 2790 additions and 840 deletions
|
|
@ -9,20 +9,26 @@ import android.os.Bundle
|
|||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.SyncExporter
|
||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.ui.TrainlogApp
|
||||
import com.labfytools.trainlog.ui.TrainlogAppState
|
||||
import com.labfytools.trainlog.ui.LanguagePresentation
|
||||
import com.labfytools.trainlog.ui.LanguageSettingsOwner
|
||||
import com.labfytools.trainlog.ui.LocalLanguagePresentation
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val appState by viewModels<TrainlogAppState>()
|
||||
private lateinit var languageSettings: LanguageSettingsOwner
|
||||
override fun onCreate(
|
||||
savedInstanceState: Bundle?
|
||||
) {
|
||||
super.onCreate(savedInstanceState)
|
||||
languageSettings = LanguageSettingsOwner(applicationContext)
|
||||
|
||||
val repository =
|
||||
TrainlogRepository(
|
||||
|
|
@ -47,15 +53,22 @@ class MainActivity : ComponentActivity() {
|
|||
)
|
||||
|
||||
setContent {
|
||||
TrainlogTheme {
|
||||
TrainlogApp(
|
||||
/* CONTRACT: reading this observable property makes a language
|
||||
* selection recompose the existing activity immediately. */
|
||||
val language = languageSettings.language
|
||||
CompositionLocalProvider(
|
||||
LocalLanguagePresentation provides LanguagePresentation(language, languageSettings::select),
|
||||
) {
|
||||
TrainlogTheme {
|
||||
TrainlogApp(
|
||||
repository = repository,
|
||||
exporter = exporter,
|
||||
inbox = inbox,
|
||||
requestOutbox =
|
||||
requestOutbox,
|
||||
appState = appState,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,11 +7,16 @@ package com.labfytools.trainlog.ui
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import com.labfytools.trainlog.data.CorrectCompletedSessionResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
|
|
@ -22,6 +27,7 @@ import com.labfytools.trainlog.model.SessionExerciseDetail
|
|||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
/**
|
||||
* WHY: a completed-session correction must be reviewed as one bounded form,
|
||||
|
|
@ -37,6 +43,7 @@ fun CompletedSessionCorrectionScreen(
|
|||
onFinished: (saved: Boolean) -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val original = remember(sessionId) { repository.getSessionDetail(sessionId) }
|
||||
var drafts by remember(sessionId) {
|
||||
mutableStateOf(original?.exercises?.map(SessionExerciseDetail::correctionDraft).orEmpty())
|
||||
|
|
@ -44,81 +51,116 @@ fun CompletedSessionCorrectionScreen(
|
|||
var pendingSetRemoval by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
var pendingOccurrenceRemoval by remember { mutableStateOf<Int?>(null) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
val decimalText = remember(sessionId) { mutableStateMapOf<String, String>() }
|
||||
|
||||
TrainlogScreen("Modifier la séance", scrollKey = "session-correction:$sessionId") {
|
||||
TrainlogScreen(strings.getString(R.string.route_session_correction), scrollKey = "session-correction:$sessionId") {
|
||||
if (original == null) {
|
||||
TrainlogInfo("Séance introuvable.", colors.error)
|
||||
TrainlogInfo(strings.getString(R.string.session_not_found), colors.error)
|
||||
return@TrainlogScreen
|
||||
}
|
||||
TrainlogInfo("Les anciennes interprétations de profil sont conservées. Les identifiants de la séance et des occurrences retenues ne changent pas.", colors.muted)
|
||||
TrainlogInfo(strings.getString(R.string.correction_profile_note), colors.muted)
|
||||
drafts.forEachIndexed { occurrenceIndex, draft ->
|
||||
TrainlogFrame("${occurrenceIndex + 1}. ${draft.exercise.name}") {
|
||||
/* INVARIANT: Compose editor/focus state follows the stable entry,
|
||||
* never a position that can be occupied by another occurrence. */
|
||||
key(draft.entryId) {
|
||||
var reorderVisualOffset by remember(draft.entryId) { mutableStateOf(0f) }
|
||||
TrainlogFrame(
|
||||
"${occurrenceIndex + 1}. ${draft.exercise.name}",
|
||||
modifier = Modifier.graphicsLayer { translationY = reorderVisualOffset },
|
||||
titleTrailing = {
|
||||
ExerciseReorderHandle(
|
||||
entryId = draft.entryId,
|
||||
index = occurrenceIndex,
|
||||
itemCount = drafts.size,
|
||||
onMove = { from, to ->
|
||||
/* CONTRACT: completed history is untouched here;
|
||||
* Save owns the sole transactional mutation. */
|
||||
drafts = reorderExerciseOccurrences(drafts, from, to)
|
||||
},
|
||||
onCommit = {},
|
||||
onVisualOffset = { reorderVisualOffset = it },
|
||||
)
|
||||
},
|
||||
) {
|
||||
draft.plan?.let { plan ->
|
||||
TrainlogInputField("Nombre de séries cible", plan.sets.toString(), { raw ->
|
||||
TrainlogInputField(strings.getString(R.string.field_target_sets), plan.sets.toString(), { raw ->
|
||||
raw.toIntOrNull()?.let { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(sets = value))) { drafts = it } }
|
||||
})
|
||||
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions cibles" else "Durée cible par série (s)",
|
||||
TrainlogInputField(strings.getString(if (draft.exercise.trackingMode == TrackingMode.REPS) R.string.field_target_reps else R.string.field_target_duration),
|
||||
(plan.reps ?: plan.durationSeconds ?: 0).toString(), { raw -> raw.toIntOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = if (draft.exercise.trackingMode == TrackingMode.REPS) plan.copy(reps = value, durationSeconds = null) else plan.copy(reps = null, durationSeconds = value))) { drafts = it }
|
||||
} })
|
||||
TrainlogInputField("Repos (s)", plan.restSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
|
||||
TrainlogInputField(strings.getString(R.string.field_rest_seconds), plan.restSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(restSeconds = value))) { drafts = it }
|
||||
} })
|
||||
if (plan.weightKg != null) TrainlogInputField("Charge cible (kg)", plan.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(weightKg = value))) { drafts = it }
|
||||
} })
|
||||
if (plan.weightKg != null) CompletedDecimalField(
|
||||
"${draft.entryId}:target-weight", strings.getString(R.string.field_target_weight),
|
||||
plan.weightKg, decimalText, allowZero = false,
|
||||
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(plan = plan.copy(weightKg = value))) { drafts = it } }
|
||||
}
|
||||
if (draft.maxWeightKg != null) {
|
||||
TrainlogInputField("MAX explicite (kg)", draft.maxWeightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(maxWeightKg = value)) { drafts = it }
|
||||
} })
|
||||
CompletedDecimalField(
|
||||
"${draft.entryId}:max", strings.getString(R.string.field_explicit_max),
|
||||
draft.maxWeightKg, decimalText, allowZero = false,
|
||||
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(maxWeightKg = value)) { drafts = it } }
|
||||
} else if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
|
||||
TrainlogInputField("Durée continue (s)", draft.continuousDurationSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
|
||||
TrainlogInputField(strings.getString(R.string.field_continuous_duration), draft.continuousDurationSeconds.toString(), { raw -> raw.toIntOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(continuousDurationSeconds = value)) { drafts = it }
|
||||
} })
|
||||
draft.speedKmh?.let { speed -> TrainlogInputField("Vitesse (km/h)", speed.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = value)) { drafts = it }
|
||||
} }) }
|
||||
draft.distanceKm?.let { distance -> TrainlogInputField("Distance (km)", distance.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(distanceKm = value)) { drafts = it }
|
||||
} }) }
|
||||
draft.speedKmh?.let { speed -> CompletedDecimalField(
|
||||
"${draft.entryId}:speed", strings.getString(R.string.field_speed), speed,
|
||||
decimalText, allowZero = false,
|
||||
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(speedKmh = value)) { drafts = it } } }
|
||||
draft.distanceKm?.let { distance -> CompletedDecimalField(
|
||||
"${draft.entryId}:distance", strings.getString(R.string.field_distance), distance,
|
||||
decimalText, allowZero = false,
|
||||
) { value -> replaceDraft(drafts, occurrenceIndex, draft.copy(distanceKm = value)) { drafts = it } } }
|
||||
} else {
|
||||
draft.sets.forEachIndexed { setIndex, set ->
|
||||
TrainlogInfo("Série ${setIndex + 1}", colors.accent)
|
||||
TrainlogInputField(if (draft.exercise.trackingMode == TrackingMode.REPS) "Répétitions" else "Durée (s)",
|
||||
TrainlogInfo(strings.getString(R.string.set_number, setIndex + 1), colors.accent)
|
||||
TrainlogInputField(strings.getString(if (draft.exercise.trackingMode == TrackingMode.REPS) R.string.field_reps else R.string.field_duration_seconds),
|
||||
(if (draft.exercise.trackingMode == TrackingMode.REPS) set.reps else set.durationSeconds).toString(), { raw -> raw.toIntOrNull()?.let { value ->
|
||||
val changed = if (draft.exercise.trackingMode == TrackingMode.REPS) set.copy(reps = value) else set.copy(durationSeconds = value)
|
||||
replaceSet(drafts, occurrenceIndex, setIndex, changed) { drafts = it }
|
||||
} })
|
||||
if (set.weightKg != null) TrainlogInputField("Charge (kg)", set.weightKg.toString(), { raw -> raw.toDoubleOrNull()?.let { value ->
|
||||
replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it }
|
||||
} })
|
||||
TrainlogDeleteButton("Supprimer cette série",
|
||||
if (set.weightKg != null) CompletedDecimalField(
|
||||
"${draft.entryId}:set-weight:$setIndex", strings.getString(R.string.field_weight),
|
||||
set.weightKg, decimalText, allowZero = true,
|
||||
) { value -> replaceSet(drafts, occurrenceIndex, setIndex, set.copy(weightKg = value)) { drafts = it } }
|
||||
TrainlogDeleteButton(strings.getString(R.string.a11y_delete_set),
|
||||
{ pendingSetRemoval = occurrenceIndex to setIndex },
|
||||
Modifier.testTag("remove-completed-set-${draft.entryId}-$setIndex"))
|
||||
}
|
||||
TrainlogAction("+ Ajouter une série", "Ajouter explicitement un fait réalisé.", {
|
||||
TrainlogAction(strings.getString(R.string.add_set), strings.getString(R.string.add_set_description), {
|
||||
replaceDraft(drafts, occurrenceIndex, draft.copy(sets = draft.sets + SessionSetDraft())) { drafts = it }
|
||||
}, accent = colors.success)
|
||||
}
|
||||
TrainlogDeleteButton("Supprimer cette occurrence",
|
||||
TrainlogDeleteButton(strings.getString(R.string.delete_occurrence),
|
||||
{ pendingOccurrenceRemoval = occurrenceIndex },
|
||||
Modifier.testTag("remove-completed-occurrence-${draft.entryId}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
message?.let { TrainlogInfo(it, colors.error) }
|
||||
TrainlogPrimaryAction("Enregistrer", "Appliquer toute la correction dans une transaction.", {
|
||||
TrainlogPrimaryAction(strings.getString(R.string.save), strings.getString(R.string.correction_save_description), {
|
||||
if (decimalText.any { (field, raw) ->
|
||||
val value = parseFiniteDecimal(raw)
|
||||
value == null || if (field.contains(":set-weight:")) value < 0.0 else value <= 0.0
|
||||
}) {
|
||||
message = strings.getString(R.string.invalid_decimal_value)
|
||||
return@TrainlogPrimaryAction
|
||||
}
|
||||
when (val result = repository.correctCompletedSession(sessionId, SessionDraft(drafts, original.summary.sessionType))) {
|
||||
CorrectCompletedSessionResult.Saved -> onFinished(true)
|
||||
is CorrectCompletedSessionResult.Invalid -> message = result.message
|
||||
is CorrectCompletedSessionResult.DatabaseError -> message = result.message
|
||||
is CorrectCompletedSessionResult.Invalid -> message = localizedRepositoryMessage(strings, result.message)
|
||||
is CorrectCompletedSessionResult.DatabaseError -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
})
|
||||
TrainlogAction("Annuler", "Quitter sans modifier la base.", { onFinished(false) }, accent = colors.muted)
|
||||
TrainlogAction(strings.getString(R.string.dialog_cancel), strings.getString(R.string.cancel_no_database_change), { onFinished(false) }, accent = colors.muted)
|
||||
}
|
||||
pendingSetRemoval?.let { (occurrence, set) ->
|
||||
DestructiveConfirmationDialog("Supprimer cette série ?", "Ce fait réalisé disparaîtra de la séance corrigée.", "Supprimer", { pendingSetRemoval = null },
|
||||
deleteContentDescription = "Supprimer cette série") {
|
||||
DestructiveConfirmationDialog(strings.getString(R.string.delete_set_question), strings.getString(R.string.delete_set_detail), strings.getString(R.string.delete), { pendingSetRemoval = null },
|
||||
deleteContentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set)) {
|
||||
val draft = drafts[occurrence]
|
||||
replaceDraft(drafts, occurrence, draft.copy(sets = draft.sets.filterIndexed { index, _ -> index != set })) { drafts = it }
|
||||
pendingSetRemoval = null
|
||||
|
|
@ -126,16 +168,49 @@ fun CompletedSessionCorrectionScreen(
|
|||
}
|
||||
pendingOccurrenceRemoval?.let { occurrence ->
|
||||
val draft = drafts[occurrence]
|
||||
DestructiveConfirmationDialog("Supprimer cette occurrence ?",
|
||||
"Impact : ${draft.sets.size} série(s) réalisée(s)${if (draft.continuousDurationSeconds > 0) ", activité continue" else ""}${if (draft.maxWeightKg != null) ", MAX" else ""}. Ses ressentis et révisions seront supprimés avec leur parent.",
|
||||
"Supprimer", { pendingOccurrenceRemoval = null },
|
||||
deleteContentDescription = "Supprimer cette occurrence") {
|
||||
DestructiveConfirmationDialog(strings.getString(R.string.delete_occurrence_question),
|
||||
strings.getString(R.string.occurrence_delete_impact,
|
||||
strings.resources.getQuantityString(R.plurals.performed_set_count, draft.sets.size, draft.sets.size),
|
||||
if (draft.continuousDurationSeconds > 0) strings.getString(R.string.continuous_activity_suffix) else "",
|
||||
if (draft.maxWeightKg != null) strings.getString(R.string.max_suffix) else ""),
|
||||
strings.getString(R.string.delete), { pendingOccurrenceRemoval = null },
|
||||
deleteContentDescription = strings.getString(R.string.a11y_delete_occurrence)) {
|
||||
drafts = drafts.filterIndexed { index, _ -> index != occurrence }
|
||||
pendingOccurrenceRemoval = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: a controlled numeric model cannot represent intermediate input such as
|
||||
* `0,` without destroying what the user is typing.
|
||||
* CONTRACT: visible text remains raw and accepts both decimal separators;
|
||||
* finite/sign validation is repeated by Save before repository mutation.
|
||||
*/
|
||||
@Composable
|
||||
private fun CompletedDecimalField(
|
||||
fieldKey: String,
|
||||
label: String,
|
||||
value: Double,
|
||||
rawValues: MutableMap<String, String>,
|
||||
allowZero: Boolean,
|
||||
onParsed: (Double) -> Unit,
|
||||
) {
|
||||
val raw = rawValues[fieldKey] ?: presentationNumber(value, 3)
|
||||
TrainlogInputField(
|
||||
label = label,
|
||||
value = raw,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
onValueChange = { updated ->
|
||||
if (decimalEditorTextValid(updated)) {
|
||||
rawValues[fieldKey] = updated
|
||||
parseFiniteDecimal(updated)?.takeIf { if (allowZero) it >= 0.0 else it > 0.0 }
|
||||
?.let(onParsed)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun SessionExerciseDetail.correctionDraft() = SessionExerciseDraft(
|
||||
entryId = entryId,
|
||||
exercise = ExerciseProfile(exerciseId, exerciseName, exerciseName, recordingMode, trackingMode, dataFields),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import com.labfytools.trainlog.model.SessionType
|
|||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
|
|
@ -74,32 +75,35 @@ fun HomeScreen(
|
|||
onSync: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
TrainlogScreen("Accueil", scrollKey = "home") {
|
||||
val strings = localizedContext()
|
||||
val locale = presentationLocale()
|
||||
TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") {
|
||||
draftError?.let { TrainlogInfo(it, colors.error) }
|
||||
TrainlogFrame("Séance") {
|
||||
TrainlogFrame(strings.getString(R.string.home_session)) {
|
||||
if (activeDraft != null) {
|
||||
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) "Test max" else "Entraînement"
|
||||
TrainlogPrimaryAction("Reprendre la séance en cours", "$kind · ${activeDraft.exercises.size} exercice(s)", onSession)
|
||||
val kind = if (activeDraft.sessionType == SessionType.MAX_TEST) strings.getString(R.string.session_max_test) else strings.getString(R.string.training)
|
||||
TrainlogPrimaryAction(strings.getString(R.string.resume_current_session), "$kind · " + strings.resources.getQuantityString(R.plurals.exercise_count, activeDraft.exercises.size, activeDraft.exercises.size), onSession)
|
||||
} else {
|
||||
TrainlogPrimaryAction("Nouvelle séance manuelle", "Créer explicitement un brouillon durable.", onSession)
|
||||
TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
|
||||
}
|
||||
}
|
||||
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics)
|
||||
latestSession?.let { session ->
|
||||
TrainlogFrame("Dernière séance") {
|
||||
TrainlogAction(formatStartedAt(session.startedAt), "${sessionTypeLabel(session.sessionType)} · ${session.exerciseCount} exercice(s)", { onOpenLatestSession(session.sessionId) })
|
||||
TrainlogFrame(strings.getString(R.string.latest_session)) {
|
||||
TrainlogAction(formatStartedAt(session.startedAt), sessionTypeLabel(session.sessionType) + " · " +
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count, session.exerciseCount, session.exerciseCount), { onOpenLatestSession(session.sessionId) })
|
||||
}
|
||||
}
|
||||
latestMaximum?.let { maximum ->
|
||||
val weight = "%.2f".format(java.util.Locale.FRANCE, maximum.maxWeightKg).trimEnd('0').trimEnd(',')
|
||||
TrainlogInfo("Dernier MAX · ${maximum.exerciseName} · $weight kg · ${maximum.startedAt.take(10)}", colors.warning)
|
||||
val weight = "%.2f".format(locale, maximum.maxWeightKg).trimEnd('0').trimEnd(',', '.')
|
||||
TrainlogInfo(strings.getString(R.string.latest_max_value, maximum.exerciseName, weight, formatDate(maximum.startedAt)), colors.warning)
|
||||
}
|
||||
TrainlogFrame("Accès rapides", active = false) {
|
||||
TrainlogFrame(strings.getString(R.string.quick_access), active = false) {
|
||||
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
TrainlogActionTile("Synchronisation", "État et actions", TrainlogIcons.Refresh,
|
||||
TrainlogActionTile(strings.getString(R.string.nav_sync), strings.getString(R.string.home_sync_state), TrainlogIcons.Refresh,
|
||||
onSync, Modifier.weight(1f).fillMaxHeight().testTag("home-sync-action"))
|
||||
TrainlogActionTile("Mensurations", "Suivi corporel", TrainlogIcons.BodyMeasurements,
|
||||
TrainlogActionTile(strings.getString(R.string.route_body_measurements), strings.getString(R.string.home_body_tracking), TrainlogIcons.BodyMeasurements,
|
||||
onBody, Modifier.weight(1f).fillMaxHeight().testTag("home-body-action"))
|
||||
}
|
||||
}
|
||||
|
|
@ -118,75 +122,76 @@ internal fun BodyZoneHomeSection(
|
|||
onOpenStatistics: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
var showExposureInfo by remember { mutableStateOf(false) }
|
||||
var selectedId by remember(overview.zones) {
|
||||
mutableStateOf(overview.recommendations.firstOrNull()?.zoneId ?: overview.zones.firstOrNull()?.zoneId)
|
||||
}
|
||||
val selected = overview.zones.firstOrNull { it.zoneId == selectedId }
|
||||
TrainlogFrame("Zones à travailler", active = overview.recommendations.isNotEmpty()) {
|
||||
TrainlogFrame(strings.getString(R.string.zones_to_work), active = overview.recommendations.isNotEmpty()) {
|
||||
TextButton(onClick = { showExposureInfo = true }) {
|
||||
Text("Exposition récente ⓘ")
|
||||
Text(strings.getString(R.string.recent_exposure) + " ⓘ")
|
||||
}
|
||||
if (!overview.hasTrainingHistory) {
|
||||
TrainlogInfo("Pas encore d’historique d’entraînement.")
|
||||
TrainlogInfo(strings.getString(R.string.no_training_history))
|
||||
}
|
||||
if (overview.hasInvalidHistoryTimestamp) {
|
||||
TrainlogInfo("Données insuffisantes : un horodatage historique ne peut pas être interprété.", colors.warning)
|
||||
TrainlogInfo(strings.getString(R.string.invalid_history_timestamp), colors.warning)
|
||||
}
|
||||
BodyZoneMap(overview.zones, selectedId) { selectedId = it }
|
||||
selected?.let { zone ->
|
||||
BasicText(
|
||||
"Sélection : ${zone.displayName}",
|
||||
strings.getString(R.string.selection_value, localizedBodyZoneName(strings, zone.zoneId, zone.displayName)),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
style = TrainlogTypography.normal.copy(color = colors.lavender),
|
||||
)
|
||||
}
|
||||
TrainlogInfo("Priorités", colors.accent)
|
||||
TrainlogInfo(strings.getString(R.string.priorities), colors.accent)
|
||||
overview.recommendations.take(3).forEachIndexed { index, zone ->
|
||||
TrainlogAction(
|
||||
"${index + 1}. ${zone.displayName} · ${homeStateLabel(zone)}",
|
||||
recommendationReason(zone),
|
||||
"${index + 1}. ${localizedBodyZoneName(strings, zone.zoneId, zone.displayName)} · ${homeStateLabel(zone, strings)}",
|
||||
recommendationReason(zone, strings),
|
||||
{ selectedId = zone.zoneId },
|
||||
)
|
||||
}
|
||||
if (overview.recommendations.isEmpty()) {
|
||||
TrainlogInfo("Aucune zone disposant d’un exercice résolu n’est disponible.")
|
||||
TrainlogInfo(strings.getString(R.string.resolved_zone_none))
|
||||
}
|
||||
selected?.let { zone ->
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
BasicText(zone.displayName, style = TrainlogTypography.section.copy(color = colors.text))
|
||||
BasicText(homeStateLabel(zone), style = TrainlogTypography.small.copy(color = stateColor(zone.state, colors)))
|
||||
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
|
||||
BasicText(homeStateLabel(zone, strings), style = TrainlogTypography.small.copy(color = stateColor(zone.state, colors)))
|
||||
BasicText(
|
||||
"Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}",
|
||||
strings.getString(R.string.latest_primary_work_label, ageLabel(zone.primaryExposureAgeSeconds, strings)),
|
||||
style = TrainlogTypography.small.copy(color = colors.text),
|
||||
)
|
||||
BasicText(
|
||||
recentWorkLabel(zone.primaryWork7Days, "principal") + " · " +
|
||||
recentWorkLabel(zone.secondaryWork7Days, "secondaire"),
|
||||
recentWorkLabel(zone.primaryWork7Days, true, strings) + " · " +
|
||||
recentWorkLabel(zone.secondaryWork7Days, false, strings),
|
||||
style = TrainlogTypography.small.copy(color = colors.text),
|
||||
)
|
||||
BasicText(
|
||||
availableExerciseLabel(zone.availableExerciseCount),
|
||||
availableExerciseLabel(zone.availableExerciseCount, strings),
|
||||
style = TrainlogTypography.small.copy(color = colors.text),
|
||||
)
|
||||
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
|
||||
TrainlogAction("Voir les exercices", "", { onOpenExercises(zone.zoneId) })
|
||||
TrainlogAction("Voir les statistiques", "", onOpenStatistics)
|
||||
TrainlogAction(strings.getString(R.string.view_exercises), "", { onOpenExercises(zone.zoneId) })
|
||||
TrainlogAction(strings.getString(R.string.view_statistics), "", onOpenStatistics)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showExposureInfo) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExposureInfo = false },
|
||||
title = { Text("Exposition récente") },
|
||||
title = { Text(strings.getString(R.string.recent_exposure)) },
|
||||
text = {
|
||||
Text("Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.")
|
||||
Text(strings.getString(R.string.exposure_explanation))
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showExposureInfo = false }) { Text("Compris") }
|
||||
TextButton(onClick = { showExposureInfo = false }) { Text(strings.getString(R.string.understood)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -213,9 +218,11 @@ private fun BodyMapPanel(
|
|||
modifier: Modifier,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val visibleZones = view.zoneIds.mapNotNull(zones::get)
|
||||
Column(modifier.semantics { contentDescription = "Carte corporelle, ${view.label}" }) {
|
||||
BasicText(view.label, style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
val viewLabel = strings.getString(view.labelId)
|
||||
Column(modifier.semantics { contentDescription = strings.getString(R.string.body_map, viewLabel) }) {
|
||||
BasicText(viewLabel, style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(236.dp)) {
|
||||
val panelWidth = maxWidth
|
||||
val panelHeight = maxHeight
|
||||
|
|
@ -281,14 +288,14 @@ private fun BodyMapPanel(
|
|||
role = Role.Button
|
||||
selected = chosen
|
||||
contentDescription = buildString {
|
||||
append(view.label)
|
||||
append(viewLabel)
|
||||
append(", ")
|
||||
append(zone.displayName)
|
||||
append(localizedBodyZoneName(strings, zone.zoneId, zone.displayName))
|
||||
append(", ")
|
||||
append(homeStateLabel(zone))
|
||||
append(if (chosen) ", sélectionnée" else ", non sélectionnée")
|
||||
append(homeStateLabel(zone, strings))
|
||||
append(strings.getString(if (chosen) R.string.selected_suffix else R.string.not_selected_suffix))
|
||||
}
|
||||
onClick(label = "Sélectionner ${zone.displayName}") {
|
||||
onClick(label = strings.getString(R.string.select_zone, localizedBodyZoneName(strings, zone.zoneId, zone.displayName))) {
|
||||
select(zone.zoneId)
|
||||
true
|
||||
}
|
||||
|
|
@ -300,13 +307,13 @@ private fun BodyMapPanel(
|
|||
}
|
||||
|
||||
private enum class BodyView(
|
||||
val label: String,
|
||||
@param:androidx.annotation.StringRes val labelId: Int,
|
||||
val testName: String,
|
||||
val zoneIds: List<String>,
|
||||
val accessibilityBounds: Map<String, Rect>,
|
||||
) {
|
||||
FRONT(
|
||||
label = "Face",
|
||||
labelId = R.string.body_front,
|
||||
testName = "front",
|
||||
zoneIds = listOf("shoulders", "chest", "arms", "core", "thighs", "calves"),
|
||||
accessibilityBounds = mapOf(
|
||||
|
|
@ -319,7 +326,7 @@ private enum class BodyView(
|
|||
),
|
||||
),
|
||||
BACK(
|
||||
label = "Dos",
|
||||
labelId = R.string.body_back,
|
||||
testName = "back",
|
||||
zoneIds = listOf("shoulders", "back", "arms", "glutes", "thighs", "calves"),
|
||||
accessibilityBounds = mapOf(
|
||||
|
|
@ -491,28 +498,31 @@ private fun stateColor(state: BodyZoneHomeState, colors: TrainlogColors): Color
|
|||
}
|
||||
}
|
||||
|
||||
internal fun recommendationReason(zone: BodyZoneHomeStatus): String = when {
|
||||
zone.lastPrimaryExposure == null -> "Aucune exposition principale enregistrée"
|
||||
zone.primaryWork7Days == 0 -> "Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}"
|
||||
else -> recentWorkLabel(zone.primaryWork7Days, "principal")
|
||||
internal fun recommendationReason(zone: BodyZoneHomeStatus, context: android.content.Context): String = when {
|
||||
zone.lastPrimaryExposure == null -> context.getString(R.string.no_primary_exposure)
|
||||
zone.primaryWork7Days == 0 -> context.getString(R.string.latest_primary_work, ageLabel(zone.primaryExposureAgeSeconds, context))
|
||||
else -> recentWorkLabel(zone.primaryWork7Days, true, context)
|
||||
}
|
||||
|
||||
internal fun homeStateLabel(zone: BodyZoneHomeStatus): String =
|
||||
internal fun homeStateLabel(zone: BodyZoneHomeStatus, context: android.content.Context): String =
|
||||
if (zone.state == BodyZoneHomeState.PRIORITIZE && zone.primaryWork7Days > 0) {
|
||||
"Priorité relative"
|
||||
context.getString(R.string.relative_priority)
|
||||
} else {
|
||||
zone.state.label
|
||||
context.getString(when (zone.state) { BodyZoneHomeState.PRIORITIZE -> R.string.state_prioritize
|
||||
BodyZoneHomeState.LITTLE_RECENT_WORK -> R.string.state_little_work; BodyZoneHomeState.RECENT_WORK -> R.string.state_recent_work
|
||||
BodyZoneHomeState.HIGH_RECENT_EXPOSURE -> R.string.state_high_exposure; BodyZoneHomeState.INSUFFICIENT_DATA -> R.string.state_insufficient
|
||||
BodyZoneHomeState.UNSUPPORTED -> R.string.state_unsupported })
|
||||
}
|
||||
|
||||
internal fun recentWorkLabel(count: Int, kind: String): String =
|
||||
"Travail $kind sur 7 j : $count"
|
||||
internal fun recentWorkLabel(count: Int, primary: Boolean, context: android.content.Context): String =
|
||||
context.getString(if (primary) R.string.recent_primary_work else R.string.recent_secondary_work, count)
|
||||
|
||||
private fun availableExerciseLabel(count: Int): String =
|
||||
"$count ${if (count == 1) "exercice disponible" else "exercices disponibles"}"
|
||||
private fun availableExerciseLabel(count: Int, context: android.content.Context): String =
|
||||
context.resources.getQuantityString(R.plurals.available_exercise_count, count, count)
|
||||
|
||||
internal fun ageLabel(seconds: Long?): String = when {
|
||||
seconds == null -> "jamais enregistrée"
|
||||
seconds < 86400L -> "aujourd’hui"
|
||||
seconds < 2L * 86400L -> "il y a 1 jour"
|
||||
else -> "il y a ${seconds / 86400L} jours"
|
||||
internal fun ageLabel(seconds: Long?, context: android.content.Context): String = when {
|
||||
seconds == null -> context.getString(R.string.age_never)
|
||||
seconds < 86400L -> context.getString(R.string.age_today)
|
||||
seconds < 2L * 86400L -> context.getString(R.string.age_yesterday)
|
||||
else -> context.resources.getQuantityString(R.plurals.age_days, (seconds / 86400L).toInt(), seconds / 86400L)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.labfytools.trainlog.R
|
||||
import java.util.Locale
|
||||
import java.text.NumberFormat
|
||||
|
||||
enum class AppLanguage(val tag: String) {
|
||||
FRENCH("fr"),
|
||||
ENGLISH("en");
|
||||
|
||||
companion object {
|
||||
fun fromStoredTag(tag: String?): AppLanguage = entries.firstOrNull { it.tag == tag } ?: FRENCH
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: language is device-local presentation state and must never enter the
|
||||
* canonical workout database or an exchange artifact.
|
||||
* CONTRACT: this owner persists exactly one BCP-47 tag in its own preferences
|
||||
* file; French is used when the preference is absent or invalid.
|
||||
* INVARIANT: changing [language] updates Compose synchronously and does not
|
||||
* call TrainlogRepository, SQLite, import, export, or synchronization code.
|
||||
*/
|
||||
@Stable
|
||||
class LanguageSettingsOwner(context: Context) {
|
||||
private val preferences = context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
var language: AppLanguage by mutableStateOf(AppLanguage.fromStoredTag(preferences.getString(KEY_LANGUAGE, null)))
|
||||
private set
|
||||
|
||||
fun select(value: AppLanguage) {
|
||||
if (language == value) return
|
||||
preferences.edit().putString(KEY_LANGUAGE, value.tag).apply()
|
||||
language = value
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal const val PREFERENCES_NAME = "trainlog_presentation_settings"
|
||||
internal const val KEY_LANGUAGE = "language_tag"
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class LanguagePresentation(
|
||||
val language: AppLanguage,
|
||||
val select: (AppLanguage) -> Unit,
|
||||
)
|
||||
|
||||
val LocalLanguagePresentation = staticCompositionLocalOf<LanguagePresentation> {
|
||||
/* Preview and focused component tests have no Activity owner. Their
|
||||
* presentation contract is the same deterministic French default. */
|
||||
LanguagePresentation(AppLanguage.FRENCH) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun localizedContext(): Context {
|
||||
val base = LocalContext.current
|
||||
val language = LocalLanguagePresentation.current.language
|
||||
val configuration = Configuration(base.resources.configuration).apply {
|
||||
setLocale(Locale.forLanguageTag(language.tag))
|
||||
}
|
||||
return base.createConfigurationContext(configuration)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun presentationLocale(): Locale = Locale.forLanguageTag(LocalLanguagePresentation.current.language.tag)
|
||||
|
||||
@Composable
|
||||
fun uiString(@StringRes id: Int, vararg arguments: Any): String =
|
||||
localizedContext().resources.getString(id, *arguments)
|
||||
|
||||
@Composable
|
||||
fun uiQuantity(@PluralsRes id: Int, quantity: Int, vararg arguments: Any): String =
|
||||
localizedContext().resources.getQuantityString(id, quantity, *arguments)
|
||||
|
||||
@Composable
|
||||
fun presentationNumber(value: Double, maximumFractionDigits: Int = 2): String =
|
||||
NumberFormat.getNumberInstance(presentationLocale()).apply {
|
||||
isGroupingUsed = false
|
||||
minimumFractionDigits = 0
|
||||
this.maximumFractionDigits = maximumFractionDigits
|
||||
}.format(value)
|
||||
|
||||
/**
|
||||
* CONTRACT: body-zone IDs are canonical catalog data; only their visible labels are localized.
|
||||
* Unknown future IDs deliberately retain the catalog label instead of changing identity data.
|
||||
*/
|
||||
fun localizedBodyZoneName(context: Context, zoneId: String, catalogLabel: String): String {
|
||||
val id = when (zoneId) {
|
||||
"full_body" -> R.string.zone_full_body
|
||||
"upper_body" -> R.string.zone_upper_body
|
||||
"chest" -> R.string.zone_chest
|
||||
"back" -> R.string.zone_back
|
||||
"shoulders" -> R.string.zone_shoulders
|
||||
"arms" -> R.string.zone_arms
|
||||
"core" -> R.string.zone_core
|
||||
"lower_body" -> R.string.zone_lower_body
|
||||
"glutes" -> R.string.zone_glutes
|
||||
"thighs" -> R.string.zone_thighs
|
||||
"calves" -> R.string.zone_calves
|
||||
else -> return catalogLabel
|
||||
}
|
||||
return context.getString(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY: repository diagnostics are persistence/domain values, not translated payload fields.
|
||||
* CONTRACT: classify them at the Android presentation boundary without rewriting repository state.
|
||||
*/
|
||||
fun localizedRepositoryMessage(context: Context, raw: String): String {
|
||||
val normalized = raw.lowercase(Locale.ROOT)
|
||||
val id = when {
|
||||
"seule la sélection" in normalized -> R.string.draft_selection_missing
|
||||
listOf("introuvable", "not found", "missing").any(normalized::contains) -> R.string.repository_item_missing
|
||||
listOf("base locale", "base de données", "database", "sqlite").any(normalized::contains) -> R.string.repository_database_error
|
||||
listOf("invalide", "invalid", "incompatible").any(normalized::contains) -> R.string.repository_invalid_data
|
||||
listOf("accès fichiers", "autorisation", "permission", "file access").any(normalized::contains) -> R.string.repository_file_access
|
||||
else -> R.string.repository_operation_error
|
||||
}
|
||||
return context.getString(id)
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ import com.labfytools.trainlog.data.TrainlogRepository
|
|||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
@Composable
|
||||
fun SessionsHub(
|
||||
|
|
@ -45,22 +46,24 @@ fun SessionsHub(
|
|||
onHistory: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
TrainlogScreen("Séances") {
|
||||
TrainlogFrame("Séance en cours", active = draft != null) {
|
||||
if (draft == null) TrainlogInfo("Aucune séance en cours.")
|
||||
else TrainlogAction("Reprendre", "${draft.exercises.size} exercice(s) · le brouillon durable est conservé.", onResume, accent = colors.success)
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.nav_sessions)) {
|
||||
TrainlogFrame(strings.getString(R.string.route_session_editor), active = draft != null) {
|
||||
if (draft == null) TrainlogInfo(strings.getString(R.string.current_session_none))
|
||||
else TrainlogAction(strings.getString(R.string.resume), strings.getString(R.string.durable_draft_count,
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count, draft.exercises.size, draft.exercises.size)), onResume, accent = colors.success)
|
||||
}
|
||||
TrainlogFrame("Préparer") {
|
||||
TrainlogFrame(strings.getString(R.string.prepare)) {
|
||||
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("sessions-prepare-row"),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
TrainlogActionTile("Nouvelle séance manuelle", "Créer ou reprendre", TrainlogIcons.Edit,
|
||||
TrainlogActionTile(strings.getString(R.string.new_manual_session), strings.getString(R.string.create_or_resume), TrainlogIcons.Edit,
|
||||
onManual, Modifier.weight(1f).fillMaxHeight().testTag("sessions-manual-action"))
|
||||
TrainlogActionTile("Brouillons", "$pendingAiDraftCount séance(s) préparée(s)",
|
||||
TrainlogActionTile(strings.getString(R.string.route_ai_drafts), strings.resources.getQuantityString(R.plurals.prepared_session_count, pendingAiDraftCount, pendingAiDraftCount),
|
||||
TrainlogIcons.Drafts, onDrafts,
|
||||
Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action"))
|
||||
}
|
||||
}
|
||||
TrainlogButton("Séances effectuées", onHistory,
|
||||
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
|
||||
Modifier.fillMaxWidth().testTag("sessions-history-action"))
|
||||
}
|
||||
}
|
||||
|
|
@ -73,48 +76,49 @@ fun AiSessionDraftsScreen(
|
|||
onStarted: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var pendingDeletion by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
val drafts = remember(externalRevision, revision) { repository.listAiSessionDrafts() }
|
||||
TrainlogScreen("Brouillons") {
|
||||
TrainlogScreen(strings.getString(R.string.route_ai_drafts)) {
|
||||
message?.let { TrainlogInfo(it) }
|
||||
if (drafts.isEmpty()) TrainlogInfo("Aucun brouillon importé.")
|
||||
if (drafts.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
|
||||
drafts.forEach { draft ->
|
||||
TrainlogFrame(draft.title ?: "Proposition de séance", active = true) {
|
||||
TrainlogFrame(draft.title ?: strings.getString(R.string.session_proposal), active = true) {
|
||||
TrainlogInfo(listOfNotNull(
|
||||
draft.plannedFor?.let { "Prévue le $it" },
|
||||
"${draft.entries.size} exercice(s)",
|
||||
draft.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count, draft.entries.size, draft.entries.size),
|
||||
).joinToString(" · "))
|
||||
draft.notes?.let { TrainlogInfo(it) }
|
||||
draft.entries.forEach { entry ->
|
||||
val plan = checkNotNull(entry.plan)
|
||||
val metric = plan.reps?.let { "$it répétitions" }
|
||||
?: "${plan.durationSeconds} secondes"
|
||||
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
|
||||
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
|
||||
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
|
||||
TrainlogInfo("${entry.exercise.name} · ${plan.sets} × $metric$weight · repos ${plan.restSeconds} s")
|
||||
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
|
||||
}
|
||||
TrainlogPrimaryAction("Démarrer", "Copier cette proposition dans la séance en cours.") {
|
||||
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_proposal_description)) {
|
||||
when (val result = repository.startAiSessionDraft(draft.draftId)) {
|
||||
StartAiSessionDraftResult.Started -> {
|
||||
onPendingChanged()
|
||||
onStarted()
|
||||
}
|
||||
StartAiSessionDraftResult.ExistingActiveDraft ->
|
||||
message = "Une séance est déjà en cours. Le brouillon importé a été conservé."
|
||||
message = strings.getString(R.string.draft_already_active)
|
||||
StartAiSessionDraftResult.NotPending -> {
|
||||
message = "Ce brouillon n'est plus disponible."
|
||||
message = strings.getString(R.string.draft_missing)
|
||||
revision++
|
||||
onPendingChanged()
|
||||
}
|
||||
is StartAiSessionDraftResult.Error -> message = result.message
|
||||
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}
|
||||
TrainlogDeleteButton(
|
||||
contentDescription = "Supprimer ce brouillon",
|
||||
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
|
||||
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
|
||||
onClick = {
|
||||
pendingDeletion = draft.draftId to (draft.title ?: "Proposition de séance")
|
||||
pendingDeletion = draft.draftId to (draft.title ?: strings.getString(R.string.session_proposal))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -122,12 +126,12 @@ fun AiSessionDraftsScreen(
|
|||
}
|
||||
pendingDeletion?.let { (draftId, title) ->
|
||||
DestructiveConfirmationDialog(
|
||||
title = "Supprimer ce brouillon ?",
|
||||
detail = "Le brouillon « $title » sera supprimé.\nCette action est irréversible.",
|
||||
confirmLabel = "Supprimer",
|
||||
title = strings.getString(R.string.delete_draft_question),
|
||||
detail = strings.getString(R.string.delete_draft_detail, title),
|
||||
confirmLabel = strings.getString(R.string.delete),
|
||||
onCancel = { pendingDeletion = null },
|
||||
dismissOnClickOutside = true,
|
||||
deleteContentDescription = "Supprimer ce brouillon",
|
||||
deleteContentDescription = localizedContext().getString(R.string.a11y_delete_draft),
|
||||
) {
|
||||
/* CONTRACT: dialog confirmation is the sole UI path to the existing
|
||||
* tombstone mutation. Dismissal never reaches the repository. */
|
||||
|
|
@ -137,7 +141,7 @@ fun AiSessionDraftsScreen(
|
|||
revision++
|
||||
onPendingChanged()
|
||||
}
|
||||
is ActiveDraftMutationResult.Error -> message = result.message
|
||||
is ActiveDraftMutationResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -147,13 +151,16 @@ fun AiSessionDraftsScreen(
|
|||
fun LatestMaximaScreen(repository: TrainlogRepository) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val maxima = remember { repository.listLatestExerciseMaxima() }
|
||||
TrainlogScreen("Derniers MAX") {
|
||||
TrainlogFrame("CAPACITÉS / MAX", active = maxima.isNotEmpty()) {
|
||||
if (maxima.isEmpty()) TrainlogInfo("Aucun max explicite enregistré.")
|
||||
val locale = presentationLocale()
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
|
||||
TrainlogFrame(strings.getString(R.string.max_capacities), active = maxima.isNotEmpty()) {
|
||||
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
|
||||
maxima.forEach { max ->
|
||||
val weight = "%.2f".format(java.util.Locale.FRANCE, max.maxWeightKg).trimEnd('0').trimEnd(',')
|
||||
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
|
||||
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
|
||||
TrainlogInfo("${formatStartedAt(max.startedAt).take(10)} · Équipement : ${max.equipmentDisplayName ?: "aucun"}", colors.muted)
|
||||
TrainlogInfo("${formatDate(max.startedAt)} · " + strings.getString(R.string.equipment_value,
|
||||
max.equipmentDisplayName ?: strings.getString(R.string.value_none)), colors.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,14 +185,15 @@ fun EquipmentScreen(
|
|||
onCreate: () -> Unit,
|
||||
onOpenDetail: (String) -> Unit,
|
||||
) {
|
||||
val strings = localizedContext()
|
||||
val equipment = remember(state.query, state.revision) { repository.searchEquipment(state.query) }
|
||||
TrainlogScreen("Catalogue d'équipements", scrollKey = "equipment-catalogue") {
|
||||
TrainlogPrimaryAction("Créer un équipement", "Ajouter une machine personnelle.", onCreate)
|
||||
TrainlogInputField("Rechercher par nom, étiquette ou alias", state.query, { state.query = it })
|
||||
TrainlogFrame("Catalogue", active = equipment.isNotEmpty()) {
|
||||
if (equipment.isEmpty()) TrainlogInfo("Aucun équipement trouvé.")
|
||||
TrainlogScreen(strings.getString(R.string.equipment_catalog), scrollKey = "equipment-catalogue") {
|
||||
TrainlogPrimaryAction(strings.getString(R.string.create_equipment), strings.getString(R.string.create_personal_machine), onCreate)
|
||||
TrainlogInputField(strings.getString(R.string.search_equipment), state.query, { state.query = it })
|
||||
TrainlogFrame(strings.getString(R.string.catalog), active = equipment.isNotEmpty()) {
|
||||
if (equipment.isEmpty()) TrainlogInfo(strings.getString(R.string.equipment_none))
|
||||
equipment.forEach { entry ->
|
||||
TrainlogAction(entry.displayName, listOf(entry.labelName, equipmentSemanticsLabel(entry.loadSemantics)).filter { it.isNotBlank() }.joinToString(" · "), {
|
||||
TrainlogAction(entry.displayName, listOf(entry.labelName, equipmentSemanticsLabel(entry.loadSemantics, strings)).filter { it.isNotBlank() }.joinToString(" · "), {
|
||||
state.selectedEquipmentId = entry.equipmentId
|
||||
onOpenDetail(entry.equipmentId)
|
||||
})
|
||||
|
|
@ -197,31 +205,32 @@ fun EquipmentScreen(
|
|||
@Composable
|
||||
fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, onOpenExercise: (String) -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val entry = remember(equipmentId) { repository.listEquipment().firstOrNull { it.equipmentId == equipmentId } }
|
||||
val options = remember(equipmentId) { repository.listEquipmentExerciseOptions(equipmentId) }
|
||||
TrainlogScreen("Fiche équipement", scrollKey = "equipment-detail:$equipmentId") {
|
||||
TrainlogScreen(strings.getString(R.string.route_equipment_detail), scrollKey = "equipment-detail:$equipmentId") {
|
||||
if (entry == null) {
|
||||
TrainlogInfo("Référence inconnue · $equipmentId", colors.warning)
|
||||
TrainlogInfo(strings.getString(R.string.unknown_reference, equipmentId), colors.warning)
|
||||
return@TrainlogScreen
|
||||
}
|
||||
TrainlogFrame("Définition") {
|
||||
TrainlogFrame(strings.getString(R.string.definition)) {
|
||||
TrainlogInfo(entry.displayName, colors.accent)
|
||||
TrainlogInfo(if (entry.type == "custom_machine") "Personnel" else "Fourni")
|
||||
if (entry.labelName.isNotBlank()) TrainlogInfo("Étiquette : ${entry.labelName}")
|
||||
TrainlogInfo("Type : ${entry.type}")
|
||||
TrainlogInfo("Charge : ${equipmentSemanticsLabel(entry.loadSemantics)}")
|
||||
if (entry.aliases.isNotEmpty()) TrainlogInfo("Alias : ${entry.aliases.joinToString()}", colors.muted)
|
||||
TrainlogInfo(strings.getString(if (entry.type == "custom_machine") R.string.personal else R.string.provided))
|
||||
if (entry.labelName.isNotBlank()) TrainlogInfo(strings.getString(R.string.label_value, entry.labelName))
|
||||
TrainlogInfo(strings.getString(R.string.type_value, entry.type))
|
||||
TrainlogInfo(strings.getString(R.string.load_value, equipmentSemanticsLabel(entry.loadSemantics, strings)))
|
||||
if (entry.aliases.isNotEmpty()) TrainlogInfo(strings.getString(R.string.aliases_value, entry.aliases.joinToString()), colors.muted)
|
||||
}
|
||||
TrainlogFrame("Exercices possibles", active = options.isNotEmpty()) {
|
||||
if (options.isEmpty()) TrainlogInfo("Aucune association vérifiée. L’anatomie n’est pas déduite du nom de la machine.", colors.muted)
|
||||
TrainlogFrame(strings.getString(R.string.possible_exercises_plain), active = options.isNotEmpty()) {
|
||||
if (options.isEmpty()) TrainlogInfo(strings.getString(R.string.association_none), colors.muted)
|
||||
options.forEach { (relation, exercise) ->
|
||||
val confidence = when (relation.confidence) {
|
||||
KnowledgeConfidence.HIGH -> "élevée"
|
||||
KnowledgeConfidence.MODERATE -> "modérée"
|
||||
KnowledgeConfidence.UNCERTAIN -> "incertaine"
|
||||
KnowledgeConfidence.HIGH -> strings.getString(R.string.confidence_high)
|
||||
KnowledgeConfidence.MODERATE -> strings.getString(R.string.confidence_moderate)
|
||||
KnowledgeConfidence.UNCERTAIN -> strings.getString(R.string.confidence_uncertain)
|
||||
}
|
||||
TrainlogAction(exercise.exerciseName,
|
||||
listOfNotNull(relation.configurationLabel, "Confiance $confidence", "Sources vérifiées : ${relation.sourceRefs.size}").joinToString(" · "),
|
||||
listOfNotNull(relation.configurationLabel, strings.getString(R.string.confidence_value, confidence), strings.getString(R.string.verified_sources, relation.sourceRefs.size)).joinToString(" · "),
|
||||
{ onOpenExercise(exercise.exerciseId) })
|
||||
}
|
||||
}
|
||||
|
|
@ -231,18 +240,19 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
|
|||
@Composable
|
||||
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
TrainlogScreen("Créer un équipement", scrollKey = "equipment-create") {
|
||||
TrainlogFrame("Équipement personnel") {
|
||||
TrainlogInputField("Nom", state.customName, { state.customName = it; state.message = null })
|
||||
TrainlogPrimaryAction("Créer", "Créer une machine personnelle avec la sémantique actuelle.") {
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
||||
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
|
||||
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
|
||||
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
|
||||
when (val result = repository.createCustomEquipment(state.customName)) {
|
||||
is CreateEquipmentResult.Created -> {
|
||||
state.selectedEquipmentId = result.equipment.equipmentId
|
||||
state.customName = ""; state.message = null; state.revision++; onCreated()
|
||||
}
|
||||
CreateEquipmentResult.Invalid -> state.message = "Saisissez un nom de 1 à 120 caractères."
|
||||
CreateEquipmentResult.Conflict -> state.message = "Un équipement porte déjà ce nom."
|
||||
is CreateEquipmentResult.DatabaseError -> state.message = "Création impossible : ${result.message}."
|
||||
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
||||
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
|
||||
is CreateEquipmentResult.DatabaseError -> state.message = strings.getString(R.string.creation_failed, result.message)
|
||||
}
|
||||
}
|
||||
state.message?.let { TrainlogInfo(it, colors.error) }
|
||||
|
|
@ -250,45 +260,73 @@ fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreen
|
|||
}
|
||||
}
|
||||
|
||||
private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics) = when (value) {
|
||||
EquipmentLoadSemantics.NONE -> "sans charge"
|
||||
EquipmentLoadSemantics.EXTERNAL -> "charge externe"
|
||||
EquipmentLoadSemantics.ASSISTANCE -> "assistance"
|
||||
EquipmentLoadSemantics.BODYWEIGHT -> "poids du corps"
|
||||
EquipmentLoadSemantics.CARDIO -> "cardio"
|
||||
}
|
||||
private fun equipmentSemanticsLabel(value: EquipmentLoadSemantics, context: android.content.Context) = context.getString(when (value) {
|
||||
EquipmentLoadSemantics.NONE -> R.string.load_none
|
||||
EquipmentLoadSemantics.EXTERNAL -> R.string.load_external
|
||||
EquipmentLoadSemantics.ASSISTANCE -> R.string.load_assistance
|
||||
EquipmentLoadSemantics.BODYWEIGHT -> R.string.load_bodyweight
|
||||
EquipmentLoadSemantics.CARDIO -> R.string.load_cardio
|
||||
})
|
||||
|
||||
private fun catalogCountsText(context: android.content.Context, imported: Int, reconciled: Int): String =
|
||||
listOf(
|
||||
context.resources.getQuantityString(R.plurals.catalog_new, imported, imported),
|
||||
context.resources.getQuantityString(R.plurals.catalog_reconciled, reconciled, reconciled),
|
||||
).joinToString(", ")
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val context = LocalContext.current
|
||||
val language = LocalLanguagePresentation.current
|
||||
val strings = localizedContext()
|
||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
authorized = inbox.hasFolderAccess()
|
||||
message = if (authorized) "Accès fichiers autorisé." else "Accès fichiers toujours requis."
|
||||
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
|
||||
if (authorized) {
|
||||
when (val result = inbox.importPcCatalog()) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
message = "Dossier autorisé · ${result.imported} exercice(s) importé(s), ${result.reconciled} réconcilié(s)."
|
||||
message = strings.getString(R.string.settings_folder_imported,
|
||||
catalogCountsText(strings, result.imported, result.reconciled))
|
||||
onCatalogChanged()
|
||||
}
|
||||
CatalogInboxResult.FileNotFound -> message = "Dossier autorisé · aucun catalogue PC reçu."
|
||||
CatalogInboxResult.FolderNotAuthorized -> message = "Le dossier n'est plus autorisé."
|
||||
is CatalogInboxResult.Error -> message = result.message
|
||||
CatalogInboxResult.FileNotFound -> message = strings.getString(R.string.settings_no_catalog)
|
||||
CatalogInboxResult.FolderNotAuthorized -> message = strings.getString(R.string.settings_folder_revoked)
|
||||
is CatalogInboxResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
TrainlogScreen("Paramètres") {
|
||||
TrainlogFrame("DOSSIER D'ÉCHANGE") {
|
||||
TrainlogInfo(if (authorized) "Dossier d'échange : Documents/Trainlog\nAccès fichiers : autorisé" else "Accès fichiers requis", if (authorized) colors.success else colors.warning)
|
||||
TrainlogAction(if (authorized) "Ouvrir les réglages d'accès" else "Autoriser", "Autoriser l'accès au dossier Trainlog dans Android.", { launcher.launch(directStoragePermissionIntent(context)) })
|
||||
if (authorized) TrainlogAction("Relire le catalogue PC", "Appliquer explicitement le catalogue présent dans le dossier autorisé.", {
|
||||
TrainlogScreen(strings.getString(R.string.settings_screen)) {
|
||||
TrainlogFrame(strings.getString(R.string.settings_language_section)) {
|
||||
TrainlogInfo("${strings.getString(R.string.settings_language_label)} : " +
|
||||
strings.getString(if (language.language == AppLanguage.FRENCH) R.string.language_french else R.string.language_english))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.language_french),
|
||||
{ language.select(AppLanguage.FRENCH) },
|
||||
Modifier.weight(1f),
|
||||
enabled = language.language != AppLanguage.FRENCH,
|
||||
)
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.language_english),
|
||||
{ language.select(AppLanguage.ENGLISH) },
|
||||
Modifier.weight(1f),
|
||||
enabled = language.language != AppLanguage.ENGLISH,
|
||||
)
|
||||
}
|
||||
}
|
||||
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
|
||||
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
|
||||
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })
|
||||
if (authorized) TrainlogAction(strings.getString(R.string.settings_reload_catalog), strings.getString(R.string.settings_reload_description), {
|
||||
when (val result = inbox.importPcCatalog()) {
|
||||
is CatalogInboxResult.Imported -> { message = "Catalogue relu · ${result.imported} nouveau(x), ${result.reconciled} réconcilié(s)."; onCatalogChanged() }
|
||||
CatalogInboxResult.FileNotFound -> message = "Aucun catalogue PC reçu."
|
||||
CatalogInboxResult.FolderNotAuthorized -> { authorized = false; message = "Le dossier n'est plus autorisé." }
|
||||
is CatalogInboxResult.Error -> message = result.message
|
||||
is CatalogInboxResult.Imported -> { message = strings.getString(R.string.settings_catalog_reloaded,
|
||||
catalogCountsText(strings, result.imported, result.reconciled)); onCatalogChanged() }
|
||||
CatalogInboxResult.FileNotFound -> message = strings.getString(R.string.settings_no_catalog)
|
||||
CatalogInboxResult.FolderNotAuthorized -> { authorized = false; message = strings.getString(R.string.settings_folder_revoked) }
|
||||
is CatalogInboxResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
})
|
||||
message?.let { TrainlogInfo(it, if (authorized) colors.success else colors.error) }
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import com.labfytools.trainlog.model.SessionExerciseDetail
|
|||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
@Composable
|
||||
fun SessionDetailScreen(
|
||||
|
|
@ -36,6 +37,8 @@ fun SessionDetailScreen(
|
|||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val locale = presentationLocale()
|
||||
val strings = localizedContext()
|
||||
|
||||
var revision by remember(sessionId) { mutableStateOf(0) }
|
||||
var editingEntryId by remember(sessionId) { mutableStateOf<String?>(null) }
|
||||
|
|
@ -61,15 +64,15 @@ fun SessionDetailScreen(
|
|||
}
|
||||
|
||||
TrainlogScreen(
|
||||
subtitle = "Détail de la séance"
|
||||
subtitle = strings.getString(R.string.route_session_detail)
|
||||
) {
|
||||
if (detail == null) {
|
||||
TrainlogFrame(
|
||||
title = "Erreur"
|
||||
title = strings.getString(R.string.error_title)
|
||||
) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Séance introuvable.",
|
||||
strings.getString(R.string.session_not_found),
|
||||
color =
|
||||
colors.error,
|
||||
)
|
||||
|
|
@ -79,7 +82,7 @@ fun SessionDetailScreen(
|
|||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = "Séance"
|
||||
title = strings.getString(R.string.home_session)
|
||||
) {
|
||||
TrainlogInfo(
|
||||
formatStartedAt(
|
||||
|
|
@ -89,11 +92,8 @@ fun SessionDetailScreen(
|
|||
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Type : " +
|
||||
sessionTypeLabel(
|
||||
detail.summary
|
||||
.sessionType
|
||||
),
|
||||
strings.getString(R.string.session_type_value,
|
||||
sessionTypeLabel(detail.summary.sessionType)),
|
||||
color =
|
||||
if (
|
||||
detail.summary
|
||||
|
|
@ -107,21 +107,22 @@ fun SessionDetailScreen(
|
|||
)
|
||||
|
||||
TrainlogInfo(
|
||||
"${detail.summary.exerciseCount} exercice(s)"
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count,
|
||||
detail.summary.exerciseCount, detail.summary.exerciseCount)
|
||||
)
|
||||
|
||||
TrainlogButton("Modifier la séance", onCorrect,
|
||||
TrainlogButton(strings.getString(R.string.route_session_correction), onCorrect,
|
||||
Modifier.fillMaxWidth().testTag("edit-completed-session"))
|
||||
|
||||
if (detail.summary.sessionType == SessionType.MAX_TEST) {
|
||||
TrainlogAction(
|
||||
label = "Reprendre ce Test max",
|
||||
description = "Continuer la même séance en conservant son identifiant et sa date.",
|
||||
label = strings.getString(R.string.resume_max_test),
|
||||
description = strings.getString(R.string.resume_max_description),
|
||||
accent = colors.success,
|
||||
onClick = {
|
||||
when (val result = repository.resumeMaxTestSession(detail.summary.sessionId)) {
|
||||
ActiveDraftMutationResult.Saved -> onResumeMaxTest()
|
||||
is ActiveDraftMutationResult.Error -> resumeMessage = result.message
|
||||
is ActiveDraftMutationResult.Error -> resumeMessage = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -139,25 +140,25 @@ fun SessionDetailScreen(
|
|||
"${index + 1}. ${exercise.exerciseName}"
|
||||
) {
|
||||
exercise.equipmentDisplayName?.let { equipment ->
|
||||
TrainlogInfo("Équipement : $equipment", color = colors.muted)
|
||||
TrainlogInfo(strings.getString(R.string.equipment_inline, equipment), color = colors.muted)
|
||||
}
|
||||
exercise.plan?.let { plan ->
|
||||
TrainlogInfo(
|
||||
"Plan : ${plan.sets} × ${plan.reps ?: plan.durationSeconds} · " +
|
||||
"repos ${plan.restSeconds} s · " +
|
||||
(plan.weightKg?.let { "charge cible $it kg" }
|
||||
?: "sans charge numérique"),
|
||||
strings.getString(R.string.plan_summary, plan.sets,
|
||||
(plan.reps ?: plan.durationSeconds).toString(), plan.restSeconds,
|
||||
plan.weightKg?.let { strings.getString(R.string.target_load_inline, it) }
|
||||
?: strings.getString(R.string.no_numeric_load)),
|
||||
color = colors.muted,
|
||||
)
|
||||
}
|
||||
if (editingEntryId == exercise.entryId) {
|
||||
TrainlogInputField(
|
||||
label = "Rechercher une machine",
|
||||
label = strings.getString(R.string.search_machine),
|
||||
value = equipmentQuery,
|
||||
onValueChange = { equipmentQuery = it },
|
||||
)
|
||||
TrainlogDeleteButton(
|
||||
contentDescription = "Retirer la machine de cet exercice",
|
||||
contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_remove_machine),
|
||||
modifier = Modifier.testTag("delete-completed-equipment-${exercise.entryId}"),
|
||||
onClick = {
|
||||
pendingEquipmentChange = exercise.entryId to null
|
||||
|
|
@ -179,17 +180,17 @@ fun SessionDetailScreen(
|
|||
}
|
||||
} else {
|
||||
TrainlogAction(
|
||||
label = "Modifier l'équipement",
|
||||
description = "Choisir, remplacer ou retirer la machine de cette entrée.",
|
||||
label = strings.getString(R.string.modify_equipment),
|
||||
description = strings.getString(R.string.modify_equipment_description),
|
||||
accent = colors.muted,
|
||||
onClick = { editingEntryId = exercise.entryId },
|
||||
)
|
||||
}
|
||||
if (exercise.maxWeightKg != null) {
|
||||
val rendered = "%.2f".format(java.util.Locale.FRANCE, exercise.maxWeightKg)
|
||||
val rendered = "%.2f".format(locale, exercise.maxWeightKg)
|
||||
.trimEnd('0').trimEnd(',')
|
||||
TrainlogInfo(
|
||||
text = "Max : $rendered kg",
|
||||
text = strings.getString(R.string.max_value, rendered),
|
||||
color = colors.warning,
|
||||
)
|
||||
} else if (
|
||||
|
|
@ -208,67 +209,70 @@ fun SessionDetailScreen(
|
|||
)
|
||||
}
|
||||
exercise.feedback.forEach { item ->
|
||||
TrainlogInfo("${exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)} · ${item.rawText}${if (item.modified) " · Modifié" else ""}")
|
||||
TrainlogAction("Modifier", "Corriger le texte sans déplacer l’observation.",
|
||||
val elapsed = exerciseFeedbackElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt)
|
||||
// CONTRACT: repository diagnostics remain stable data; only this visible sentinel is localized.
|
||||
val localizedElapsed = if (elapsed == "Pendant la séance") strings.getString(R.string.during_session) else elapsed
|
||||
TrainlogInfo("$localizedElapsed · ${item.rawText}${if (item.modified) strings.getString(R.string.modified_suffix) else ""}")
|
||||
TrainlogAction(strings.getString(R.string.modify), strings.getString(R.string.feedback_move_description),
|
||||
onClick = { editingFeedbackId = item.feedbackId }, accent = colors.muted)
|
||||
if (editingFeedbackId == item.feedbackId) FeedbackEditor(
|
||||
initialText = item.rawText, editing = true,
|
||||
onSave = { text -> when (val result = repository.reviseExerciseFeedback(item.feedbackId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { editingFeedbackId = null; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
|
||||
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
|
||||
} }, onCancel = { editingFeedbackId = null })
|
||||
}
|
||||
if (feedbackEntryId == exercise.entryId) {
|
||||
FeedbackEditor(onSave = { text ->
|
||||
when (val result = repository.saveExerciseFeedback(detail.summary.sessionId, exercise.entryId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { feedbackEntryId = null; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
|
||||
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}, onCancel = { feedbackEntryId = null })
|
||||
} else {
|
||||
TrainlogButton("Ajouter un ressenti", { feedbackEntryId = exercise.entryId },
|
||||
TrainlogButton(strings.getString(R.string.add_feedback), { feedbackEntryId = exercise.entryId },
|
||||
Modifier.fillMaxWidth().testTag("add-exercise-feedback-${exercise.entryId}"),
|
||||
icon = TrainlogIcons.Mic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(title = "Suivi après séance") {
|
||||
TrainlogFrame(title = strings.getString(R.string.session_follow_up)) {
|
||||
detail.followUps.forEach { item ->
|
||||
TrainlogInfo(sessionFollowUpElapsedLabel(detail.summary.endedAt, detail.summary.startedAt, item.observedAt), color = colors.accent)
|
||||
TrainlogInfo(item.rawText + if (item.modified) " · Modifié" else "")
|
||||
TrainlogAction("Modifier", "Corriger le texte sans changer le repère H+.",
|
||||
TrainlogInfo(item.rawText + if (item.modified) strings.getString(R.string.modified_suffix) else "")
|
||||
TrainlogAction(strings.getString(R.string.modify), strings.getString(R.string.feedback_marker_description),
|
||||
onClick = { editingFollowUpId = item.followupId }, accent = colors.muted)
|
||||
if (editingFollowUpId == item.followupId) FeedbackEditor(
|
||||
initialText = item.rawText, editing = true,
|
||||
onSave = { text -> when (val result = repository.reviseSessionFollowUp(item.followupId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { editingFollowUpId = null; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
|
||||
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
|
||||
} }, onCancel = { editingFollowUpId = null })
|
||||
}
|
||||
if (addingFollowUp) {
|
||||
FeedbackEditor(onSave = { text ->
|
||||
when (val result = repository.saveSessionFollowUp(detail.summary.sessionId, text)) {
|
||||
is SaveFeedbackResult.Saved -> { addingFollowUp = false; revision++; null }
|
||||
is SaveFeedbackResult.Invalid -> result.message
|
||||
is SaveFeedbackResult.DatabaseError -> result.message
|
||||
is SaveFeedbackResult.Invalid -> localizedRepositoryMessage(strings, result.message)
|
||||
is SaveFeedbackResult.DatabaseError -> localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}, onCancel = { addingFollowUp = false })
|
||||
} else TrainlogButton("Ajouter un enregistrement", { addingFollowUp = true },
|
||||
} else TrainlogButton(strings.getString(R.string.add_record), { addingFollowUp = true },
|
||||
Modifier.fillMaxWidth().testTag("add-session-follow-up"), icon = TrainlogIcons.Mic,
|
||||
containerColor = colors.success)
|
||||
}
|
||||
pendingEquipmentChange?.let { (entryId, equipmentId) ->
|
||||
DestructiveConfirmationDialog(
|
||||
title = if (equipmentId == null) "Retirer la machine de cette entrée ?" else "Remplacer la machine associée ?",
|
||||
detail = "L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés.",
|
||||
confirmLabel = if (equipmentId == null) "Retirer" else "Remplacer",
|
||||
title = strings.getString(if (equipmentId == null) R.string.remove_machine_question else R.string.replace_machine_question),
|
||||
detail = strings.getString(R.string.machine_change_detail),
|
||||
confirmLabel = strings.getString(if (equipmentId == null) R.string.remove else R.string.replace),
|
||||
onCancel = { pendingEquipmentChange = null },
|
||||
deleteContentDescription = if (equipmentId == null) {
|
||||
"Retirer la machine de cet exercice"
|
||||
strings.getString(R.string.a11y_remove_machine)
|
||||
} else null,
|
||||
onConfirm = {
|
||||
if (repository.setCompletedSessionEquipment(detail.summary.sessionId,entryId,equipmentId)) {
|
||||
|
|
@ -287,6 +291,8 @@ private fun SetsDetail(
|
|||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val locale = presentationLocale()
|
||||
val strings = localizedContext()
|
||||
|
||||
TrainlogInfo(
|
||||
text =
|
||||
|
|
@ -294,9 +300,9 @@ private fun SetsDetail(
|
|||
exercise.trackingMode ==
|
||||
TrackingMode.REPS
|
||||
) {
|
||||
"Mode : séries · répétitions"
|
||||
strings.getString(R.string.mode_sets_reps)
|
||||
} else {
|
||||
"Mode : séries · durée"
|
||||
strings.getString(R.string.mode_sets_duration)
|
||||
},
|
||||
color = colors.accent,
|
||||
)
|
||||
|
|
@ -304,13 +310,13 @@ private fun SetsDetail(
|
|||
if (exercise.trackingMode == TrackingMode.REPS) {
|
||||
val loadHeading =
|
||||
if (loadSemantics == EquipmentLoadSemantics.ASSISTANCE) {
|
||||
"Assistance (kg)"
|
||||
strings.getString(R.string.assistance_kg)
|
||||
} else {
|
||||
"Charge (kg)"
|
||||
strings.getString(R.string.field_weight)
|
||||
}
|
||||
/* Readable row table: history must expose every persisted value and
|
||||
* distinguish an absent load from an explicit zero. */
|
||||
TrainlogInfo("Série | Répétitions | $loadHeading", color = colors.muted)
|
||||
TrainlogInfo(strings.getString(R.string.sets_table, loadHeading), color = colors.muted)
|
||||
}
|
||||
|
||||
exercise.sets
|
||||
|
|
@ -324,11 +330,11 @@ private fun SetsDetail(
|
|||
TrackingMode.REPS
|
||||
) {
|
||||
val renderedWeight = set.weightKg?.let {
|
||||
"%.2f".format(java.util.Locale.FRANCE, it).trimEnd('0').trimEnd(',')
|
||||
"%.2f".format(locale, it).trimEnd('0').trimEnd(',', '.')
|
||||
} ?: "—"
|
||||
"${index + 1} | ${set.reps} | $renderedWeight"
|
||||
} else {
|
||||
"Série ${index + 1} : ${formatDuration(set.durationSeconds)}"
|
||||
strings.getString(R.string.set_duration, index + 1, formatDuration(set.durationSeconds))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -340,15 +346,16 @@ private fun ContinuousDetail(
|
|||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
|
||||
TrainlogInfo(
|
||||
text =
|
||||
"Mode : continu",
|
||||
strings.getString(R.string.mode_continuous),
|
||||
color = colors.accent,
|
||||
)
|
||||
|
||||
TrainlogInfo(
|
||||
"Durée : ${formatDuration(exercise.continuousDurationSeconds)}"
|
||||
strings.getString(R.string.duration_value, formatDuration(exercise.continuousDurationSeconds))
|
||||
)
|
||||
|
||||
if (
|
||||
|
|
@ -356,9 +363,7 @@ private fun ContinuousDetail(
|
|||
ExerciseDataFields.SPEED_KMH != 0
|
||||
) {
|
||||
TrainlogInfo(
|
||||
"Vitesse : %.1f km/h".format(
|
||||
exercise.speedKmh ?: 0.0
|
||||
)
|
||||
strings.getString(R.string.speed_value, exercise.speedKmh ?: 0.0)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -367,13 +372,12 @@ private fun ContinuousDetail(
|
|||
ExerciseDataFields.DISTANCE_KM != 0
|
||||
) {
|
||||
TrainlogInfo(
|
||||
"Distance : %.2f km".format(
|
||||
exercise.distanceKm ?: 0.0
|
||||
)
|
||||
strings.getString(R.string.distance_value, exercise.distanceKm ?: 0.0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun formatDuration(
|
||||
seconds: Int,
|
||||
): String {
|
||||
|
|
@ -381,8 +385,8 @@ private fun formatDuration(
|
|||
seconds > 0 &&
|
||||
seconds % 60 == 0
|
||||
) {
|
||||
return "${seconds / 60} min"
|
||||
return uiQuantity(R.plurals.duration_minutes_value, seconds / 60, seconds / 60)
|
||||
}
|
||||
|
||||
return "$seconds s"
|
||||
return uiQuantity(R.plurals.duration_seconds_value, seconds, seconds)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('_', ' ')
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -40,6 +40,7 @@ import com.labfytools.trainlog.data.StatisticsSeries
|
|||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
import com.labfytools.trainlog.R
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
|
|
@ -47,6 +48,15 @@ import kotlin.math.max
|
|||
|
||||
private enum class StatisticsDetail { PROGRESS, BODY, FREQUENCY, DISTRIBUTION, SUMMARY }
|
||||
|
||||
@Composable
|
||||
private fun statisticsPeriodLabel(period: StatisticsPeriod): String = uiString(when (period) {
|
||||
StatisticsPeriod.DAYS_7 -> R.string.period_7_days
|
||||
StatisticsPeriod.DAYS_30 -> R.string.period_30_days
|
||||
StatisticsPeriod.DAYS_90 -> R.string.period_90_days
|
||||
StatisticsPeriod.YEAR -> R.string.period_year
|
||||
StatisticsPeriod.ALL -> R.string.period_all
|
||||
})
|
||||
|
||||
internal data class DashboardWeek(val label: String, val sessions: Int, val maxima: Int)
|
||||
|
||||
internal fun sixWeekFrequency(
|
||||
|
|
@ -70,38 +80,40 @@ internal fun sixWeekFrequency(
|
|||
|
||||
@Composable
|
||||
fun StatisticsDashboard(repository: TrainlogRepository) {
|
||||
val strings = localizedContext()
|
||||
var period by remember { mutableStateOf(StatisticsPeriod.DAYS_30) }
|
||||
var detail by remember { mutableStateOf<StatisticsDetail?>(null) }
|
||||
val overview = remember(period) { repository.loadStatistics(period) }
|
||||
if (detail != null) return StatisticsDetailScreen(detail!!, overview) { detail = null }
|
||||
TrainlogScreen("Statistiques") {
|
||||
TrainlogChoiceChips(StatisticsPeriod.entries.map { it.name to it.label }, period.name) {
|
||||
TrainlogScreen(strings.getString(R.string.nav_statistics)) {
|
||||
TrainlogChoiceChips(StatisticsPeriod.entries.map { it.name to statisticsPeriodLabel(it) }, period.name) {
|
||||
period = StatisticsPeriod.valueOf(it)
|
||||
}
|
||||
if (overview.hasInvalidData) TrainlogInfo(
|
||||
"Certaines données historiques invalides ont été ignorées; les statistiques valides restent disponibles.",
|
||||
strings.getString(R.string.statistics_invalid_ignored),
|
||||
)
|
||||
SummaryGrid(overview)
|
||||
PerformanceCard(overview) { detail = StatisticsDetail.PROGRESS }
|
||||
MeasurementsCard(overview) { detail = StatisticsDetail.BODY }
|
||||
FrequencyCard(overview) { detail = StatisticsDetail.FREQUENCY }
|
||||
TrainlogFrame("EXPLORER") {
|
||||
TrainlogAction("Répartition", "Zones et utilisation des exercices, uniquement quand elles sont classifiées.", { detail = StatisticsDetail.DISTRIBUTION })
|
||||
TrainlogAction("Résumé global", "Indicateurs prudents dérivés des données disponibles.", { detail = StatisticsDetail.SUMMARY })
|
||||
TrainlogFrame(strings.getString(R.string.explore)) {
|
||||
TrainlogAction(strings.getString(R.string.distribution), strings.getString(R.string.distribution_description), { detail = StatisticsDetail.DISTRIBUTION })
|
||||
TrainlogAction(strings.getString(R.string.global_summary), strings.getString(R.string.global_summary_description), { detail = StatisticsDetail.SUMMARY })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryGrid(overview: StatisticsOverview) {
|
||||
TrainlogFrame("RÉSUMÉ · ${overview.period.label}") {
|
||||
val strings = localizedContext()
|
||||
TrainlogFrame(strings.getString(R.string.summary_period, statisticsPeriodLabel(overview.period))) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Metric("Séances", overview.summary.sessions.toString(), Modifier.weight(1f))
|
||||
Metric("Séries", overview.summary.performedSets.toString(), Modifier.weight(1f))
|
||||
Metric(strings.getString(R.string.nav_sessions), overview.summary.sessions.toString(), Modifier.weight(1f))
|
||||
Metric(strings.getString(R.string.sets), overview.summary.performedSets.toString(), Modifier.weight(1f))
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Metric("Exercices", overview.summary.distinctExercises.toString(), Modifier.weight(1f))
|
||||
Metric("MAX explicites", overview.summary.explicitMaxima.toString(), Modifier.weight(1f))
|
||||
Metric(strings.getString(R.string.nav_exercises), overview.summary.distinctExercises.toString(), Modifier.weight(1f))
|
||||
Metric(strings.getString(R.string.explicit_max_plural), overview.summary.explicitMaxima.toString(), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -120,17 +132,18 @@ internal fun PerformanceCard(overview: StatisticsOverview, click: () -> Unit) {
|
|||
val colors = LocalTrainlogColors.current
|
||||
val work = overview.performanceEvents.sumOf { it.workingImprovements }
|
||||
val maxima = overview.performanceEvents.sumOf { it.maxImprovements }
|
||||
TrainlogFrame("PROGRESSION", active = true) {
|
||||
val strings = localizedContext()
|
||||
TrainlogFrame(strings.getString(R.string.progression), active = true) {
|
||||
Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 2.dp)) {
|
||||
PerformanceTimeline(overview)
|
||||
Text(
|
||||
if (work + maxima == 0) "Données insuffisantes pour établir une progression comparable."
|
||||
else "$work progression(s) travail · $maxima record(s) MAX",
|
||||
if (work + maxima == 0) strings.getString(R.string.progress_insufficient)
|
||||
else strings.getString(R.string.progress_counts, work, maxima),
|
||||
style = TrainlogTypography.small.copy(color = if (work + maxima == 0) colors.muted else colors.text),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
Text(
|
||||
"${overview.summary.explicitMaxima} MAX explicite(s) · ouvrir le détail",
|
||||
strings.getString(R.string.max_open_detail, overview.summary.explicitMaxima),
|
||||
style = TrainlogTypography.small.copy(color = colors.notice),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
|
@ -144,8 +157,8 @@ private fun PerformanceTimeline(overview: StatisticsOverview) {
|
|||
val recent = overview.performanceEvents.takeLast(6).map { it.workingImprovements + it.maxImprovements }
|
||||
val values = List(6 - recent.size) { 0 } + recent
|
||||
val description = if (values.all { it == 0 })
|
||||
"Progression sur six semaines : aucun événement comparable, six points vides"
|
||||
else "Progression sur six semaines : ${values.joinToString()} événements"
|
||||
uiString(R.string.progress_a11y_empty)
|
||||
else uiString(R.string.progress_a11y_values, values.joinToString())
|
||||
Canvas(Modifier.fillMaxWidth().height(54.dp).semantics { contentDescription = description }) {
|
||||
val step = size.width / values.size
|
||||
val peak = max(1, values.max())
|
||||
|
|
@ -160,10 +173,11 @@ private fun PerformanceTimeline(overview: StatisticsOverview) {
|
|||
@Composable
|
||||
internal fun MeasurementsCard(overview: StatisticsOverview, click: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
TrainlogFrame("MENSURATIONS", active = overview.body.isNotEmpty()) {
|
||||
val strings = localizedContext()
|
||||
TrainlogFrame(strings.getString(R.string.route_body_measurements).uppercase(presentationLocale()), active = overview.body.isNotEmpty()) {
|
||||
Column(Modifier.fillMaxWidth().clickable(onClick = click)) {
|
||||
if (overview.body.isEmpty()) {
|
||||
Text("Aucune mensuration dans cette période.", style = TrainlogTypography.normal.copy(color = colors.muted))
|
||||
Text(strings.getString(R.string.body_period_none), style = TrainlogTypography.normal.copy(color = colors.muted))
|
||||
} else overview.body.chunked(2).forEachIndexed { rowIndex, row ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = if (rowIndex == 0) 0.dp else 8.dp),
|
||||
|
|
@ -183,11 +197,11 @@ private fun MeasurementTile(series: StatisticsSeries, modifier: Modifier) {
|
|||
val latest = series.points.last().value
|
||||
val delta = series.points.takeIf { it.size >= 2 }?.let { latest - it[it.lastIndex - 1].value }
|
||||
Column(modifier.background(colors.surface, RoundedCornerShape(10.dp)).padding(10.dp)) {
|
||||
Text(series.label, style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
Text("$latest ${series.unit}", style = TrainlogTypography.section.copy(color = colors.text))
|
||||
if (delta == null) Text("1 relevé · tendance indisponible", style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
Text(localizedStatisticsSeriesLabel(series), style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
Text("${presentationNumber(latest)} ${series.unit}", style = TrainlogTypography.section.copy(color = colors.text))
|
||||
if (delta == null) Text(uiString(R.string.measurement_single), style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
else {
|
||||
Text("Δ ${if (delta >= 0) "+" else ""}$delta", style = TrainlogTypography.small.copy(color = colors.info))
|
||||
Text("Δ ${if (delta >= 0) "+" else ""}${presentationNumber(delta)}", style = TrainlogTypography.small.copy(color = colors.info))
|
||||
LineChart(listOf(series), Modifier.height(42.dp))
|
||||
}
|
||||
}
|
||||
|
|
@ -198,16 +212,18 @@ internal fun FrequencyCard(overview: StatisticsOverview, today: LocalDate = Loca
|
|||
val colors = LocalTrainlogColors.current
|
||||
val weeks = sixWeekFrequency(overview.frequency, today)
|
||||
val current = weeks.last()
|
||||
TrainlogFrame("FRÉQUENCE", active = overview.frequency.isNotEmpty()) {
|
||||
val strings = localizedContext()
|
||||
TrainlogFrame(strings.getString(R.string.frequency), active = overview.frequency.isNotEmpty()) {
|
||||
Column(Modifier.fillMaxWidth().clickable(onClick = click)) {
|
||||
FrequencyBars(weeks)
|
||||
Text(
|
||||
"Semaine actuelle · ${current.sessions} séance(s) · ${current.maxima} MAX inclus",
|
||||
strings.getString(R.string.current_week_summary,
|
||||
strings.resources.getQuantityString(R.plurals.session_count, current.sessions, current.sessions), current.maxima),
|
||||
style = TrainlogTypography.small.copy(color = colors.accent, fontWeight = FontWeight.Bold),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
Text(
|
||||
"Les MAX signalent des séances; ils ne sont jamais additionnés une seconde fois.",
|
||||
strings.getString(R.string.max_not_double_counted),
|
||||
style = TrainlogTypography.small.copy(color = colors.muted),
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
|
|
@ -218,9 +234,12 @@ internal fun FrequencyCard(overview: StatisticsOverview, today: LocalDate = Loca
|
|||
@Composable
|
||||
private fun FrequencyBars(weeks: List<DashboardWeek>) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val description = "Fréquence sur six semaines : " + weeks.mapIndexed { index, week ->
|
||||
"${if (index == weeks.lastIndex) "actuelle" else week.label} ${week.sessions} séances dont ${week.maxima} MAX"
|
||||
}.joinToString("; ")
|
||||
val strings = localizedContext()
|
||||
val description = strings.getString(R.string.frequency_a11y, weeks.mapIndexed { index, week ->
|
||||
strings.getString(R.string.frequency_week_a11y,
|
||||
if (index == weeks.lastIndex) strings.getString(R.string.current_feminine) else week.label,
|
||||
strings.resources.getQuantityString(R.plurals.session_count, week.sessions, week.sessions), week.maxima)
|
||||
}.joinToString("; "))
|
||||
Canvas(Modifier.fillMaxWidth().height(116.dp).semantics { contentDescription = description }) {
|
||||
val peak = max(1, weeks.maxOf { it.sessions })
|
||||
val slot = size.width / weeks.size
|
||||
|
|
@ -247,10 +266,10 @@ private fun DashboardCard(title: String, subtitle: String, series: List<Statisti
|
|||
TrainlogFrame(title, active = series.isNotEmpty()) {
|
||||
Column(Modifier.fillMaxWidth().clickable(onClick = click).padding(vertical = 4.dp)) {
|
||||
Text(subtitle, style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
if (series.isEmpty()) Text("Pas encore assez de données pour une courbe.", style = TrainlogTypography.normal.copy(color = colors.muted), modifier = Modifier.padding(top = 12.dp))
|
||||
if (series.isEmpty()) Text(uiString(R.string.chart_insufficient), style = TrainlogTypography.normal.copy(color = colors.muted), modifier = Modifier.padding(top = 12.dp))
|
||||
else {
|
||||
LineChart(series.take(3))
|
||||
Text("${series.size} série(s) comparable(s) · ouvrir le détail", style = TrainlogTypography.small.copy(color = colors.accent), modifier = Modifier.padding(top = 6.dp))
|
||||
Text(uiString(R.string.chart_series_detail, series.size), style = TrainlogTypography.small.copy(color = colors.accent), modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -281,17 +300,59 @@ private fun LineChart(series: List<StatisticsSeries>, modifier: Modifier = Modif
|
|||
|
||||
@Composable
|
||||
private fun StatisticsDetailScreen(detail: StatisticsDetail, overview: StatisticsOverview, back: () -> Unit) {
|
||||
val strings = localizedContext()
|
||||
val (title, series, description) = when (detail) {
|
||||
StatisticsDetail.PROGRESS -> Triple("Progression", overview.performance, "Chaque ligne de détail conserve un exercice canonique, un équipement et un mode comparables; le dashboard ne somme jamais des kg.")
|
||||
StatisticsDetail.BODY -> Triple("Mensurations", overview.body, "Aucune interpolation : seuls les relevés réellement enregistrés sont affichés.")
|
||||
StatisticsDetail.FREQUENCY -> Triple("Fréquence", listOf(StatisticsSeries("f", "Séances", "séances", overview.frequency.map { x -> StatisticsPoint(x.week, x.all.toDouble(), x.week) })), "Nombre de séances avec travail réel par semaine; MAX est un marqueur, pas une seconde séance.")
|
||||
StatisticsDetail.DISTRIBUTION -> Triple("Répartition", emptyList(), "La répartition détaillée arrive lorsque les occurrences classifiées sont disponibles dans cette vue.")
|
||||
StatisticsDetail.SUMMARY -> Triple("Résumé global", emptyList(), "${overview.summary.sessions} séances · ${overview.summary.performedSets} séries · ${overview.summary.distinctExercises} exercices · ${overview.summary.explicitMaxima} MAX explicites")
|
||||
StatisticsDetail.PROGRESS -> Triple(strings.getString(R.string.progression), overview.performance, strings.getString(R.string.progress_detail_description))
|
||||
StatisticsDetail.BODY -> Triple(strings.getString(R.string.route_body_measurements), overview.body, strings.getString(R.string.body_detail_description))
|
||||
StatisticsDetail.FREQUENCY -> Triple(strings.getString(R.string.frequency_title), listOf(StatisticsSeries("f", strings.getString(R.string.nav_sessions), strings.getString(R.string.frequency_unit), overview.frequency.map { x -> StatisticsPoint(x.week, x.all.toDouble(), x.week) })), strings.getString(R.string.frequency_detail_description))
|
||||
StatisticsDetail.DISTRIBUTION -> Triple(strings.getString(R.string.distribution), emptyList(), strings.getString(R.string.distribution_detail_description))
|
||||
StatisticsDetail.SUMMARY -> Triple(strings.getString(R.string.global_summary), emptyList(), strings.getString(R.string.summary_detail, overview.summary.sessions, overview.summary.performedSets, overview.summary.distinctExercises, overview.summary.explicitMaxima))
|
||||
}
|
||||
TrainlogScreen(title) {
|
||||
TrainlogAction("← Retour au dashboard", "", back)
|
||||
TrainlogAction(strings.getString(R.string.back_dashboard), "", back)
|
||||
TrainlogInfo(description)
|
||||
if (series.isEmpty()) TrainlogInfo("Aucune donnée comparable dans cette période.")
|
||||
else series.forEach { DashboardCard(it.label, "${it.points.size} point(s) · ${it.unit}", listOf(it), {}) }
|
||||
if (series.isEmpty()) TrainlogInfo(strings.getString(R.string.comparable_none))
|
||||
else series.forEach {
|
||||
DashboardCard(
|
||||
localizedStatisticsSeriesLabel(it),
|
||||
strings.resources.getQuantityString(R.plurals.point_count, it.points.size, it.points.size) + " · ${it.unit}",
|
||||
listOf(it),
|
||||
{},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun localizedStatisticsSeriesLabel(series: StatisticsSeries): String {
|
||||
val strings = localizedContext()
|
||||
val bodyLabel = when (series.id) {
|
||||
"body_weight_kg" -> R.string.weight
|
||||
"neck_cm" -> R.string.neck
|
||||
"shoulders_cm" -> R.string.shoulders
|
||||
"chest_cm" -> R.string.chest
|
||||
"waist_cm" -> R.string.waist
|
||||
"hips_cm" -> R.string.hips
|
||||
"left_arm_cm" -> R.string.left_arm
|
||||
"right_arm_cm" -> R.string.right_arm
|
||||
"left_forearm_cm" -> R.string.left_forearm
|
||||
"right_forearm_cm" -> R.string.right_forearm
|
||||
"left_thigh_cm" -> R.string.left_thigh
|
||||
"right_thigh_cm" -> R.string.right_thigh
|
||||
"left_calf_cm" -> R.string.left_calf
|
||||
"right_calf_cm" -> R.string.right_calf
|
||||
else -> null
|
||||
}
|
||||
if (bodyLabel != null) return strings.getString(bodyLabel)
|
||||
// INVARIANT: exercise/equipment names in the prefix are user/catalog data and remain byte-for-byte unchanged.
|
||||
return when {
|
||||
series.id.startsWith("max:") -> series.label.substringBeforeLast(" — ") + " — " + strings.getString(R.string.explicit_max_label)
|
||||
series.id.startsWith("work:") -> {
|
||||
val prefix = series.label.substringBeforeLast(" — ")
|
||||
.replace(Regex(" reps$"), " " + strings.getString(R.string.reps_short))
|
||||
.replace(Regex(" duration$"), " s")
|
||||
prefix + " — " + strings.getString(R.string.performed_loads)
|
||||
}
|
||||
else -> series.label
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
399
android/app/src/main/res/values-en/strings.xml
Normal file
399
android/app/src/main/res/values-en/strings.xml
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Trainlog</string>
|
||||
<string name="nav_home">Home</string>
|
||||
<string name="nav_sessions">Sessions</string>
|
||||
<string name="nav_exercises">Exercises</string>
|
||||
<string name="nav_equipment">Equipment</string>
|
||||
<string name="nav_statistics">Statistics</string>
|
||||
<string name="nav_sync">Synchronization</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="route_session_editor">Current session</string>
|
||||
<string name="route_session_generator">Plan a session</string>
|
||||
<string name="route_ai_drafts">Drafts</string>
|
||||
<string name="route_completed_sessions">Completed sessions</string>
|
||||
<string name="route_session_detail">Session details</string>
|
||||
<string name="route_session_correction">Edit session</string>
|
||||
<string name="route_exercise_detail">Exercise details</string>
|
||||
<string name="route_exercise_edit">Edit exercise</string>
|
||||
<string name="route_exercise_create">Create exercise</string>
|
||||
<string name="route_equipment_detail">Equipment details</string>
|
||||
<string name="route_equipment_create">Create equipment</string>
|
||||
<string name="route_body_measurements">Body measurements</string>
|
||||
<string name="route_latest_maxima">Latest MAX</string>
|
||||
<string name="a11y_open_navigation">Open navigation</string>
|
||||
<string name="a11y_back">Back</string>
|
||||
<string name="a11y_navigation">Navigation</string>
|
||||
<string name="settings_language_section">LANGUAGE</string>
|
||||
<string name="settings_language_label">Language</string>
|
||||
<string name="language_french">Français</string>
|
||||
<string name="language_english">English</string>
|
||||
<string name="settings_exchange_folder">EXCHANGE FOLDER</string>
|
||||
<string name="settings_screen">Settings</string>
|
||||
<string name="dialog_cancel">Cancel</string>
|
||||
<string name="dialog_keep_leave">Keep and leave</string>
|
||||
<string name="dialog_discard_proposal">Discard proposal</string>
|
||||
<string name="dialog_discard_changes">Discard changes</string>
|
||||
<string name="dialog_unaccepted_proposal">Unaccepted proposal</string>
|
||||
<string name="dialog_unsaved_changes">Unsaved changes</string>
|
||||
<string name="dialog_proposal_retained">The proposal can remain in memory while you visit another section.</string>
|
||||
<string name="dialog_fields_retained">The fields remain in memory until you explicitly discard them.</string>
|
||||
<string name="a11y_delete_exercise">Delete this exercise</string>
|
||||
<string name="a11y_delete_current_session">Delete the current session</string>
|
||||
<string name="a11y_delete_set">Delete this set</string>
|
||||
<string name="a11y_remove_machine">Remove the machine from this exercise</string>
|
||||
<string name="a11y_delete_draft">Delete this draft</string>
|
||||
<string name="home_session">Session</string>
|
||||
<string name="home_sync_state">Status and actions</string>
|
||||
<string name="home_body_tracking">Body tracking</string>
|
||||
<string name="settings_access_granted">File access granted.</string>
|
||||
<string name="settings_access_required">File access is still required.</string>
|
||||
<string name="settings_folder_granted">Exchange folder: Documents/Trainlog\nFile access: granted</string>
|
||||
<string name="settings_folder_required">File access required</string>
|
||||
<string name="settings_open_access">Open access settings</string>
|
||||
<string name="settings_authorize">Authorize</string>
|
||||
<string name="settings_authorize_description">Allow access to the Trainlog folder in Android.</string>
|
||||
<string name="settings_reload_catalog">Reload PC catalog</string>
|
||||
<string name="settings_reload_description">Explicitly apply the catalog in the authorized folder.</string>
|
||||
<string name="settings_no_catalog">No PC catalog received.</string>
|
||||
<string name="settings_folder_revoked">The folder is no longer authorized.</string>
|
||||
<string name="sync_folder_unauthorized">Trainlog folder not authorized.</string>
|
||||
<string name="sync_complete">Synchronization complete · Android catalog: %1$s.</string>
|
||||
<string name="sync_receipt_failure">PC synchronization failed. Check the PC log for diagnostics.</string>
|
||||
<string name="sync_pc_catalog_missing">PC synchronization completed, but the received catalog could not be found.</string>
|
||||
<string name="sync_pc_folder_unauthorized">PC synchronization completed, but the Trainlog folder is not authorized.</string>
|
||||
<string name="sync_pc_import_error">PC synchronization completed; Android import: %1$s</string>
|
||||
<string name="sync_timeout">The PC did not respond within 60 seconds.</string>
|
||||
<string name="sync_access_ready">File access granted · Documents/Trainlog is ready.</string>
|
||||
<string name="sync_access_enable">File access is required. Enable the permission in Android.</string>
|
||||
<string name="sync_access_catalog">Access granted · PC catalog: %1$s.</string>
|
||||
<string name="sync_access_no_catalog">Access granted · no PC catalog received.</string>
|
||||
<string name="sync_action">Synchronize</string>
|
||||
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
|
||||
<string name="sync_running">Synchronization in progress…</string>
|
||||
<string name="sync_now">Synchronize now</string>
|
||||
<string name="sync_flow_description">Android → PC then PC → Android, in one operation.</string>
|
||||
<string name="sync_authorize_first">File access is required. Authorize Documents/Trainlog first.</string>
|
||||
<string name="sync_request_sent">Request sent · waiting for the PC…</string>
|
||||
<string name="sync_android_unsupported">Android is not supported.</string>
|
||||
<string name="sync_folder_section">EXCHANGE FOLDER</string>
|
||||
<string name="sync_authorize_folder">Authorize access to the Trainlog folder</string>
|
||||
<string name="sync_manage_files">Allow access to manage all files in Android.</string>
|
||||
<string name="sync_manual_recovery">Manual recovery action if needed.</string>
|
||||
<string name="sync_catalog_result">PC catalog: %1$s.</string>
|
||||
<string name="sync_folder_access_required">File access is required for Documents/Trainlog.</string>
|
||||
<string name="sync_status">Status</string>
|
||||
<string name="sync_prepare_error">Android → PC preparation: %1$s</string>
|
||||
<plurals name="catalog_new"><item quantity="one">%d new</item><item quantity="other">%d new</item></plurals>
|
||||
<plurals name="catalog_reconciled"><item quantity="one">%d reconciled</item><item quantity="other">%d reconciled</item></plurals>
|
||||
<plurals name="catalog_present"><item quantity="one">%d present</item><item quantity="other">%d present</item></plurals>
|
||||
<string name="history_none">No sessions recorded.</string>
|
||||
<string name="max_none">No explicit MAX recorded.</string>
|
||||
<string name="equipment_value">Equipment: %1$s</string>
|
||||
<string name="value_none">none</string>
|
||||
<string name="session_training">TRAINING</string>
|
||||
<string name="session_max_test">MAX TEST</string>
|
||||
<plurals name="exercise_count"><item quantity="one">%d exercise</item><item quantity="other">%d exercises</item></plurals>
|
||||
<string name="date_time_pattern">MM/dd/yyyy h:mm a</string>
|
||||
<string name="date_pattern">MM/dd/yyyy</string>
|
||||
<string name="correction_profile_note">Previous profile interpretations are preserved. The identifiers of the session and retained occurrences do not change.</string>
|
||||
<string name="field_target_sets">Target set count</string>
|
||||
<string name="field_target_reps">Target repetitions</string>
|
||||
<string name="field_target_duration">Target duration per set (s)</string>
|
||||
<string name="field_rest_seconds">Rest (s)</string>
|
||||
<string name="field_target_weight">Target load (kg)</string>
|
||||
<string name="field_explicit_max">Explicit MAX (kg)</string>
|
||||
<string name="field_continuous_duration">Continuous duration (s)</string>
|
||||
<string name="field_speed">Speed (km/h)</string>
|
||||
<string name="field_distance">Distance (km)</string>
|
||||
<string name="field_reps">Repetitions</string>
|
||||
<string name="field_duration_seconds">Duration (s)</string>
|
||||
<string name="field_weight">Load (kg)</string>
|
||||
<string name="set_number">Set %1$d</string>
|
||||
<string name="add_set">+ Add a set</string>
|
||||
<string name="add_set_description">Explicitly add a performed fact.</string>
|
||||
<string name="delete_occurrence">Delete this occurrence</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="correction_save_description">Apply the entire correction in one transaction.</string>
|
||||
<string name="cancel_no_database_change">Leave without changing the database.</string>
|
||||
<string name="delete_set_question">Delete this set?</string>
|
||||
<string name="delete_set_detail">This performed fact will disappear from the corrected session.</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="delete_occurrence_question">Delete this occurrence?</string>
|
||||
<string name="a11y_delete_occurrence">Delete this occurrence</string>
|
||||
<plurals name="performed_set_count"><item quantity="one">%d performed set</item><item quantity="other">%d performed sets</item></plurals>
|
||||
<string name="occurrence_delete_impact">Impact: %1$s%2$s%3$s. Its feedback and revisions will be deleted with their parent.</string>
|
||||
<string name="continuous_activity_suffix">, continuous activity</string>
|
||||
<string name="max_suffix">, MAX</string>
|
||||
<string name="session_not_found">Session not found.</string>
|
||||
<string name="feedback_dictation">Feedback dictation</string>
|
||||
<string name="feedback_recording">● Recording</string>
|
||||
<string name="feedback_stop">Stop dictation</string>
|
||||
<string name="feedback_text">Text</string>
|
||||
<string name="feedback_resume">Resume dictation</string>
|
||||
<string name="feedback_start">Start dictation</string>
|
||||
<string name="save_changes">Save changes</string>
|
||||
<string name="feedback_revision_description">Create a new immutable revision.</string>
|
||||
<string name="feedback_create_description">Create an immutable historical record.</string>
|
||||
<string name="save_nothing">Save nothing.</string>
|
||||
<string name="feedback_microphone_denied">Microphone permission denied: manual input remains available.</string>
|
||||
<string name="feedback_speech_unavailable">Speech recognition is unavailable: use manual input.</string>
|
||||
<string name="feedback_empty">Empty feedback cannot be saved.</string>
|
||||
<string name="current_session_none">No session in progress.</string>
|
||||
<string name="prepare">Prepare</string>
|
||||
<string name="new_manual_session">New manual session</string>
|
||||
<string name="create_or_resume">Create or resume</string>
|
||||
<plurals name="prepared_session_count"><item quantity="one">%d prepared session</item><item quantity="other">%d prepared sessions</item></plurals>
|
||||
<string name="draft_none">No imported drafts.</string>
|
||||
<string name="delete_draft_question">Delete this draft?</string>
|
||||
<string name="max_capacities">CAPABILITIES / MAX</string>
|
||||
<string name="equipment_catalog">Equipment catalog</string>
|
||||
<string name="catalog">Catalog</string>
|
||||
<string name="equipment_none">No equipment found.</string>
|
||||
<string name="load_value">Load: %1$s</string>
|
||||
<string name="possible_exercises">Possible exercises</string>
|
||||
<string name="association_none">No verified association. Anatomy is not inferred from the machine name.</string>
|
||||
<string name="settings_folder_imported">Folder authorized · %1$s.</string>
|
||||
<string name="settings_catalog_reloaded">Catalog reloaded · %1$s.</string>
|
||||
<string name="body_save">Save body measurements</string>
|
||||
<string name="body_saved">Body measurements saved.</string>
|
||||
<string name="local_database_error">Local database error.</string>
|
||||
<string name="body_none">No measurements recorded.</string>
|
||||
<string name="exercise_save">Save exercise</string>
|
||||
<string name="existing_exercises">Existing exercises</string>
|
||||
<string name="exercise_none_saved">No exercises saved.</string>
|
||||
<string name="modify_named">Edit · %1$s</string>
|
||||
<string name="view_latest_max">View latest MAX</string>
|
||||
<string name="name_no_rules">No rule depends on the name.</string>
|
||||
<string name="tracking_change_question">Change tracking mode?</string>
|
||||
<string name="modify">Edit</string>
|
||||
<string name="exercise_not_found">Exercise not found.</string>
|
||||
<string name="profile_loading">Loading profile…</string>
|
||||
<string name="primary_muscles">Primary muscles: %1$s</string>
|
||||
<string name="secondary_muscles">Secondary muscles: %1$s</string>
|
||||
<string name="stabilizers">Stabilizers: %1$s</string>
|
||||
<string name="unclassified">Unclassified</string>
|
||||
<string name="none_established">None established</string>
|
||||
<string name="exercise_none_found">No exercises found.</string>
|
||||
<string name="exercise_modify_description">Edit the name, profile, and zones according to the existing rules.</string>
|
||||
<string name="knowledge_none">No scientific knowledge is linked to this identifier.</string>
|
||||
<string name="view_latest_max_description">Open the existing aggregated list in Statistics.</string>
|
||||
<string name="value_none_feminine">None</string>
|
||||
<string name="field_duration">Duration</string>
|
||||
<string name="field_speed_short">Speed</string>
|
||||
<string name="field_distance_short">Distance</string>
|
||||
<string name="error_title">Error</string>
|
||||
<string name="draft_unsaved">Draft not saved: %1$s</string>
|
||||
<string name="draft_unsaved_title">Draft not saved</string>
|
||||
<string name="last_change_unsaved">Latest change not saved.</string>
|
||||
<string name="session_saved_locally">Session saved locally.</string>
|
||||
<string name="normal_session_description">Normal training session.</string>
|
||||
<string name="max_session_description">Session explicitly dedicated to a MAX measurement.</string>
|
||||
<string name="exercise_none_added">No exercises added.</string>
|
||||
<string name="performed_sets_none">No performed sets entered: add the values actually performed.</string>
|
||||
<string name="modify_name">Edit %1$s</string>
|
||||
<string name="feedback_edit_description">Correct the text without changing the observation.</string>
|
||||
<string name="exercise_added">Exercise added to the session.</string>
|
||||
<string name="exercise_modified">Exercise edited.</string>
|
||||
<string name="session_save">Save session</string>
|
||||
<string name="exercise_title">Exercise</string>
|
||||
<string name="exercise_none">No exercises.</string>
|
||||
<string name="choose_exercise">Choose an exercise</string>
|
||||
<string name="exercise_picker_description">Exercise selected. Tap to show suggestions.</string>
|
||||
<string name="search_exercise">Search for an exercise…</string>
|
||||
<string name="exercise_none_found_short">No exercises found</string>
|
||||
<string name="cancel_search">Cancel search</string>
|
||||
<string name="keep_named_values">Keep %1$s and the entered values.</string>
|
||||
<string name="entry_title">ENTRY — %1$s</string>
|
||||
<string name="target_load_note">Planned target load — separate from loads actually performed.</string>
|
||||
<string name="load_kg_value">Value in kg</string>
|
||||
<string name="percent_my_max">%% of my MAX</string>
|
||||
<string name="percent_my_max_field">Percentage of my MAX (1 to 100)</string>
|
||||
<string name="compatible_max">Compatible MAX: %1$s kg · %2$s</string>
|
||||
<string name="target_none">No target load will be saved.</string>
|
||||
<string name="set_reps_field">Set %1$d — Repetitions</string>
|
||||
<string name="optional_load_note">Load is optional; both decimal separators are accepted. An empty field is not zero.</string>
|
||||
<string name="sets_count">Number of sets</string>
|
||||
<string name="set_duration_seconds">Duration per set (seconds)</string>
|
||||
<string name="duration_minutes">Duration (minutes)</string>
|
||||
<string name="speed_kmh">Speed km/h</string>
|
||||
<string name="distance_km">Distance km</string>
|
||||
<string name="invalid_target_load">Invalid target load.</string>
|
||||
<string name="cancel_entry">Cancel entry</string>
|
||||
<string name="delete_numbered_set_question">Delete set %1$d?</string>
|
||||
<string name="session_type_value">Type: %1$s</string>
|
||||
<string name="resume_max_test">Resume this MAX test</string>
|
||||
<string name="resume_max_description">Continue the same session while preserving its identifier and date.</string>
|
||||
<string name="equipment_inline">Equipment: %1$s</string>
|
||||
<string name="plan_summary">Plan: %1$d × %2$s · rest %3$d s · %4$s</string>
|
||||
<string name="target_load_inline">target load %1$s kg</string>
|
||||
<string name="no_numeric_load">no numeric load</string>
|
||||
<string name="search_machine">Search for a machine</string>
|
||||
<string name="modify_equipment">Edit equipment</string>
|
||||
<string name="modify_equipment_description">Choose, replace, or remove the machine for this entry.</string>
|
||||
<string name="max_value">MAX: %1$s kg</string>
|
||||
<string name="modified_suffix"> · Edited</string>
|
||||
<string name="feedback_move_description">Correct the text without moving the observation.</string>
|
||||
<string name="add_feedback">Add feedback</string>
|
||||
<string name="session_follow_up">Post-session follow-up</string>
|
||||
<string name="feedback_marker_description">Correct the text without changing the H+ marker.</string>
|
||||
<string name="add_record">Add a record</string>
|
||||
<string name="remove_machine_question">Remove the machine from this entry?</string>
|
||||
<string name="replace_machine_question">Replace the associated machine?</string>
|
||||
<string name="machine_change_detail">The currently recorded machine association will be lost. Sets, MAX, and feedback remain unchanged.</string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="replace">Replace</string>
|
||||
<string name="mode_sets_reps">Mode: sets · repetitions</string>
|
||||
<string name="mode_sets_duration">Mode: sets · duration</string>
|
||||
<string name="assistance_kg">Assistance (kg)</string>
|
||||
<string name="sets_table">Set | Repetitions | %1$s</string>
|
||||
<string name="set_duration">Set %1$d: %2$s</string>
|
||||
<string name="mode_continuous">Mode: continuous</string>
|
||||
<string name="duration_value">Duration: %1$s</string>
|
||||
<string name="speed_value">Speed: %1$.1f km/h</string>
|
||||
<string name="distance_value">Distance: %1$.2f km</string>
|
||||
<plurals name="duration_minutes_value"><item quantity="one">%d min</item><item quantity="other">%d min</item></plurals>
|
||||
<plurals name="duration_seconds_value"><item quantity="one">%d sec</item><item quantity="other">%d sec</item></plurals>
|
||||
<string name="period_7_days">7 days</string><string name="period_30_days">30 days</string><string name="period_90_days">90 days</string><string name="period_year">1 year</string><string name="period_all">All</string>
|
||||
<string name="statistics_invalid_ignored">Some invalid historical data was ignored; valid statistics remain available.</string>
|
||||
<string name="explore">EXPLORE</string><string name="distribution">Distribution</string>
|
||||
<string name="distribution_description">Zones and exercise usage, only when they are classified.</string>
|
||||
<string name="global_summary">Global summary</string><string name="global_summary_description">Cautious indicators derived from available data.</string>
|
||||
<string name="summary_period">SUMMARY · %1$s</string><string name="sets">Sets</string><string name="explicit_max_plural">Explicit MAX</string>
|
||||
<string name="progression">PROGRESSION</string><string name="progress_insufficient">Insufficient data to establish comparable progress.</string>
|
||||
<string name="progress_counts">%1$d training improvements · %2$d MAX records</string><string name="max_open_detail">%1$d explicit MAX · open details</string>
|
||||
<string name="body_period_none">No body measurements in this period.</string><string name="measurement_single">1 measurement · trend unavailable</string>
|
||||
<string name="frequency">FREQUENCY</string><string name="current_week_summary">Current week · %1$s · %2$d MAX included</string>
|
||||
<string name="max_not_double_counted">MAX values mark sessions; they are never added a second time.</string>
|
||||
<string name="back_dashboard">← Back to dashboard</string><string name="comparable_none">No comparable data in this period.</string>
|
||||
<plurals name="session_count"><item quantity="one">%d session</item><item quantity="other">%d sessions</item></plurals>
|
||||
<string name="progress_detail_description">Each detail row preserves a canonical exercise, comparable equipment, and mode; the dashboard never adds kilograms together.</string>
|
||||
<string name="body_detail_description">No interpolation: only measurements actually recorded are shown.</string>
|
||||
<string name="frequency_title">Frequency</string><string name="frequency_unit">sessions</string>
|
||||
<string name="frequency_detail_description">Number of sessions with actual work per week; MAX is a marker, not a second session.</string>
|
||||
<string name="distribution_detail_description">Detailed distribution appears when classified occurrences are available in this view.</string>
|
||||
<string name="summary_detail">%1$d sessions · %2$d sets · %3$d exercises · %4$d explicit MAX</string>
|
||||
<string name="generator_zone">Body zone</string><string name="generator_goal">Goal</string>
|
||||
<string name="generator_custom_duration">Custom duration (%1$d to %2$d min)</string>
|
||||
<string name="generate">Generate</string><string name="generate_description">Analyze history read-only and prepare the proposal.</string>
|
||||
<string name="duration_range_error">Duration must be between %1$d and %2$d minutes.</string>
|
||||
<string name="target_duration_estimate">Target duration: %1$d min · estimate: %2$d min</string>
|
||||
<string name="proposal_short">The proposal is significantly shorter than the target; no exercise is added merely to fill time.</string>
|
||||
<string name="warmup_not_generated">Warm-up and cool-down are not generated in V1.</string>
|
||||
<string name="recent_exposure_warning">Informational warning: history shows recent exposure. This does not measure physiological recovery.</string>
|
||||
<string name="continue_warning">Continue despite warning</string><string name="continue_warning_description">Keep the proposal and allow it to be accepted.</string>
|
||||
<string name="equipment_generator">Equipment: %1$s</string><string name="target_load_none_proposed">Target load: no numeric load proposed.</string><string name="target_load_proposed">Target load: %1$s kg</string>
|
||||
<string name="recent_similar">Similar recent history detected; informational only.</string>
|
||||
<string name="sets_label">Sets</string><string name="user_load_note">Load: user choice, never a recommendation.</string>
|
||||
<string name="automatic">Automatic</string><string name="none">None</string><string name="apply">Apply</string>
|
||||
<string name="modify_target">Edit target</string><string name="modify_target_description">Edit sets, repetitions, rest, or load.</string>
|
||||
<string name="regenerate">Regenerate</string><string name="accept_actual_values">Accept and enter actual values</string>
|
||||
<string name="cancel_proposal">Cancel proposal</string><string name="cancel_proposal_description">Return without writing a draft.</string>
|
||||
<string name="processing">Processing</string><string name="analysis_running">Analysis in progress…</string><string name="message_title">Message</string>
|
||||
<string name="resume">Resume</string><string name="durable_draft_count">%1$s · the durable draft is preserved.</string>
|
||||
<string name="session_proposal">Session proposal</string><string name="planned_for">Planned for %1$s</string>
|
||||
<plurals name="repetition_count"><item quantity="one">%d repetition</item><item quantity="other">%d repetitions</item></plurals>
|
||||
<string name="start">Start</string><string name="start_proposal_description">Copy this proposal into the current session.</string>
|
||||
<string name="draft_already_active">A session is already in progress. The imported draft was preserved.</string>
|
||||
<string name="delete_draft_detail">Draft “%1$s” will be deleted.\nThis action cannot be undone.</string>
|
||||
<string name="training">Training</string><string name="resume_current_session">Resume current session</string>
|
||||
<string name="new_manual_description">Explicitly create a durable draft.</string><string name="latest_session">Latest session</string><string name="quick_access">Quick access</string>
|
||||
<string name="zones_to_work">Zones to work</string><string name="recent_exposure">Recent exposure</string><string name="no_training_history">No training history yet.</string>
|
||||
<string name="invalid_history_timestamp">Insufficient data: a historical timestamp could not be interpreted.</string>
|
||||
<string name="selection_value">Selection: %1$s</string><string name="priorities">Priorities</string><string name="resolved_zone_none">No zone with a resolved exercise is available.</string>
|
||||
<string name="exposure_explanation">These colors describe recently recorded work. They do not measure physiological recovery.</string><string name="understood">Got it</string>
|
||||
<string name="view_exercises">View exercises</string><string name="view_statistics">View statistics</string>
|
||||
<string name="session_type">Session type</string><string name="training_selected">[✓] Training</string><string name="training_unselected">[ ] Training</string>
|
||||
<string name="edit_exercise_description">Edit this exercise without removing it from the session.</string><string name="editor_opened">Editor opened for %1$s — prefilled fields appear below.</string>
|
||||
<string name="editing_scroll_hint">Editing — scroll to ENTRY — %1$s.</string><string name="feedback_value">Feedback · %1$s%2$s</string>
|
||||
<string name="feedback_now_description">Dictate or enter feedback now, before the session ends.</string><string name="feedback_saved_current">Feedback saved in the current session.</string>
|
||||
<string name="create_new_exercise">Create a new exercise</string><string name="finalize_failed">Finalization failed, draft preserved: %1$s</string>
|
||||
<string name="session_exercise_total">%1$s in the session.</string><string name="discard_session_question">Discard the current session?</string>
|
||||
<string name="discard_session_detail">This will delete the draft, its %1$s, and all their recorded data.</string>
|
||||
<string name="remove_exercise_question">Remove “%1$s” from the session?</string><string name="remove_occurrence_detail">This occurrence will be removed.</string>
|
||||
<string name="continuous_fact">the continuous activity</string><string name="max_fact">the MAX value</string>
|
||||
<string name="create_context">Create context</string><string name="context_compatibility">Temporary compatibility for a custom or unresolved exercise.</string>
|
||||
<string name="context_exists">This context already exists.</string><string name="context_create_error">Context not created: %1$s</string><string name="equipment_selected">Equipment selected.</string>
|
||||
<string name="positive_decimal_note">Strictly positive value; both decimal separators are accepted.</string><string name="calculated_target">Calculated target: %1$s kg</string>
|
||||
<string name="set_independent_values">Each set retains its own repetitions and load.</string><string name="add_set_plain">Add a set</string>
|
||||
<string name="add_set_row_description">Add an empty row without changing the other sets.</string><string name="add_to_session">Add to session</string>
|
||||
<string name="delete_set_entered_detail">Its entered repetitions, duration, and load will be deleted.</string>
|
||||
<string name="validation_set_required">Add at least one set.</string><string name="validation_set_reps">Set %1$d: enter repetitions between 0 and %2$d.</string>
|
||||
<string name="validation_set_load">Set %1$d: enter a non-negative load or leave the field empty.</string><string name="validation_sets_invalid">Invalid set values.</string>
|
||||
<string name="exercise_removed">Exercise removed from the session.</string>
|
||||
<string name="target_positive">Enter a strictly positive target load.</string><string name="max_unavailable">Compatible MAX unavailable.</string>
|
||||
<string name="max_assistance_unavailable">%%MAX is unavailable for assistance; choose external resistance.</string>
|
||||
<string name="compatible_load_equipment_required">Choose equipment compatible with a target load.</string><string name="positive_dose_required">Define a positive target dose before the target load.</string>
|
||||
<string name="percent_range">Percentage must be between 1 and 100.</string><string name="max_read_failed">Unable to read MAX.</string>
|
||||
<string name="max_choose_external">Compatible MAX unavailable: choose external-resistance equipment.</string><string name="max_known_external_required">%%MAX is unavailable without known external-resistance equipment.</string><string name="max_exact_unavailable">Compatible MAX unavailable for this exact exercise and equipment.</string>
|
||||
<string name="organization_sets">Sets</string><string name="supplemental_data">Supplemental data</string><string name="not_specified_feminine">Not specified</string><string name="not_specified_plural">Not specified</string>
|
||||
<string name="profile_change_note">Past sessions and the occurrence already in the draft remain unchanged. The new profile applies to future uses.</string>
|
||||
<string name="create_return_session">Create and return to session</string><string name="identity_update_description">Preserve identity and update the catalog.</string>
|
||||
<string name="exercise_name_exists">An exercise with this name already exists.</string><string name="other_exercise_name_exists">Another exercise already has this name.</string><string name="profile_used_error">The profile cannot change after use.</string>
|
||||
<string name="search_prefix">Prefix search</string><string name="profile_change_dialog_detail">Previous sessions and the active draft remain unchanged. New uses follow the new profile.</string>
|
||||
<string name="profile_transition_confirmed">Transition confirmed. Save to apply the profile to future uses.</string>
|
||||
<string name="science_unresolved">Scientific classification unresolved.</string><string name="conditional_interpretation">Conditional interpretation · confirm: %1$s</string>
|
||||
<string name="movements_value">Movements: %1$s</string><string name="compatible_equipment_value">Compatible equipment: %1$s</string><string name="not_established">Not established</string>
|
||||
<string name="create_exercise_description">Add a profile to the local catalog.</string><string name="compatible_equipment">Compatible equipment</string><string name="compatibility_none">Compatibility not established.</string>
|
||||
<string name="latest_result">Latest result: %1$s kg · %2$s</string><string name="science_classification_confidence">Scientific classification · %1$s confidence</string>
|
||||
<string name="conditional_classification">Conditional classification · explicit uncertainty</string><string name="confidence_high">high</string><string name="confidence_moderate">moderate</string><string name="confidence_low">low</string>
|
||||
<string name="zone_not_specified">Zone: Not specified</string>
|
||||
<string name="confidence_uncertain">uncertain</string><string name="primary_zone_value">Primary zone: %1$s</string><string name="secondary_zones_value"> · Secondary zones: %1$s</string><string name="group_value"> · Group: %1$s</string>
|
||||
<string name="goal_general">General</string><string name="goal_strength">Strength</string><string name="goal_hypertrophy">Hypertrophy</string><string name="goal_endurance">Local endurance</string>
|
||||
<string name="generator_weight_invalid">Target load must be a finite positive number.</string><string name="proposal">Proposal</string>
|
||||
<string name="coverage_incomplete">Coverage is incomplete because resolved contexts are unavailable%1$s</string>
|
||||
<string name="exposure_summary">Observed exposure: %1$d primary and %2$d secondary sets over 24 h; %3$d primary and %4$d secondary over 72 h.</string><string name="latest_exposure">Latest observed exposure: %1$s</string>
|
||||
<string name="observed_load_note">Load derived from a dose actually observed on %1$s; its applicability today remains uncertain.</string>
|
||||
<string name="max_percentage_field">MAX percentage (1 to 100)</string><string name="manual_target_load">Manual target load (empty = automatic)</string>
|
||||
<string name="reestimate_description">Re-estimate duration and reclassify the observed load.</string><string name="move_up">Move up</string><string name="move_up_description">Move this exercise before the previous one.</string><string name="move_down">Move down</string><string name="move_down_description">Move this exercise after the next one.</string>
|
||||
<string name="regenerate_description">Run again with the same inputs and reference instant.</string><string name="accept_actual_description">Create the normal draft without prefilling performed sets.</string><string name="acknowledge_exposure_first">Confirm the recent-exposure warning first.</string>
|
||||
<string name="reason_observed_dose">observed repeated dose</string><string name="reason_max_no_prescription">observed maximum without numeric prescription</string><string name="reason_user_max">user-selected MAX percentage</string><string name="reason_max_unavailable">compatible MAX unavailable</string><string name="reason_assistance_omitted">assistance load omitted</string><string name="reason_load_absent">numeric load absent</string><string name="reason_manual_load">manually entered load</string><string name="reason_preferred">preferred exercise</string><string name="reason_primary_zone">requested primary zone</string><string name="reason_secondary_zone">requested secondary zone</string><string name="reason_distinct_movement">distinct movement</string><string name="reason_recent_exercise">same exercise observed recently</string><string name="reason_recent_pattern">similar movement observed recently</string>
|
||||
<string name="shortage_upper">missing upper region</string><string name="shortage_lower">missing lower region</string><string name="shortage_core">missing core</string><string name="shortage_push">missing upper-body push</string><string name="shortage_pull">missing upper-body pull</string><string name="shortage_lower_extension">missing lower-body extension</string><string name="shortage_lower_flexion">missing lower-body flexion</string><string name="shortage_exercises">fewer distinct exercises available</string>
|
||||
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="search_equipment">Search by name, label, or alias</string>
|
||||
<string name="unknown_reference">Unknown reference · %1$s</string><string name="definition">Definition</string><string name="personal">Personal</string><string name="provided">Provided</string><string name="label_value">Label: %1$s</string>
|
||||
<string name="confidence_value">%1$s confidence</string><string name="verified_sources">Verified sources: %1$d</string><string name="personal_equipment">Personal equipment</string><string name="create">Create</string>
|
||||
<string name="create_machine_description">Create a personal machine with the current semantics.</string><string name="equipment_name_invalid">Enter a name of 1 to 120 characters.</string><string name="equipment_name_exists">Equipment already has this name.</string><string name="creation_failed">Creation failed: %1$s.</string>
|
||||
<string name="load_none">no load</string><string name="load_external">external load</string><string name="load_assistance">assistance</string><string name="load_bodyweight">bodyweight</string><string name="load_cardio">cardio</string>
|
||||
<string name="existing_draft_warning">A session is already in progress. Resume it or return to the preserved proposal.</string><string name="no_numeric_load_proposed">no numeric load proposed</string>
|
||||
<string name="incompatible_change_detail">This incompatible change will replace:\n- %1$s\nFeedback remains attached to the same entry_id.</string><string name="set_load_field">Set %1$d — %2$s</string>
|
||||
<string name="selected_suffix">, selected</string><string name="not_selected_suffix">, not selected</string><string name="select_zone">Select %1$s</string>
|
||||
<string name="progress_a11y_empty">Progress over six weeks: no comparable event, six empty points</string><string name="progress_a11y_values">Progress over six weeks: %1$s events</string>
|
||||
<string name="chart_insufficient">Not enough data for a chart yet.</string><string name="chart_series_detail">%1$d comparable series · open details</string>
|
||||
<string name="general">General</string><string name="shoulders">Shoulders</string><string name="empty_fields_ignored">Empty fields are ignored.</string><string name="latest_measurements">Latest measurements</string>
|
||||
<string name="state_prioritize">Prioritize</string><string name="state_little_work">Little recent work</string><string name="state_recent_work">Recent work</string><string name="state_high_exposure">High recent exposure</string><string name="state_insufficient">Insufficient data</string><string name="state_unsupported">No resolved exercise available</string><string name="relative_priority">Relative priority</string>
|
||||
<string name="no_primary_exposure">No primary exposure recorded</string><string name="latest_primary_work">Latest primary work: %1$s</string><string name="recent_primary_work">Primary work over 7 days: %1$d</string><string name="recent_secondary_work">Secondary work over 7 days: %1$d</string>
|
||||
<string name="age_never">never recorded</string><string name="age_today">today</string><string name="age_yesterday">1 day ago</string><plurals name="age_days"><item quantity="one">%d day ago</item><item quantity="other">%d days ago</item></plurals>
|
||||
<string name="latest_primary_work_label">Latest primary work: %1$s</string>
|
||||
<string name="frequency_a11y">Frequency over six weeks: %1$s</string><string name="frequency_week_a11y">%1$s %2$s including %3$d MAX</string><string name="current_feminine">current</string>
|
||||
<string name="weight">Weight</string><string name="target_summary">Target: %1$d × %2$s · rest %3$d s</string><string name="primary_zone_value_short">Primary zone: %1$s</string><string name="movement_value">Movement: %1$s</string><string name="reasons_value">Reasons: %1$s</string><string name="rest_seconds">Rest (seconds)</string>
|
||||
<string name="name">Name</string><string name="primary_zone">Primary zone</string><string name="secondary_zones">Secondary zones</string><string name="add_profile_description">Add this profile to the local catalog.</string><string name="invalid_profile_name">Invalid profile or name.</string><string name="invalid_name_profile">Invalid name or profile.</string><string name="zone_filter">Filter by zone</string><string name="all_zones">All zones</string>
|
||||
<string name="confidence_inline">Confidence: %1$s</string><string name="scientific_zones">Scientific zones: %1$s%2$s</string><string name="secondary_suffix"> · secondary: %1$s</string><string name="limitation_value">Limitation: %1$s</string><string name="source_value">Source: %1$s · %2$s%3$s</string><string name="exercise_catalog">Exercise catalog</string><string name="filter">Filter</string><string name="profile">Profile</string>
|
||||
<string name="latest_max_value">Latest MAX · %1$s · %2$s kg · %3$s</string><string name="type_value">Type: %1$s</string><string name="aliases_value">Aliases: %1$s</string><string name="max_weight">MAX weight (kg)</string><string name="add_entry_description">Add this entry to the draft.</string>
|
||||
<string name="neck">Neck</string><string name="chest">Chest</string><string name="waist">Waist</string><string name="hips">Hips</string><string name="limbs">Limbs</string>
|
||||
<string name="left_arm">Left arm</string><string name="right_arm">Right arm</string><string name="left_forearm">Left forearm</string><string name="right_forearm">Right forearm</string><string name="left_thigh">Left thigh</string><string name="right_thigh">Right thigh</string><string name="left_calf">Left calf</string><string name="right_calf">Right calf</string><string name="recording">Recording</string>
|
||||
<string name="body_map">Body map, %1$s</string><string name="body_front">Front</string><string name="body_back">Back</string>
|
||||
<string name="plan_draft_summary">%1$s · %2$d × %3$s%4$s · rest %5$d s</string><string name="possible_exercises_plain">Possible exercises</string>
|
||||
<string name="actions">Actions</string><string name="change_profile_question">Change %1$s’s profile?</string><string name="historical_context_optional">Historical context (optional)</string><string name="new_historical_context">New historical context</string>
|
||||
<string name="organization">Organization</string><string name="continuous">Continuous</string><string name="primary_measurement">Primary measurement</string><string name="cancel_catalog_description">Return to the catalog without changes.</string><string name="contract">Contract</string><string name="knowledge">Knowledge</string>
|
||||
<string name="remove_proposal_exercise">Remove this exercise from the proposal</string><string name="speech_interrupted">Recognition interrupted (code %1$d).</string>
|
||||
<string name="draft_missing">This draft is no longer available.</string><string name="invalid_value">A value is invalid.</string><string name="positive_measure_required">Add at least one positive measurement.</string><plurals name="measurement_count"><item quantity="one">%d measurement</item><item quantity="other">%d measurements</item></plurals><plurals name="available_exercise_count"><item quantity="one">%d exercise available</item><item quantity="other">%d exercises available</item></plurals>
|
||||
<string name="max_test_selected">[✓] MAX test</string><string name="max_test_unselected">[ ] MAX test</string><string name="feedback_add_count">Feedback (%1$d) · + Add feedback</string><string name="delete_facts_detail">This will delete:\n- %1$s</string><string name="plan_rest_fact">targets and rest</string>
|
||||
<string name="context_name_invalid">Enter a valid context name.</string><string name="max_weight_invalid">Enter a strictly positive MAX weight (e.g. 100 or 86.5).</string><string name="invalid_values">Invalid values.</string><string name="return_catalog">Return to catalog.</string>
|
||||
<string name="new_exercise">New exercise</string><string name="primary_zone_required">A primary zone is required for a new muscular exercise.</string>
|
||||
<string name="feedback_voice">🎙 Feedback</string><string name="draft_max_summary">%1$s · MAX: %2$s kg</string><string name="reps_short">reps</string><string name="profile_continuous">CONTINUOUS</string><string name="profile_sets">SETS</string><string name="profile_reps">REPS</string><string name="profile_duration">DURATION</string>
|
||||
<plurals name="point_count"><item quantity="one">%d point</item><item quantity="other">%d points</item></plurals>
|
||||
<string name="database_save_failed">Unable to save to the database.</string><string name="knowledge_expand">+ Knowledge</string><string name="knowledge_collapse">− Knowledge</string>
|
||||
<plurals name="seconds_count"><item quantity="one">%d second</item><item quantity="other">%d seconds</item></plurals><string name="discard">Discard</string>
|
||||
<plurals name="feedback_count"><item quantity="one">%d feedback entry</item><item quantity="other">%d feedback entries</item></plurals><string name="during_session">During the session</string>
|
||||
<string name="profile_speed_suffix"> · speed</string><string name="profile_distance_suffix"> · distance</string>
|
||||
<string name="zone_full_body">Full body</string><string name="zone_upper_body">Upper body</string><string name="zone_chest">Chest</string><string name="zone_back">Back</string><string name="zone_shoulders">Shoulders</string><string name="zone_arms">Arms</string><string name="zone_core">Abdominals / core</string><string name="zone_lower_body">Lower body</string><string name="zone_glutes">Glutes</string><string name="zone_thighs">Thighs</string><string name="zone_calves">Calves</string>
|
||||
<string name="performed_loads">performed loads</string><string name="explicit_max_label">explicit MAX</string>
|
||||
<string name="repository_item_missing">The requested item is no longer available.</string><string name="repository_invalid_data">The requested data is invalid.</string><string name="repository_database_error">The database operation failed.</string><string name="repository_file_access">File access is required.</string><string name="repository_operation_error">The operation failed.</string>
|
||||
<string name="draft_selection_missing">The exercise being entered no longer exists; only the selection was cleared. Partial input and added exercises were preserved.</string>
|
||||
<string name="a11y_reorder_exercise">Reorder exercise</string>
|
||||
<string name="a11y_move_exercise_up">Move exercise up</string>
|
||||
<string name="a11y_move_exercise_down">Move exercise down</string>
|
||||
<string name="exercise_order_saved">Session order saved.</string>
|
||||
<string name="exercise_reorder_failed">Reorder was not saved: %1$s</string>
|
||||
<string name="invalid_decimal_value">Enter a valid decimal value using “,” or “.”.</string>
|
||||
</resources>
|
||||
|
|
@ -1,4 +1,399 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Trainlog</string>
|
||||
<string name="nav_home">Accueil</string>
|
||||
<string name="nav_sessions">Séances</string>
|
||||
<string name="nav_exercises">Exercices</string>
|
||||
<string name="nav_equipment">Équipements</string>
|
||||
<string name="nav_statistics">Statistiques</string>
|
||||
<string name="nav_sync">Synchronisation</string>
|
||||
<string name="nav_settings">Paramètres</string>
|
||||
<string name="route_session_editor">Séance en cours</string>
|
||||
<string name="route_session_generator">Programmer une séance</string>
|
||||
<string name="route_ai_drafts">Brouillons</string>
|
||||
<string name="route_completed_sessions">Séances effectuées</string>
|
||||
<string name="route_session_detail">Détail de la séance</string>
|
||||
<string name="route_session_correction">Modifier la séance</string>
|
||||
<string name="route_exercise_detail">Fiche exercice</string>
|
||||
<string name="route_exercise_edit">Modifier l’exercice</string>
|
||||
<string name="route_exercise_create">Créer un exercice</string>
|
||||
<string name="route_equipment_detail">Fiche équipement</string>
|
||||
<string name="route_equipment_create">Créer un équipement</string>
|
||||
<string name="route_body_measurements">Mensurations</string>
|
||||
<string name="route_latest_maxima">Derniers MAX</string>
|
||||
<string name="a11y_open_navigation">Ouvrir la navigation</string>
|
||||
<string name="a11y_back">Retour</string>
|
||||
<string name="a11y_navigation">Navigation</string>
|
||||
<string name="settings_language_section">LANGUE</string>
|
||||
<string name="settings_language_label">Langue</string>
|
||||
<string name="language_french">Français</string>
|
||||
<string name="language_english">English</string>
|
||||
<string name="settings_exchange_folder">DOSSIER D’ÉCHANGE</string>
|
||||
<string name="settings_screen">Paramètres</string>
|
||||
<string name="dialog_cancel">Annuler</string>
|
||||
<string name="dialog_keep_leave">Conserver et quitter</string>
|
||||
<string name="dialog_discard_proposal">Abandonner la proposition</string>
|
||||
<string name="dialog_discard_changes">Abandonner les modifications</string>
|
||||
<string name="dialog_unaccepted_proposal">Proposition non acceptée</string>
|
||||
<string name="dialog_unsaved_changes">Modifications non enregistrées</string>
|
||||
<string name="dialog_proposal_retained">La proposition peut rester en mémoire pendant que vous changez de rubrique.</string>
|
||||
<string name="dialog_fields_retained">Les champs restent en mémoire tant que vous ne les abandonnez pas explicitement.</string>
|
||||
<string name="a11y_delete_exercise">Supprimer cet exercice</string>
|
||||
<string name="a11y_delete_current_session">Supprimer la séance en cours</string>
|
||||
<string name="a11y_delete_set">Supprimer cette série</string>
|
||||
<string name="a11y_remove_machine">Retirer la machine de cet exercice</string>
|
||||
<string name="a11y_delete_draft">Supprimer ce brouillon</string>
|
||||
<string name="home_session">Séance</string>
|
||||
<string name="home_sync_state">État et actions</string>
|
||||
<string name="home_body_tracking">Suivi corporel</string>
|
||||
<string name="settings_access_granted">Accès fichiers autorisé.</string>
|
||||
<string name="settings_access_required">Accès fichiers toujours requis.</string>
|
||||
<string name="settings_folder_granted">Dossier d’échange : Documents/Trainlog\nAccès fichiers : autorisé</string>
|
||||
<string name="settings_folder_required">Accès fichiers requis</string>
|
||||
<string name="settings_open_access">Ouvrir les réglages d’accès</string>
|
||||
<string name="settings_authorize">Autoriser</string>
|
||||
<string name="settings_authorize_description">Autoriser l’accès au dossier Trainlog dans Android.</string>
|
||||
<string name="settings_reload_catalog">Relire le catalogue PC</string>
|
||||
<string name="settings_reload_description">Appliquer explicitement le catalogue présent dans le dossier autorisé.</string>
|
||||
<string name="settings_no_catalog">Aucun catalogue PC reçu.</string>
|
||||
<string name="settings_folder_revoked">Le dossier n’est plus autorisé.</string>
|
||||
<string name="sync_folder_unauthorized">Dossier Trainlog non autorisé.</string>
|
||||
<string name="sync_complete">Synchronisation terminée · Catalogue Android : %1$s.</string>
|
||||
<string name="sync_receipt_failure">La synchronisation PC a échoué. Consultez le journal du PC pour le diagnostic.</string>
|
||||
<string name="sync_pc_catalog_missing">Synchronisation PC terminée, mais catalogue reçu introuvable.</string>
|
||||
<string name="sync_pc_folder_unauthorized">Synchronisation PC terminée, dossier Trainlog non autorisé.</string>
|
||||
<string name="sync_pc_import_error">Synchronisation PC terminée, import Android : %1$s</string>
|
||||
<string name="sync_timeout">Le PC n’a pas répondu dans les 60 secondes.</string>
|
||||
<string name="sync_access_ready">Accès fichiers autorisé · Documents/Trainlog prêt.</string>
|
||||
<string name="sync_access_enable">Accès fichiers requis. Activez l’autorisation dans Android.</string>
|
||||
<string name="sync_access_catalog">Accès autorisé · catalogue PC : %1$s.</string>
|
||||
<string name="sync_access_no_catalog">Accès autorisé · aucun catalogue PC reçu.</string>
|
||||
<string name="sync_action">Synchroniser</string>
|
||||
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
|
||||
<string name="sync_running">Synchronisation en cours…</string>
|
||||
<string name="sync_now">Synchroniser maintenant</string>
|
||||
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
|
||||
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
|
||||
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
|
||||
<string name="sync_android_unsupported">Android non supporté.</string>
|
||||
<string name="sync_folder_section">DOSSIER D’ÉCHANGE</string>
|
||||
<string name="sync_authorize_folder">Autoriser l’accès au dossier Trainlog</string>
|
||||
<string name="sync_manage_files">Autoriser l’accès pour gérer tous les fichiers dans Android.</string>
|
||||
<string name="sync_manual_recovery">Action de récupération manuelle si nécessaire.</string>
|
||||
<string name="sync_catalog_result">Catalogue PC : %1$s.</string>
|
||||
<string name="sync_folder_access_required">Accès fichiers requis pour Documents/Trainlog.</string>
|
||||
<string name="sync_status">État</string>
|
||||
<string name="sync_prepare_error">Préparation Android → PC : %1$s</string>
|
||||
<plurals name="catalog_new"><item quantity="one">%d nouveau</item><item quantity="other">%d nouveaux</item></plurals>
|
||||
<plurals name="catalog_reconciled"><item quantity="one">%d réconcilié</item><item quantity="other">%d réconciliés</item></plurals>
|
||||
<plurals name="catalog_present"><item quantity="one">%d présent</item><item quantity="other">%d présents</item></plurals>
|
||||
<string name="history_none">Aucune séance enregistrée.</string>
|
||||
<string name="max_none">Aucun MAX explicite enregistré.</string>
|
||||
<string name="equipment_value">Équipement : %1$s</string>
|
||||
<string name="value_none">aucun</string>
|
||||
<string name="session_training">ENTRAÎNEMENT</string>
|
||||
<string name="session_max_test">TEST MAX</string>
|
||||
<plurals name="exercise_count"><item quantity="one">%d exercice</item><item quantity="other">%d exercices</item></plurals>
|
||||
<string name="date_time_pattern">dd/MM/yyyy HH:mm</string>
|
||||
<string name="date_pattern">dd/MM/yyyy</string>
|
||||
<string name="correction_profile_note">Les anciennes interprétations de profil sont conservées. Les identifiants de la séance et des occurrences retenues ne changent pas.</string>
|
||||
<string name="field_target_sets">Nombre de séries cible</string>
|
||||
<string name="field_target_reps">Répétitions cibles</string>
|
||||
<string name="field_target_duration">Durée cible par série (s)</string>
|
||||
<string name="field_rest_seconds">Repos (s)</string>
|
||||
<string name="field_target_weight">Charge cible (kg)</string>
|
||||
<string name="field_explicit_max">MAX explicite (kg)</string>
|
||||
<string name="field_continuous_duration">Durée continue (s)</string>
|
||||
<string name="field_speed">Vitesse (km/h)</string>
|
||||
<string name="field_distance">Distance (km)</string>
|
||||
<string name="field_reps">Répétitions</string>
|
||||
<string name="field_duration_seconds">Durée (s)</string>
|
||||
<string name="field_weight">Charge (kg)</string>
|
||||
<string name="set_number">Série %1$d</string>
|
||||
<string name="add_set">+ Ajouter une série</string>
|
||||
<string name="add_set_description">Ajouter explicitement un fait réalisé.</string>
|
||||
<string name="delete_occurrence">Supprimer cette occurrence</string>
|
||||
<string name="save">Enregistrer</string>
|
||||
<string name="correction_save_description">Appliquer toute la correction dans une transaction.</string>
|
||||
<string name="cancel_no_database_change">Quitter sans modifier la base.</string>
|
||||
<string name="delete_set_question">Supprimer cette série ?</string>
|
||||
<string name="delete_set_detail">Ce fait réalisé disparaîtra de la séance corrigée.</string>
|
||||
<string name="delete">Supprimer</string>
|
||||
<string name="delete_occurrence_question">Supprimer cette occurrence ?</string>
|
||||
<string name="a11y_delete_occurrence">Supprimer cette occurrence</string>
|
||||
<plurals name="performed_set_count"><item quantity="one">%d série réalisée</item><item quantity="other">%d séries réalisées</item></plurals>
|
||||
<string name="occurrence_delete_impact">Impact : %1$s%2$s%3$s. Ses ressentis et révisions seront supprimés avec leur parent.</string>
|
||||
<string name="continuous_activity_suffix">, activité continue</string>
|
||||
<string name="max_suffix">, MAX</string>
|
||||
<string name="session_not_found">Séance introuvable.</string>
|
||||
<string name="feedback_dictation">Dictée du ressenti</string>
|
||||
<string name="feedback_recording">● Enregistrement en cours</string>
|
||||
<string name="feedback_stop">Arrêter la dictée</string>
|
||||
<string name="feedback_text">Texte</string>
|
||||
<string name="feedback_resume">Reprendre la dictée</string>
|
||||
<string name="feedback_start">Démarrer la dictée</string>
|
||||
<string name="save_changes">Enregistrer les modifications</string>
|
||||
<string name="feedback_revision_description">Créer une nouvelle révision immuable.</string>
|
||||
<string name="feedback_create_description">Créer un enregistrement historique immuable.</string>
|
||||
<string name="save_nothing">Ne rien enregistrer.</string>
|
||||
<string name="feedback_microphone_denied">Microphone refusé : la saisie manuelle reste disponible.</string>
|
||||
<string name="feedback_speech_unavailable">Reconnaissance vocale indisponible : utilisez la saisie manuelle.</string>
|
||||
<string name="feedback_empty">Un ressenti vide ne peut pas être enregistré.</string>
|
||||
<string name="current_session_none">Aucune séance en cours.</string>
|
||||
<string name="prepare">Préparer</string>
|
||||
<string name="new_manual_session">Nouvelle séance manuelle</string>
|
||||
<string name="create_or_resume">Créer ou reprendre</string>
|
||||
<plurals name="prepared_session_count"><item quantity="one">%d séance préparée</item><item quantity="other">%d séances préparées</item></plurals>
|
||||
<string name="draft_none">Aucun brouillon importé.</string>
|
||||
<string name="delete_draft_question">Supprimer ce brouillon ?</string>
|
||||
<string name="max_capacities">CAPACITÉS / MAX</string>
|
||||
<string name="equipment_catalog">Catalogue d’équipements</string>
|
||||
<string name="catalog">Catalogue</string>
|
||||
<string name="equipment_none">Aucun équipement trouvé.</string>
|
||||
<string name="load_value">Charge : %1$s</string>
|
||||
<string name="possible_exercises">Exercices possibles</string>
|
||||
<string name="association_none">Aucune association vérifiée. L’anatomie n’est pas déduite du nom de la machine.</string>
|
||||
<string name="settings_folder_imported">Dossier autorisé · %1$s.</string>
|
||||
<string name="settings_catalog_reloaded">Catalogue relu · %1$s.</string>
|
||||
<string name="body_save">Enregistrer les mensurations</string>
|
||||
<string name="body_saved">Mensurations enregistrées.</string>
|
||||
<string name="local_database_error">Erreur de base locale.</string>
|
||||
<string name="body_none">Aucun relevé enregistré.</string>
|
||||
<string name="exercise_save">Enregistrer l’exercice</string>
|
||||
<string name="existing_exercises">Exercices existants</string>
|
||||
<string name="exercise_none_saved">Aucun exercice enregistré.</string>
|
||||
<string name="modify_named">Modifier · %1$s</string>
|
||||
<string name="view_latest_max">Voir les derniers MAX</string>
|
||||
<string name="name_no_rules">Aucune règle ne dépend du nom.</string>
|
||||
<string name="tracking_change_question">Modifier le mode de suivi ?</string>
|
||||
<string name="modify">Modifier</string>
|
||||
<string name="exercise_not_found">Exercice introuvable.</string>
|
||||
<string name="profile_loading">Chargement du profil…</string>
|
||||
<string name="primary_muscles">Muscles principaux : %1$s</string>
|
||||
<string name="secondary_muscles">Muscles secondaires : %1$s</string>
|
||||
<string name="stabilizers">Stabilisateurs : %1$s</string>
|
||||
<string name="unclassified">Non classés</string>
|
||||
<string name="none_established">Aucun établi</string>
|
||||
<string name="exercise_none_found">Aucun exercice trouvé.</string>
|
||||
<string name="exercise_modify_description">Modifier le nom, le profil et les zones selon les règles existantes.</string>
|
||||
<string name="knowledge_none">Aucune connaissance scientifique liée à cet identifiant.</string>
|
||||
<string name="view_latest_max_description">Ouvrir la liste agrégée existante dans Statistiques.</string>
|
||||
<string name="value_none_feminine">Aucune</string>
|
||||
<string name="field_duration">Durée</string>
|
||||
<string name="field_speed_short">Vitesse</string>
|
||||
<string name="field_distance_short">Distance</string>
|
||||
<string name="error_title">Erreur</string>
|
||||
<string name="draft_unsaved">Brouillon non sauvegardé : %1$s</string>
|
||||
<string name="draft_unsaved_title">Brouillon non sauvegardé</string>
|
||||
<string name="last_change_unsaved">Dernière modification non sauvegardée.</string>
|
||||
<string name="session_saved_locally">Séance sauvegardée localement.</string>
|
||||
<string name="normal_session_description">Séance normale de travail.</string>
|
||||
<string name="max_session_description">Séance explicitement dédiée à une mesure de MAX.</string>
|
||||
<string name="exercise_none_added">Aucun exercice ajouté.</string>
|
||||
<string name="performed_sets_none">Aucune série réalisée saisie : ajoutez les valeurs réellement effectuées.</string>
|
||||
<string name="modify_name">Modifier %1$s</string>
|
||||
<string name="feedback_edit_description">Corriger le texte sans changer l’observation.</string>
|
||||
<string name="exercise_added">Exercice ajouté à la séance.</string>
|
||||
<string name="exercise_modified">Exercice modifié.</string>
|
||||
<string name="session_save">Enregistrer la séance</string>
|
||||
<string name="exercise_title">Exercice</string>
|
||||
<string name="exercise_none">Aucun exercice.</string>
|
||||
<string name="choose_exercise">Choisir un exercice</string>
|
||||
<string name="exercise_picker_description">Exercice sélectionné. Appuyer pour afficher les suggestions.</string>
|
||||
<string name="search_exercise">Rechercher un exercice…</string>
|
||||
<string name="exercise_none_found_short">Aucun exercice trouvé</string>
|
||||
<string name="cancel_search">Annuler la recherche</string>
|
||||
<string name="keep_named_values">Conserver %1$s et les valeurs saisies.</string>
|
||||
<string name="entry_title">SAISIE — %1$s</string>
|
||||
<string name="target_load_note">Charge cible prévue — séparée des charges réellement effectuées.</string>
|
||||
<string name="load_kg_value">Valeur en kg</string>
|
||||
<string name="percent_my_max">%% de mon MAX</string>
|
||||
<string name="percent_my_max_field">Pourcentage de mon MAX (1 à 100)</string>
|
||||
<string name="compatible_max">MAX compatible : %1$s kg · %2$s</string>
|
||||
<string name="target_none">Aucune charge cible ne sera enregistrée.</string>
|
||||
<string name="set_reps_field">Série %1$d — Répétitions</string>
|
||||
<string name="optional_load_note">Charge facultative ; les deux séparateurs décimaux sont acceptés. Une case vide n’est pas zéro.</string>
|
||||
<string name="sets_count">Nombre de séries</string>
|
||||
<string name="set_duration_seconds">Durée par série (secondes)</string>
|
||||
<string name="duration_minutes">Durée (minutes)</string>
|
||||
<string name="speed_kmh">Vitesse km/h</string>
|
||||
<string name="distance_km">Distance km</string>
|
||||
<string name="invalid_target_load">Charge cible invalide.</string>
|
||||
<string name="cancel_entry">Annuler la saisie</string>
|
||||
<string name="delete_numbered_set_question">Supprimer la série %1$d ?</string>
|
||||
<string name="session_type_value">Type : %1$s</string>
|
||||
<string name="resume_max_test">Reprendre ce test MAX</string>
|
||||
<string name="resume_max_description">Continuer la même séance en conservant son identifiant et sa date.</string>
|
||||
<string name="equipment_inline">Équipement : %1$s</string>
|
||||
<string name="plan_summary">Plan : %1$d × %2$s · repos %3$d s · %4$s</string>
|
||||
<string name="target_load_inline">charge cible %1$s kg</string>
|
||||
<string name="no_numeric_load">sans charge numérique</string>
|
||||
<string name="search_machine">Rechercher une machine</string>
|
||||
<string name="modify_equipment">Modifier l’équipement</string>
|
||||
<string name="modify_equipment_description">Choisir, remplacer ou retirer la machine de cette entrée.</string>
|
||||
<string name="max_value">MAX : %1$s kg</string>
|
||||
<string name="modified_suffix"> · Modifié</string>
|
||||
<string name="feedback_move_description">Corriger le texte sans déplacer l’observation.</string>
|
||||
<string name="add_feedback">Ajouter un ressenti</string>
|
||||
<string name="session_follow_up">Suivi après séance</string>
|
||||
<string name="feedback_marker_description">Corriger le texte sans changer le repère H+.</string>
|
||||
<string name="add_record">Ajouter un enregistrement</string>
|
||||
<string name="remove_machine_question">Retirer la machine de cette entrée ?</string>
|
||||
<string name="replace_machine_question">Remplacer la machine associée ?</string>
|
||||
<string name="machine_change_detail">L’association de machine actuellement enregistrée sera perdue. Les séries, MAX et ressentis restent inchangés.</string>
|
||||
<string name="remove">Retirer</string>
|
||||
<string name="replace">Remplacer</string>
|
||||
<string name="mode_sets_reps">Mode : séries · répétitions</string>
|
||||
<string name="mode_sets_duration">Mode : séries · durée</string>
|
||||
<string name="assistance_kg">Assistance (kg)</string>
|
||||
<string name="sets_table">Série | Répétitions | %1$s</string>
|
||||
<string name="set_duration">Série %1$d : %2$s</string>
|
||||
<string name="mode_continuous">Mode : continu</string>
|
||||
<string name="duration_value">Durée : %1$s</string>
|
||||
<string name="speed_value">Vitesse : %1$.1f km/h</string>
|
||||
<string name="distance_value">Distance : %1$.2f km</string>
|
||||
<plurals name="duration_minutes_value"><item quantity="one">%d min</item><item quantity="other">%d min</item></plurals>
|
||||
<plurals name="duration_seconds_value"><item quantity="one">%d s</item><item quantity="other">%d s</item></plurals>
|
||||
<string name="period_7_days">7 j</string><string name="period_30_days">30 j</string><string name="period_90_days">90 j</string><string name="period_year">1 an</string><string name="period_all">Tout</string>
|
||||
<string name="statistics_invalid_ignored">Certaines données historiques invalides ont été ignorées ; les statistiques valides restent disponibles.</string>
|
||||
<string name="explore">EXPLORER</string><string name="distribution">Répartition</string>
|
||||
<string name="distribution_description">Zones et utilisation des exercices, uniquement lorsqu’elles sont classifiées.</string>
|
||||
<string name="global_summary">Résumé global</string><string name="global_summary_description">Indicateurs prudents dérivés des données disponibles.</string>
|
||||
<string name="summary_period">RÉSUMÉ · %1$s</string><string name="sets">Séries</string><string name="explicit_max_plural">MAX explicites</string>
|
||||
<string name="progression">PROGRESSION</string><string name="progress_insufficient">Données insuffisantes pour établir une progression comparable.</string>
|
||||
<string name="progress_counts">%1$d progressions de travail · %2$d records MAX</string><string name="max_open_detail">%1$d MAX explicites · ouvrir le détail</string>
|
||||
<string name="body_period_none">Aucune mensuration dans cette période.</string><string name="measurement_single">1 relevé · tendance indisponible</string>
|
||||
<string name="frequency">FRÉQUENCE</string><string name="current_week_summary">Semaine actuelle · %1$s · %2$d MAX inclus</string>
|
||||
<string name="max_not_double_counted">Les MAX signalent des séances ; ils ne sont jamais additionnés une seconde fois.</string>
|
||||
<string name="back_dashboard">← Retour au tableau de bord</string><string name="comparable_none">Aucune donnée comparable dans cette période.</string>
|
||||
<plurals name="session_count"><item quantity="one">%d séance</item><item quantity="other">%d séances</item></plurals>
|
||||
<string name="progress_detail_description">Chaque ligne de détail conserve un exercice canonique, un équipement et un mode comparables ; le tableau de bord ne somme jamais des kg.</string>
|
||||
<string name="body_detail_description">Aucune interpolation : seuls les relevés réellement enregistrés sont affichés.</string>
|
||||
<string name="frequency_title">Fréquence</string><string name="frequency_unit">séances</string>
|
||||
<string name="frequency_detail_description">Nombre de séances avec travail réel par semaine ; MAX est un marqueur, pas une seconde séance.</string>
|
||||
<string name="distribution_detail_description">La répartition détaillée arrive lorsque les occurrences classifiées sont disponibles dans cette vue.</string>
|
||||
<string name="summary_detail">%1$d séances · %2$d séries · %3$d exercices · %4$d MAX explicites</string>
|
||||
<string name="generator_zone">Zone corporelle</string><string name="generator_goal">Objectif</string>
|
||||
<string name="generator_custom_duration">Durée personnalisée (%1$d à %2$d min)</string>
|
||||
<string name="generate">Générer</string><string name="generate_description">Analyser l’historique en lecture seule et préparer la proposition.</string>
|
||||
<string name="duration_range_error">La durée doit être comprise entre %1$d et %2$d minutes.</string>
|
||||
<string name="target_duration_estimate">Durée cible : %1$d min · estimation : %2$d min</string>
|
||||
<string name="proposal_short">La proposition est nettement plus courte que la cible ; aucun exercice n’est ajouté pour remplir artificiellement le temps.</string>
|
||||
<string name="warmup_not_generated">Échauffement et retour au calme ne sont pas générés en V1.</string>
|
||||
<string name="recent_exposure_warning">Attention informative : l’historique montre une exposition récente. Cela ne mesure pas la récupération physiologique.</string>
|
||||
<string name="continue_warning">Continuer malgré l’avertissement</string><string name="continue_warning_description">Conserver la proposition et autoriser son acceptation.</string>
|
||||
<string name="equipment_generator">Équipement : %1$s</string><string name="target_load_none_proposed">Charge cible : aucune charge numérique proposée.</string><string name="target_load_proposed">Charge cible : %1$s kg</string>
|
||||
<string name="recent_similar">Historique récent similaire détecté ; information uniquement.</string>
|
||||
<string name="sets_label">Séries</string><string name="user_load_note">Charge : choix utilisateur, jamais une recommandation.</string>
|
||||
<string name="automatic">Automatique</string><string name="none">Aucune</string><string name="apply">Appliquer</string>
|
||||
<string name="modify_target">Modifier la cible</string><string name="modify_target_description">Modifier séries, répétitions, repos ou charge.</string>
|
||||
<string name="regenerate">Régénérer</string><string name="accept_actual_values">Accepter et saisir les valeurs réelles</string>
|
||||
<string name="cancel_proposal">Annuler la proposition</string><string name="cancel_proposal_description">Revenir sans écrire de brouillon.</string>
|
||||
<string name="processing">Traitement</string><string name="analysis_running">Analyse en cours…</string><string name="message_title">Message</string>
|
||||
<string name="resume">Reprendre</string><string name="durable_draft_count">%1$s · le brouillon durable est conservé.</string>
|
||||
<string name="session_proposal">Proposition de séance</string><string name="planned_for">Prévue le %1$s</string>
|
||||
<plurals name="repetition_count"><item quantity="one">%d répétition</item><item quantity="other">%d répétitions</item></plurals>
|
||||
<string name="start">Démarrer</string><string name="start_proposal_description">Copier cette proposition dans la séance en cours.</string>
|
||||
<string name="draft_already_active">Une séance est déjà en cours. Le brouillon importé a été conservé.</string>
|
||||
<string name="delete_draft_detail">Le brouillon « %1$s » sera supprimé.\nCette action est irréversible.</string>
|
||||
<string name="training">Entraînement</string><string name="resume_current_session">Reprendre la séance en cours</string>
|
||||
<string name="new_manual_description">Créer explicitement un brouillon durable.</string><string name="latest_session">Dernière séance</string><string name="quick_access">Accès rapides</string>
|
||||
<string name="zones_to_work">Zones à travailler</string><string name="recent_exposure">Exposition récente</string><string name="no_training_history">Pas encore d’historique d’entraînement.</string>
|
||||
<string name="invalid_history_timestamp">Données insuffisantes : un horodatage historique ne peut pas être interprété.</string>
|
||||
<string name="selection_value">Sélection : %1$s</string><string name="priorities">Priorités</string><string name="resolved_zone_none">Aucune zone disposant d’un exercice résolu n’est disponible.</string>
|
||||
<string name="exposure_explanation">Ces couleurs décrivent le travail enregistré récemment. Elles ne mesurent pas la récupération physiologique.</string><string name="understood">Compris</string>
|
||||
<string name="view_exercises">Voir les exercices</string><string name="view_statistics">Voir les statistiques</string>
|
||||
<string name="session_type">Type de séance</string><string name="training_selected">[✓] Entraînement</string><string name="training_unselected">[ ] Entraînement</string>
|
||||
<string name="edit_exercise_description">Corriger cet exercice sans le supprimer de la séance.</string><string name="editor_opened">Éditeur ouvert pour %1$s — les champs préremplis apparaissent ci-dessous.</string>
|
||||
<string name="editing_scroll_hint">Modification en cours — faites défiler jusqu’à SAISIE — %1$s.</string><string name="feedback_value">Ressenti · %1$s%2$s</string>
|
||||
<string name="feedback_now_description">Dicter ou saisir un ressenti maintenant, avant la fin de la séance.</string><string name="feedback_saved_current">Ressenti sauvegardé dans la séance en cours.</string>
|
||||
<string name="create_new_exercise">Créer un nouvel exercice</string><string name="finalize_failed">Échec de finalisation, brouillon conservé : %1$s</string>
|
||||
<string name="session_exercise_total">%1$s dans la séance.</string><string name="discard_session_question">Abandonner la séance en cours ?</string>
|
||||
<string name="discard_session_detail">Cette action supprimera le brouillon, ses %1$s et toutes leurs données enregistrées.</string>
|
||||
<string name="remove_exercise_question">Supprimer « %1$s » de la séance ?</string><string name="remove_occurrence_detail">Cette occurrence sera retirée.</string>
|
||||
<string name="continuous_fact">l’activité continue</string><string name="max_fact">la donnée MAX</string>
|
||||
<string name="create_context">Créer le contexte</string><string name="context_compatibility">Compatibilité temporaire pour un exercice personnalisé ou non résolu.</string>
|
||||
<string name="context_exists">Ce contexte existe déjà.</string><string name="context_create_error">Contexte non créé : %1$s</string><string name="equipment_selected">Équipement sélectionné.</string>
|
||||
<string name="positive_decimal_note">Valeur strictement positive ; les deux séparateurs décimaux sont acceptés.</string><string name="calculated_target">Cible calculée : %1$s kg</string>
|
||||
<string name="set_independent_values">Chaque série conserve ses propres répétitions et sa propre charge.</string><string name="add_set_plain">Ajouter une série</string>
|
||||
<string name="add_set_row_description">Ajouter une ligne vide sans modifier les autres séries.</string><string name="add_to_session">Ajouter à la séance</string>
|
||||
<string name="delete_set_entered_detail">Ses répétitions, sa durée et sa charge saisies seront supprimées.</string>
|
||||
<string name="validation_set_required">Ajoutez au moins une série.</string><string name="validation_set_reps">Série %1$d : saisissez des répétitions entre 0 et %2$d.</string>
|
||||
<string name="validation_set_load">Série %1$d : saisissez une charge non négative ou laissez la case vide.</string><string name="validation_sets_invalid">Valeurs de séries invalides.</string>
|
||||
<string name="exercise_removed">Exercice retiré de la séance.</string>
|
||||
<string name="target_positive">Saisissez une charge cible strictement positive.</string><string name="max_unavailable">MAX compatible indisponible.</string>
|
||||
<string name="max_assistance_unavailable">Le %%MAX est indisponible pour une assistance ; choisissez une résistance externe.</string>
|
||||
<string name="compatible_load_equipment_required">Choisissez un équipement compatible avec une charge cible.</string><string name="positive_dose_required">Définissez une dose cible positive avant la charge cible.</string>
|
||||
<string name="percent_range">Le pourcentage doit être compris entre 1 et 100.</string><string name="max_read_failed">Lecture du MAX impossible.</string>
|
||||
<string name="max_choose_external">MAX compatible indisponible : choisissez un équipement à résistance externe.</string><string name="max_known_external_required">Le %%MAX est indisponible sans équipement connu à résistance externe.</string><string name="max_exact_unavailable">MAX compatible indisponible pour cet exercice et cet équipement exacts.</string>
|
||||
<string name="organization_sets">Séries</string><string name="supplemental_data">Données complémentaires</string><string name="not_specified_feminine">Non renseignée</string><string name="not_specified_plural">Non renseignés</string>
|
||||
<string name="profile_change_note">Les séances passées et l’occurrence déjà présente dans le brouillon resteront inchangées. Le nouveau profil s’appliquera aux prochaines utilisations.</string>
|
||||
<string name="create_return_session">Créer et revenir à la séance</string><string name="identity_update_description">Conserver l’identité et mettre à jour le catalogue.</string>
|
||||
<string name="exercise_name_exists">Un exercice portant ce nom existe déjà.</string><string name="other_exercise_name_exists">Un autre exercice porte déjà ce nom.</string><string name="profile_used_error">Le profil ne peut pas changer après utilisation.</string>
|
||||
<string name="search_prefix">Recherche par préfixe</string><string name="profile_change_dialog_detail">Les anciennes séances et le brouillon actif resteront inchangés. Les nouvelles utilisations suivront le nouveau profil.</string>
|
||||
<string name="profile_transition_confirmed">Transition confirmée. Enregistrez pour appliquer le profil aux prochaines utilisations.</string>
|
||||
<string name="science_unresolved">Classification scientifique non résolue.</string><string name="conditional_interpretation">Interprétation conditionnelle · à confirmer : %1$s</string>
|
||||
<string name="movements_value">Mouvements : %1$s</string><string name="compatible_equipment_value">Équipement compatible : %1$s</string><string name="not_established">Non établi</string>
|
||||
<string name="create_exercise_description">Ajouter un profil au catalogue local.</string><string name="compatible_equipment">Équipements compatibles</string><string name="compatibility_none">Compatibilité non établie.</string>
|
||||
<string name="latest_result">Dernier résultat : %1$s kg · %2$s</string><string name="science_classification_confidence">Classification scientifique · confiance %1$s</string>
|
||||
<string name="conditional_classification">Classification conditionnelle · incertitude explicite</string><string name="confidence_high">élevée</string><string name="confidence_moderate">modérée</string><string name="confidence_low">faible</string>
|
||||
<string name="zone_not_specified">Zone : Non renseignée</string>
|
||||
<string name="confidence_uncertain">incertaine</string><string name="primary_zone_value">Zone principale : %1$s</string><string name="secondary_zones_value"> · Zones secondaires : %1$s</string><string name="group_value"> · Groupe : %1$s</string>
|
||||
<string name="goal_general">Général</string><string name="goal_strength">Force</string><string name="goal_hypertrophy">Hypertrophie</string><string name="goal_endurance">Endurance locale</string>
|
||||
<string name="generator_weight_invalid">La charge cible doit être un nombre positif fini.</string><string name="proposal">Proposition</string>
|
||||
<string name="coverage_incomplete">La couverture est incomplète faute de contextes résolus disponibles%1$s</string>
|
||||
<string name="exposure_summary">Exposition observée : %1$d séries principales et %2$d secondaires sur 24 h ; %3$d principales et %4$d secondaires sur 72 h.</string><string name="latest_exposure">Dernière exposition observée : %1$s</string>
|
||||
<string name="observed_load_note">Charge issue d’une dose réellement observée le %1$s ; son applicabilité aujourd’hui reste incertaine.</string>
|
||||
<string name="max_percentage_field">Pourcentage du MAX (1 à 100)</string><string name="manual_target_load">Charge cible manuelle (vide = automatique)</string>
|
||||
<string name="reestimate_description">Réestimer la durée et requalifier la charge observée.</string><string name="move_up">Monter</string><string name="move_up_description">Déplacer cet exercice avant le précédent.</string><string name="move_down">Descendre</string><string name="move_down_description">Déplacer cet exercice après le suivant.</string>
|
||||
<string name="regenerate_description">Relancer les mêmes entrées et le même instant de référence.</string><string name="accept_actual_description">Créer le brouillon normal sans préremplir les séries réalisées.</string><string name="acknowledge_exposure_first">Confirmez d’abord l’avertissement d’exposition récente.</string>
|
||||
<string name="reason_observed_dose">dose répétée observée</string><string name="reason_max_no_prescription">maximum observé sans prescription numérique</string><string name="reason_user_max">pourcentage de MAX choisi par l’utilisateur</string><string name="reason_max_unavailable">MAX compatible indisponible</string><string name="reason_assistance_omitted">charge d’assistance omise</string><string name="reason_load_absent">charge numérique absente</string><string name="reason_manual_load">charge saisie manuellement</string><string name="reason_preferred">exercice préféré</string><string name="reason_primary_zone">zone principale demandée</string><string name="reason_secondary_zone">zone secondaire demandée</string><string name="reason_distinct_movement">mouvement distinct</string><string name="reason_recent_exercise">même exercice observé récemment</string><string name="reason_recent_pattern">mouvement similaire observé récemment</string>
|
||||
<string name="shortage_upper">région supérieure manquante</string><string name="shortage_lower">région inférieure manquante</string><string name="shortage_core">tronc manquant</string><string name="shortage_push">poussée du haut du corps manquante</string><string name="shortage_pull">tirage du haut du corps manquant</string><string name="shortage_lower_extension">extension du bas du corps manquante</string><string name="shortage_lower_flexion">flexion du bas du corps manquante</string><string name="shortage_exercises">moins d’exercices distincts disponibles</string>
|
||||
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
|
||||
<string name="unknown_reference">Référence inconnue · %1$s</string><string name="definition">Définition</string><string name="personal">Personnel</string><string name="provided">Fourni</string><string name="label_value">Étiquette : %1$s</string>
|
||||
<string name="confidence_value">Confiance %1$s</string><string name="verified_sources">Sources vérifiées : %1$d</string><string name="personal_equipment">Équipement personnel</string><string name="create">Créer</string>
|
||||
<string name="create_machine_description">Créer une machine personnelle avec la sémantique actuelle.</string><string name="equipment_name_invalid">Saisissez un nom de 1 à 120 caractères.</string><string name="equipment_name_exists">Un équipement porte déjà ce nom.</string><string name="creation_failed">Création impossible : %1$s.</string>
|
||||
<string name="load_none">sans charge</string><string name="load_external">charge externe</string><string name="load_assistance">assistance</string><string name="load_bodyweight">poids du corps</string><string name="load_cardio">cardio</string>
|
||||
<string name="existing_draft_warning">Une séance est déjà en cours. Reprenez-la ou revenez à la proposition conservée.</string><string name="no_numeric_load_proposed">aucune charge numérique proposée</string>
|
||||
<string name="incompatible_change_detail">Ce changement incompatible remplacera :\n- %1$s\nLes ressentis restent attachés au même entry_id.</string><string name="set_load_field">Série %1$d — %2$s</string>
|
||||
<string name="selected_suffix">, sélectionnée</string><string name="not_selected_suffix">, non sélectionnée</string><string name="select_zone">Sélectionner %1$s</string>
|
||||
<string name="progress_a11y_empty">Progression sur six semaines : aucun événement comparable, six points vides</string><string name="progress_a11y_values">Progression sur six semaines : %1$s événements</string>
|
||||
<string name="chart_insufficient">Pas encore assez de données pour une courbe.</string><string name="chart_series_detail">%1$d séries comparables · ouvrir le détail</string>
|
||||
<string name="general">Général</string><string name="shoulders">Épaules</string><string name="empty_fields_ignored">Les champs vides sont ignorés.</string><string name="latest_measurements">Derniers relevés</string>
|
||||
<string name="state_prioritize">À privilégier</string><string name="state_little_work">Peu travaillé récemment</string><string name="state_recent_work">Travaillé récemment</string><string name="state_high_exposure">Forte exposition récente</string><string name="state_insufficient">Données insuffisantes</string><string name="state_unsupported">Aucun exercice résolu disponible</string><string name="relative_priority">Priorité relative</string>
|
||||
<string name="no_primary_exposure">Aucune exposition principale enregistrée</string><string name="latest_primary_work">Dernier travail principal : %1$s</string><string name="recent_primary_work">Travail principal sur 7 j : %1$d</string><string name="recent_secondary_work">Travail secondaire sur 7 j : %1$d</string>
|
||||
<string name="age_never">jamais enregistrée</string><string name="age_today">aujourd’hui</string><string name="age_yesterday">il y a 1 jour</string><plurals name="age_days"><item quantity="one">il y a %d jour</item><item quantity="other">il y a %d jours</item></plurals>
|
||||
<string name="latest_primary_work_label">Dernier travail principal : %1$s</string>
|
||||
<string name="frequency_a11y">Fréquence sur six semaines : %1$s</string><string name="frequency_week_a11y">%1$s %2$s dont %3$d MAX</string><string name="current_feminine">actuelle</string>
|
||||
<string name="weight">Poids</string><string name="target_summary">Cible : %1$d × %2$s · repos %3$d s</string><string name="primary_zone_value_short">Zone principale : %1$s</string><string name="movement_value">Mouvement : %1$s</string><string name="reasons_value">Raisons : %1$s</string><string name="rest_seconds">Repos (secondes)</string>
|
||||
<string name="name">Nom</string><string name="primary_zone">Zone principale</string><string name="secondary_zones">Zones secondaires</string><string name="add_profile_description">Ajouter ce profil au catalogue local.</string><string name="invalid_profile_name">Profil ou nom invalide.</string><string name="invalid_name_profile">Nom ou profil invalide.</string><string name="zone_filter">Filtre par zone</string><string name="all_zones">Toutes les zones</string>
|
||||
<string name="confidence_inline">Confiance : %1$s</string><string name="scientific_zones">Zones scientifiques : %1$s%2$s</string><string name="secondary_suffix"> · secondaires : %1$s</string><string name="limitation_value">Limite : %1$s</string><string name="source_value">Source : %1$s · %2$s%3$s</string><string name="exercise_catalog">Catalogue d’exercices</string><string name="filter">Filtrer</string><string name="profile">Profil</string>
|
||||
<string name="latest_max_value">Dernier MAX · %1$s · %2$s kg · %3$s</string><string name="type_value">Type : %1$s</string><string name="aliases_value">Alias : %1$s</string><string name="max_weight">Poids MAX (kg)</string><string name="add_entry_description">Ajouter cette saisie au brouillon.</string>
|
||||
<string name="neck">Cou</string><string name="chest">Poitrine</string><string name="waist">Tour de taille</string><string name="hips">Hanches</string><string name="limbs">Membres</string>
|
||||
<string name="left_arm">Bras gauche</string><string name="right_arm">Bras droit</string><string name="left_forearm">Avant-bras gauche</string><string name="right_forearm">Avant-bras droit</string><string name="left_thigh">Cuisse gauche</string><string name="right_thigh">Cuisse droite</string><string name="left_calf">Mollet gauche</string><string name="right_calf">Mollet droit</string><string name="recording">Enregistrement</string>
|
||||
<string name="body_map">Carte corporelle, %1$s</string><string name="body_front">Face</string><string name="body_back">Dos</string>
|
||||
<string name="plan_draft_summary">%1$s · %2$d × %3$s%4$s · repos %5$d s</string><string name="possible_exercises_plain">Exercices possibles</string>
|
||||
<string name="actions">Actions</string><string name="change_profile_question">Changer le profil de %1$s ?</string><string name="historical_context_optional">Contexte historique (optionnel)</string><string name="new_historical_context">Nouveau contexte historique</string>
|
||||
<string name="organization">Organisation</string><string name="continuous">Continu</string><string name="primary_measurement">Mesure principale</string><string name="cancel_catalog_description">Revenir au catalogue sans modification.</string><string name="contract">Contrat</string><string name="knowledge">Connaissances</string>
|
||||
<string name="remove_proposal_exercise">Retirer cet exercice de la proposition</string><string name="speech_interrupted">Reconnaissance interrompue (code %1$d).</string>
|
||||
<string name="draft_missing">Ce brouillon n’est plus disponible.</string><string name="invalid_value">Une valeur est invalide.</string><string name="positive_measure_required">Ajoutez au moins une mesure positive.</string><plurals name="measurement_count"><item quantity="one">%d mesure</item><item quantity="other">%d mesures</item></plurals><plurals name="available_exercise_count"><item quantity="one">%d exercice disponible</item><item quantity="other">%d exercices disponibles</item></plurals>
|
||||
<string name="max_test_selected">[✓] Test MAX</string><string name="max_test_unselected">[ ] Test MAX</string><string name="feedback_add_count">Ressenti (%1$d) · + Ajouter un ressenti</string><string name="delete_facts_detail">Cette action supprimera :\n- %1$s</string><string name="plan_rest_fact">les objectifs et le repos</string>
|
||||
<string name="context_name_invalid">Donnez un nom de contexte valide.</string><string name="max_weight_invalid">Saisissez un poids MAX strictement positif (ex. 100 ou 86,5).</string><string name="invalid_values">Valeurs invalides.</string><string name="return_catalog">Revenir au catalogue.</string>
|
||||
<string name="new_exercise">Nouvel exercice</string><string name="primary_zone_required">Une zone principale est requise pour un nouvel exercice musculaire.</string>
|
||||
<string name="feedback_voice">🎙 Ressenti</string><string name="draft_max_summary">%1$s · MAX : %2$s kg</string><string name="reps_short">rép.</string><string name="profile_continuous">CONTINU</string><string name="profile_sets">SÉRIES</string><string name="profile_reps">RÉP.</string><string name="profile_duration">DURÉE</string>
|
||||
<plurals name="point_count"><item quantity="one">%d point</item><item quantity="other">%d points</item></plurals>
|
||||
<string name="database_save_failed">Enregistrement en base impossible.</string><string name="knowledge_expand">+ Connaissances</string><string name="knowledge_collapse">− Connaissances</string>
|
||||
<plurals name="seconds_count"><item quantity="one">%d seconde</item><item quantity="other">%d secondes</item></plurals><string name="discard">Abandonner</string>
|
||||
<plurals name="feedback_count"><item quantity="one">%d ressenti</item><item quantity="other">%d ressentis</item></plurals><string name="during_session">Pendant la séance</string>
|
||||
<string name="profile_speed_suffix"> · vitesse</string><string name="profile_distance_suffix"> · distance</string>
|
||||
<string name="zone_full_body">Corps entier</string><string name="zone_upper_body">Membres supérieurs</string><string name="zone_chest">Pectoraux</string><string name="zone_back">Dos</string><string name="zone_shoulders">Épaules</string><string name="zone_arms">Bras</string><string name="zone_core">Abdominaux / tronc</string><string name="zone_lower_body">Membres inférieurs</string><string name="zone_glutes">Fessiers</string><string name="zone_thighs">Cuisses</string><string name="zone_calves">Mollets</string>
|
||||
<string name="performed_loads">charges réalisées</string><string name="explicit_max_label">MAX explicite</string>
|
||||
<string name="repository_item_missing">L’élément demandé n’est plus disponible.</string><string name="repository_invalid_data">Les données demandées sont invalides.</string><string name="repository_database_error">L’opération en base de données a échoué.</string><string name="repository_file_access">L’accès aux fichiers est requis.</string><string name="repository_operation_error">L’opération a échoué.</string>
|
||||
<string name="draft_selection_missing">L’exercice en cours de saisie n’existe plus ; seule la sélection a été annulée. La saisie partielle et les exercices ajoutés sont conservés.</string>
|
||||
<string name="a11y_reorder_exercise">Réordonner l’exercice</string>
|
||||
<string name="a11y_move_exercise_up">Déplacer l’exercice vers le haut</string>
|
||||
<string name="a11y_move_exercise_down">Déplacer l’exercice vers le bas</string>
|
||||
<string name="exercise_order_saved">Ordre de la séance enregistré.</string>
|
||||
<string name="exercise_reorder_failed">Réorganisation non enregistrée : %1$s</string>
|
||||
<string name="invalid_decimal_value">Saisissez une valeur décimale valide avec « , » ou « . ».</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -5,14 +5,25 @@
|
|||
*/
|
||||
package com.labfytools.trainlog.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeState
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.Locale
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class BodyZoneHomePresentationTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext<Context>().createConfigurationContext(
|
||||
Configuration(ApplicationProvider.getApplicationContext<Context>().resources.configuration).apply { setLocale(Locale.FRENCH) })
|
||||
@Test fun detailWordingIsExposureOnlyAndIncludesEvidence() {
|
||||
val status = BodyZoneHomeStatus(
|
||||
zoneId = "chest",
|
||||
|
|
@ -30,21 +41,21 @@ class BodyZoneHomePresentationTest {
|
|||
state = BodyZoneHomeState.PRIORITIZE,
|
||||
reasons = listOf("Exposition primaire moins récente."),
|
||||
)
|
||||
val text = recommendationReason(status)
|
||||
val text = recommendationReason(status, context)
|
||||
assertEquals("Dernier travail principal : il y a 10 jours", text)
|
||||
assertEquals("jamais enregistrée", ageLabel(null))
|
||||
assertEquals("aujourd’hui", ageLabel(60))
|
||||
assertEquals("il y a 10 jours", ageLabel(10 * 86400L))
|
||||
assertEquals("jamais enregistrée", ageLabel(null, context))
|
||||
assertEquals("aujourd’hui", ageLabel(60, context))
|
||||
assertEquals("il y a 10 jours", ageLabel(10 * 86400L, context))
|
||||
listOf("récupération", "fatigue", "prêt", "séance recommandée").forEach {
|
||||
assertTrue(it !in text.lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun recentWorkLabelsAreNeutralFactualCounts() {
|
||||
assertEquals("Travail principal sur 7 j : 1", recentWorkLabel(1, "principal"))
|
||||
assertEquals("Travail principal sur 7 j : 2", recentWorkLabel(2, "principal"))
|
||||
assertEquals("Travail secondaire sur 7 j : 1", recentWorkLabel(1, "secondaire"))
|
||||
assertEquals("Travail secondaire sur 7 j : 3", recentWorkLabel(3, "secondaire"))
|
||||
assertEquals("Travail principal sur 7 j : 1", recentWorkLabel(1, true, context))
|
||||
assertEquals("Travail principal sur 7 j : 2", recentWorkLabel(2, true, context))
|
||||
assertEquals("Travail secondaire sur 7 j : 1", recentWorkLabel(1, false, context))
|
||||
assertEquals("Travail secondaire sur 7 j : 3", recentWorkLabel(3, false, context))
|
||||
}
|
||||
|
||||
@Test fun recentPrioritizedExposureIsPresentedAsRelativePriority() {
|
||||
|
|
@ -65,10 +76,10 @@ class BodyZoneHomePresentationTest {
|
|||
reasons = emptyList(),
|
||||
)
|
||||
|
||||
assertEquals("Priorité relative", homeStateLabel(status))
|
||||
assertEquals("Travail principal sur 7 j : 2", recommendationReason(status))
|
||||
assertFalse(recommendationReason(status).contains("exposition"))
|
||||
assertFalse(recommendationReason(status).contains("fait(s)"))
|
||||
assertFalse(recommendationReason(status).contains("pondéré"))
|
||||
assertEquals("Priorité relative", homeStateLabel(status, context))
|
||||
assertEquals("Travail principal sur 7 j : 2", recommendationReason(status, context))
|
||||
assertFalse(recommendationReason(status, context).contains("exposition"))
|
||||
assertFalse(recommendationReason(status, context).contains("fait(s)"))
|
||||
assertFalse(recommendationReason(status, context).contains("pondéré"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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."))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]:
|
||||
|
|
|
|||
Loading…
Reference in a new issue