From 444df7dadd0e32f7c052421d3b1de8eae604036b Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Mon, 2 Nov 2026 16:35:51 +0100 Subject: [PATCH] =?UTF-8?q?Calcule=20les=20objectifs,=20d=C3=A9place=20les?= =?UTF-8?q?=20repas,=20enl=C3=A8ve=20le=20bandeau?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Déplacer une ligne d'un repas à l'autre ne demande plus de la ressaisir : une feuille propose les autres moments de la journée. Le profil calcule désormais l'objectif calorique et les macros à partir d'un questionnaire : taille, âge, sexe, pas quotidiens, séances de salle et sorties cardio avec leurs intensités, objectif de poids. L'activité n'est pas prise comme un facteur tout fait mais reconstituée, ce qui colle mieux à une semaine qui varie. Les chiffres se recalculent à chaque réponse et rien ne s'applique sans validation. Pour une perte, l'app annonce la perte réellement atteignable : quand le rythme demandé passerait sous le minimum vital, la cible est bornée et l'écart dit en clair. Sans cela, demander 1,5 kg par semaine affichait une cible plancher sans prévenir que le rythme était hors d'atteinte. Le poids vient de Health Connect, où la balance l'écrit : c'est lui qui pilote le calcul. Deux corrections trouvées à l'écran : un bandeau restait en haut des onglets, la barre n'ayant plus de titre mais gardant sa couleur de surface ; et les champs numériques renvoyaient le curseur au début, « 180 » pouvant s'afficher « 801 » puis diverger de la valeur réellement retenue. Version 1.1.0. --- CHANGELOG.md | 12 + README.md | 5 +- app/build.gradle.kts | 4 +- .../java/com/kaz229/fittrack/data/Daos.kt | 3 + .../fittrack/data/FitTrackRepository.kt | 33 ++ .../java/com/kaz229/fittrack/data/Needs.kt | 170 +++++++++ .../kaz229/fittrack/data/UserPreferences.kt | 47 +++ .../com/kaz229/fittrack/ui/FitTrackApp.kt | 31 ++ .../kaz229/fittrack/ui/FitTrackViewModel.kt | 30 ++ .../kaz229/fittrack/ui/screens/MealsScreen.kt | 78 +++- .../kaz229/fittrack/ui/screens/NeedsScreen.kt | 339 ++++++++++++++++++ .../fittrack/ui/screens/ProfileScreen.kt | 13 + .../java/com/kaz229/fittrack/NeedsTest.kt | 166 +++++++++ 13 files changed, 924 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/kaz229/fittrack/data/Needs.kt create mode 100644 app/src/main/java/com/kaz229/fittrack/ui/screens/NeedsScreen.kt create mode 100644 app/src/test/java/com/kaz229/fittrack/NeedsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f836fd..b156be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ Numérotation `MAJEUR.MINEUR.CORRECTIF`. Le numéro vit dans `app/build.gradle.kts` (`versionName`), et `versionCode` s'incrémente de un à chaque version publiée. +## 1.1.0 (2026-11-02) + +### Nouveautés +- Une ligne du journal se **déplace d'un repas à l'autre** sans la ressaisir. +- **Calcul des besoins** à partir d'un questionnaire : taille, âge, sexe, pas quotidiens, séances de salle et sorties cardio avec leurs intensités, objectif. L'objectif calorique et les macros s'en déduisent et s'appliquent d'un bouton. +- Pour une perte, le rythme hebdomadaire visé se choisit. L'app dit la perte **réellement atteignable** : quand le rythme demandé passerait sous le minimum vital, la cible est bornée et l'écart annoncé. +- Le **poids est repris de Health Connect** à chaque lancement — celui qu'écrit la balance. + +### Corrections +- Plus de bandeau en haut des onglets : la barre épouse le fond. +- Les champs numériques du questionnaire gardaient mal le curseur ; « 180 » pouvait s'afficher « 801 ». + ## 1.0.0 (2026-11-02) Première version complète : l'application couvre désormais la salle et la nutrition diff --git a/README.md b/README.md index acfe412..8822838 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FitTrack -**Version 1.0.0** — les nouveautés de chaque version sont dans [CHANGELOG.md](CHANGELOG.md). +**Version 1.1.0** — les nouveautés de chaque version sont dans [CHANGELOG.md](CHANGELOG.md). Application Android pour **suivre ses séances de salle** et **tracker ses calories et ses macros**. Pas de compte, pas de serveur, pas de publicité : tout est stocké sur le téléphone. @@ -44,7 +44,8 @@ Rien d'autre ne sort du téléphone, et les deux restent facultatifs. - **Saisie libre** : pour la cuisine maison ou un produit absent de la base, avec macros optionnelles. - **Accueil** : bilan calorique du jour (ingéré / brûlé / net), macros face aux objectifs, volume soulevé, progression vers l'objectif, historique des 7 derniers jours. -- **Profil** : poids, objectif calorique et objectifs de macros, utilisés pour les calculs. +- **Profil** : objectif calorique et macros **calculés** à partir de quelques questions + (pas quotidiens, séances, intensités, rythme de perte visé) ; poids repris de Health Connect. ### Estimation des calories brûlées diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0be27c3..33f0993 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.kaz229.fittrack" minSdk = 26 targetSdk = 35 - versionCode = 2 - versionName = "1.0.0" + versionCode = 3 + versionName = "1.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/kaz229/fittrack/data/Daos.kt b/app/src/main/java/com/kaz229/fittrack/data/Daos.kt index 1fb9e02..2aa7a9f 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Daos.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Daos.kt @@ -103,6 +103,9 @@ interface MealDao { @Insert suspend fun insert(meal: Meal): Long + @Update + suspend fun update(meal: Meal) + @Delete suspend fun delete(meal: Meal) diff --git a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt index 441d39c..6b9aaf4 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -28,6 +28,33 @@ class FitTrackRepository( val theme: Flow = userPreferences.theme + val needsProfile: Flow = userPreferences.needsProfile + + suspend fun setNeedsProfile(value: NeedsProfile) = userPreferences.setNeedsProfile(value) + + /** + * Applique l'objectif et les macros calculés à partir du questionnaire. + * + * Rien n'est recalculé en silence : l'utilisateur voit les chiffres avant de valider. + */ + suspend fun applyComputedNeeds(needs: Needs) { + userPreferences.setDailyCalorieGoal(needs.target) + userPreferences.setMacroGoals(needs.macros) + } + + /** + * Reprend le poids de Health Connect, écrit par la balance. + * + * Renseigné chaque pesée par l'application de la balance, il est plus à jour que + * celui saisi à la main — et c'est lui qui pilote le calcul des besoins. + */ + suspend fun refreshWeightFromHealth(): Double? { + val weight = healthConnect?.latestWeightKg() ?: return null + if (weight <= 0) return null + userPreferences.setWeight(weight) + return weight + } + suspend fun setTheme(choice: ThemeChoice) = userPreferences.setTheme(choice) // --- Séances ------------------------------------------------------------- @@ -164,6 +191,12 @@ class FitTrackRepository( suspend fun deleteMeal(meal: Meal) = mealDao.delete(meal) + /** Range une ligne sous un autre moment de la journée, sans la ressaisir. */ + suspend fun moveMeal(meal: Meal, mealType: MealType) { + if (meal.mealType == mealType.id) return + mealDao.update(meal.copy(mealType = mealType.id)) + } + /** Réinscrit un aliment déjà saisi, à la date du jour et au repas choisi. */ suspend fun repeatMeal(meal: Meal, mealType: MealType, date: LocalDate = LocalDate.now()) { mealDao.insert( diff --git a/app/src/main/java/com/kaz229/fittrack/data/Needs.kt b/app/src/main/java/com/kaz229/fittrack/data/Needs.kt new file mode 100644 index 0000000..06cece7 --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/data/Needs.kt @@ -0,0 +1,170 @@ +package com.kaz229.fittrack.data + +import kotlin.math.roundToInt + +/** Sexe biologique, dont la formule du métabolisme de base a besoin. */ +enum class Sex(val id: String, val label: String) { + MALE("homme", "Homme"), + FEMALE("femme", "Femme"); + + companion object { + fun byId(id: String?): Sex = entries.firstOrNull { it.id == id } ?: MALE + } +} + +/** Intensité déclarée d'un entraînement, qui pèse sur sa dépense. */ +enum class Intensity(val id: String, val label: String, val met: Double) { + EASY("facile", "Tranquille", 4.0), + MODERATE("modere", "Modérée", 6.0), + HARD("intense", "Intense", 8.5); + + companion object { + fun byId(id: String?): Intensity = entries.firstOrNull { it.id == id } ?: MODERATE + } +} + +/** Ce que l'utilisateur vise : perdre, tenir, ou prendre. */ +enum class Goal(val id: String, val label: String) { + LOSE("perdre", "Perdre du poids"), + MAINTAIN("maintenir", "Maintenir"), + GAIN("prendre", "Prendre du muscle"); + + companion object { + fun byId(id: String?): Goal = entries.firstOrNull { it.id == id } ?: MAINTAIN + } +} + +/** + * Les réponses du questionnaire, à partir desquelles tout est calculé. + * + * [weeklyLossKg] est un rythme hebdomadaire : positif pour une perte, et ignoré quand + * l'objectif n'est pas d'en perdre. + */ +data class NeedsProfile( + val heightCm: Int = 0, + val age: Int = 0, + val sex: Sex = Sex.MALE, + val dailySteps: Int = 0, + val gymPerWeek: Int = 0, + val gymMinutes: Int = 60, + val gymIntensity: Intensity = Intensity.MODERATE, + val runsPerWeek: Int = 0, + val runMinutes: Int = 30, + val runIntensity: Intensity = Intensity.MODERATE, + val goal: Goal = Goal.MAINTAIN, + val weeklyLossKg: Double = 0.5, +) { + /** Tant que la taille et l'âge manquent, aucun calcul n'est possible. */ + val complete: Boolean get() = heightCm > 0 && age > 0 +} + +/** Le détail du calcul, pour que l'écran puisse montrer d'où viennent les chiffres. */ +data class Needs( + val bmr: Int, + val stepsKcal: Int, + val trainingKcal: Int, + val maintenance: Int, + val target: Int, + val macros: MacroGoals, + val weeklyDeficit: Int, + /** Perte réellement permise par la cible, qui peut être inférieure à celle demandée. */ + val achievableWeeklyLossKg: Double, + /** Vrai quand le plancher vital a borné la cible : le rythme demandé est hors d'atteinte. */ + val cappedByFloor: Boolean, +) { + /** + * Un rythme intenable, soit parce que le déficit dépasse ce qu'on tient sans carences, + * soit parce que le plancher vital a dû borner la cible. + */ + val tooAggressive: Boolean get() = cappedByFloor || maintenance - target > 1000 +} + +/** Un kilo de masse grasse vaut environ 7 700 kcal. */ +private const val KCAL_PER_KG = 7700.0 + +/** En dessous, un adulte ne couvre plus ses besoins de base. */ +private const val FLOOR_MALE = 1500 +private const val FLOOR_FEMALE = 1200 + +/** + * Estime les besoins journaliers et une répartition de macros. + * + * Le métabolisme de base suit Mifflin-St Jeor, plus fiable que Harris-Benedict sur les + * populations actuelles. L'activité n'est pas prise comme un facteur multiplicateur tout + * fait, mais reconstituée : les pas d'un côté, les entraînements déclarés de l'autre. + * C'est plus proche du réel pour quelqu'un dont la semaine varie. + * + * **Les calories brûlées pendant une séance ne sont pas comptées deux fois.** Elles + * entrent ici, dans le besoin de maintien ; l'app les soustrait déjà de la journée quand + * une séance est enregistrée. L'objectif affiché est donc un objectif *avant* sport. + */ +fun computeNeeds(profile: NeedsProfile, weightKg: Double): Needs? { + if (!profile.complete || weightKg <= 0) return null + + val bmr = when (profile.sex) { + Sex.MALE -> 10 * weightKg + 6.25 * profile.heightCm - 5 * profile.age + 5 + Sex.FEMALE -> 10 * weightKg + 6.25 * profile.heightCm - 5 * profile.age - 161 + } + + // Marcher coûte environ 0,04 kcal par pas et par kilo : 8 000 pas à 75 kg ≈ 240 kcal + val stepsKcal = profile.dailySteps * 0.0004 * weightKg + + // Les séances sont déclarées par semaine : on les ramène à la journée + val gym = metKcal(profile.gymIntensity, profile.gymMinutes, weightKg) * profile.gymPerWeek + val runs = metKcal(profile.runIntensity, profile.runMinutes, weightKg) * profile.runsPerWeek + val trainingKcal = (gym + runs) / 7.0 + + // Le corps dépense environ 10 % de ce qu'il ingère rien qu'à le digérer + val maintenance = (bmr + stepsKcal + trainingKcal) * 1.1 + + val dailyDelta = when (profile.goal) { + Goal.LOSE -> -(profile.weeklyLossKg.coerceIn(0.0, 1.5) * KCAL_PER_KG / 7.0) + Goal.GAIN -> 250.0 + Goal.MAINTAIN -> 0.0 + } + val floor = if (profile.sex == Sex.MALE) FLOOR_MALE else FLOOR_FEMALE + val wanted = (maintenance + dailyDelta).roundToInt() + val target = wanted.coerceAtLeast(floor) + val deficit = maintenance.roundToInt() - target + + return Needs( + bmr = bmr.roundToInt(), + stepsKcal = stepsKcal.roundToInt(), + trainingKcal = trainingKcal.roundToInt(), + maintenance = maintenance.roundToInt(), + target = target, + macros = macrosFor(profile.goal, weightKg, target), + weeklyDeficit = deficit * 7, + achievableWeeklyLossKg = if (deficit > 0) deficit * 7 / KCAL_PER_KG else 0.0, + cappedByFloor = profile.goal == Goal.LOSE && wanted < floor, + ) +} + +private fun metKcal(intensity: Intensity, minutes: Int, weightKg: Double): Double = + intensity.met * 3.5 * weightKg / 200.0 * minutes + +/** + * Protéines et lipides d'abord, glucides avec ce qui reste. + * + * Les protéines se calent sur le poids (plus hautes en perte, pour garder le muscle), + * les lipides sur un plancher hormonal, et les glucides absorbent le solde — ce sont + * eux qui bougent quand l'objectif change. + */ +private fun macrosFor(goal: Goal, weightKg: Double, target: Int): MacroGoals { + val proteinPerKg = when (goal) { + Goal.LOSE -> 2.2 + Goal.GAIN -> 1.8 + Goal.MAINTAIN -> 1.8 + } + val protein = (weightKg * proteinPerKg).roundToInt() + val fat = (weightKg * 0.9).roundToInt() + val remaining = target - protein * 4 - fat * 9 + // Jamais de glucides négatifs : sur un objectif très bas, on rogne les lipides + val carbs = if (remaining >= 0) { + remaining / 4 + } else { + 0 + } + val adjustedFat = if (remaining >= 0) fat else ((target - protein * 4) / 9).coerceAtLeast(0) + return MacroGoals(proteinG = protein, carbsG = carbs, fatG = adjustedFat) +} diff --git a/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt b/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt index ba7405c..00ff42f 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt @@ -59,6 +59,18 @@ class UserPreferences(private val context: Context) { private val kazisTokenKey = stringPreferencesKey("kazis_token") private val deviceIdKey = stringPreferencesKey("device_id") private val themeKey = stringPreferencesKey("theme_choice") + private val heightKey = intPreferencesKey("height_cm") + private val ageKey = intPreferencesKey("age") + private val sexKey = stringPreferencesKey("sex") + private val stepsKey = intPreferencesKey("daily_steps") + private val gymPerWeekKey = intPreferencesKey("gym_per_week") + private val gymMinutesKey = intPreferencesKey("gym_minutes") + private val gymIntensityKey = stringPreferencesKey("gym_intensity") + private val runsPerWeekKey = intPreferencesKey("runs_per_week") + private val runMinutesKey = intPreferencesKey("run_minutes") + private val runIntensityKey = stringPreferencesKey("run_intensity") + private val bodyGoalKey = stringPreferencesKey("body_goal") + private val weeklyLossKey = doublePreferencesKey("weekly_loss_kg") private val lastBackupKey = longPreferencesKey("last_kazis_backup_epoch_second") val profile: Flow = context.dataStore.data.map { prefs -> @@ -113,6 +125,41 @@ class UserPreferences(private val context: Context) { val theme: Flow = context.dataStore.data.map { ThemeChoice.byId(it[themeKey]) } + /** Les réponses du questionnaire, d'où sortent l'objectif et les macros. */ + val needsProfile: Flow = context.dataStore.data.map { prefs -> + NeedsProfile( + heightCm = prefs[heightKey] ?: 0, + age = prefs[ageKey] ?: 0, + sex = Sex.byId(prefs[sexKey]), + dailySteps = prefs[stepsKey] ?: 0, + gymPerWeek = prefs[gymPerWeekKey] ?: 0, + gymMinutes = prefs[gymMinutesKey] ?: 60, + gymIntensity = Intensity.byId(prefs[gymIntensityKey]), + runsPerWeek = prefs[runsPerWeekKey] ?: 0, + runMinutes = prefs[runMinutesKey] ?: 30, + runIntensity = Intensity.byId(prefs[runIntensityKey]), + goal = Goal.byId(prefs[bodyGoalKey]), + weeklyLossKg = prefs[weeklyLossKey] ?: 0.5, + ) + } + + suspend fun setNeedsProfile(value: NeedsProfile) { + context.dataStore.edit { + it[heightKey] = value.heightCm + it[ageKey] = value.age + it[sexKey] = value.sex.id + it[stepsKey] = value.dailySteps + it[gymPerWeekKey] = value.gymPerWeek + it[gymMinutesKey] = value.gymMinutes + it[gymIntensityKey] = value.gymIntensity.id + it[runsPerWeekKey] = value.runsPerWeek + it[runMinutesKey] = value.runMinutes + it[runIntensityKey] = value.runIntensity.id + it[bodyGoalKey] = value.goal.id + it[weeklyLossKey] = value.weeklyLossKg + } + } + suspend fun setTheme(choice: ThemeChoice) { context.dataStore.edit { it[themeKey] = choice.id } } diff --git a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt index f0c91ae..e48c326 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -24,12 +24,14 @@ import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,6 +61,7 @@ import com.kaz229.fittrack.ui.screens.HealthConnectScreen import com.kaz229.fittrack.ui.screens.HomeScreen import com.kaz229.fittrack.ui.screens.KazisScreen import com.kaz229.fittrack.ui.screens.MealsScreen +import com.kaz229.fittrack.ui.screens.NeedsScreen import com.kaz229.fittrack.ui.screens.ProfileScreen import com.kaz229.fittrack.ui.screens.ScanScreen import com.kaz229.fittrack.ui.screens.SessionDetailScreen @@ -83,6 +86,7 @@ private const val SCAN = "scan" private const val HEALTH = "health" private const val KAZIS = "kazis" private const val FOOD_SEARCH = "food-search" +private const val NEEDS = "needs" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -122,6 +126,8 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { val kazisState by viewModel.kazis.collectAsStateWithLifecycle() val foodSearchState by viewModel.foodSearch.collectAsStateWithLifecycle() val theme by viewModel.theme.collectAsStateWithLifecycle() + val needsAnswers by viewModel.needsProfile.collectAsStateWithLifecycle() + val needs by viewModel.needs.collectAsStateWithLifecycle() val catalog by viewModel.catalog.collectAsStateWithLifecycle() val exercisesById by viewModel.exercisesById.collectAsStateWithLifecycle() val openSession by viewModel.openSession.collectAsStateWithLifecycle() @@ -137,6 +143,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { HEALTH -> "Health Connect" KAZIS -> "Repas dictés" FOOD_SEARCH -> "Chercher un aliment" + NEEDS -> "Mes besoins" else -> currentTab?.label ?: "FitTrack" } @@ -145,6 +152,15 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { TopAppBar( // Les onglets affichent leur propre en-tête : un titre ici ferait doublon title = { if (!isTopLevel) Text(title) }, + // Sur un onglet la barre ne porte plus que l'accès au profil : lui laisser + // la couleur de surface dessinait un bandeau en haut de l'écran. + colors = TopAppBarDefaults.topAppBarColors( + containerColor = if (isTopLevel) { + MaterialTheme.colorScheme.background + } else { + MaterialTheme.colorScheme.surface + }, + ), navigationIcon = { if (!isTopLevel) { IconButton(onClick = { navController.popBackStack() }) { @@ -274,6 +290,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { macros = macros, macroGoals = profile.macroGoals, onDelete = viewModel::deleteMeal, + onMove = viewModel::moveMeal, ) } composable(FOOD_SEARCH) { @@ -290,6 +307,19 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { }, ) } + composable(NEEDS) { + NeedsScreen( + answers = needsAnswers, + needs = needs, + weightKg = profile.weightKg, + onChange = viewModel::setNeedsProfile, + onApply = { + viewModel.applyComputedNeeds() + navController.popBackStack() + }, + onRefreshWeight = viewModel::refreshWeightFromHealth, + ) + } composable(KAZIS) { KazisScreen( state = kazisState, @@ -349,6 +379,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { onThemeChange = viewModel::setTheme, onOpenHealthConnect = { navController.navigate(HEALTH) }, onOpenKazis = { navController.navigate(KAZIS) }, + onOpenNeeds = { navController.navigate(NEEDS) }, onSave = { weight, goal, macroGoals -> viewModel.setWeight(weight) viewModel.setDailyCalorieGoal(goal) diff --git a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt index e47f3d7..094d5dd 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt @@ -20,6 +20,9 @@ import com.kaz229.fittrack.data.KazisSettings import com.kaz229.fittrack.data.Labels import com.kaz229.fittrack.data.MacroGoals import com.kaz229.fittrack.data.MacroTotals +import com.kaz229.fittrack.data.Needs +import com.kaz229.fittrack.data.NeedsProfile +import com.kaz229.fittrack.data.computeNeeds import com.kaz229.fittrack.data.Meal import com.kaz229.fittrack.data.MealType import com.kaz229.fittrack.data.Session @@ -329,6 +332,8 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application // main, puis la sauvegarde de ce que le téléphone est seul à détenir. repository.fetchKazisMeals(today) repository.backupToKazis() + // La balance écrit dans Health Connect : le poids du profil doit suivre + repository.refreshWeightFromHealth() } } @@ -396,6 +401,27 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application // --- Séances, repas, bilans --------------------------------------------- + val needsProfile: StateFlow = repository.needsProfile + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NeedsProfile()) + + /** Les besoins recalculés à chaque réponse, pour que l'écran montre l'effet tout de suite. */ + val needs: StateFlow = combine( + repository.needsProfile, + repository.profile, + ) { answers, profile -> computeNeeds(answers, profile.weightKg) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + fun setNeedsProfile(value: NeedsProfile) = viewModelScope.launch { + repository.setNeedsProfile(value) + } + + fun applyComputedNeeds() = viewModelScope.launch { + needs.value?.let { repository.applyComputedNeeds(it) } + } + + /** Relit le poids écrit par la balance dans Health Connect. */ + fun refreshWeightFromHealth() = viewModelScope.launch { repository.refreshWeightFromHealth() } + val theme: StateFlow = repository.theme .stateIn(viewModelScope, SharingStarted.Eagerly, ThemeChoice.SYSTEM) @@ -451,6 +477,10 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application fun deleteMeal(meal: Meal) = viewModelScope.launch { repository.deleteMeal(meal) } + fun moveMeal(meal: Meal, mealType: MealType) = viewModelScope.launch { + repository.moveMeal(meal, mealType) + } + fun setWeight(weightKg: Double) = viewModelScope.launch { repository.setWeight(weightKg) } fun setDailyCalorieGoal(goal: Int) = viewModelScope.launch { diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/MealsScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/MealsScreen.kt index 807ace2..ef889ba 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/MealsScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/MealsScreen.kt @@ -17,7 +17,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.foundation.clickable import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -25,6 +29,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,8 +60,22 @@ fun MealsScreen( macros: MacroTotals, macroGoals: MacroGoals, onDelete: (Meal) -> Unit, + onMove: (Meal, MealType) -> Unit, modifier: Modifier = Modifier, ) { + // La ligne qu'on veut ranger ailleurs : le choix du moment se fait dans une feuille + var moving by remember { mutableStateOf(null) } + moving?.let { meal -> + MoveMealSheet( + meal = meal, + onDismiss = { moving = null }, + onPick = { type -> + onMove(meal, type) + moving = null + }, + ) + } + val byType = MealType.entries.associateWith { type -> meals.filter { it.mealType == type.id } } @@ -87,6 +109,7 @@ fun MealsScreen( type = type, meals = byType[type].orEmpty(), onDelete = onDelete, + onMove = { moving = it }, ) } } @@ -95,7 +118,12 @@ fun MealsScreen( /** Un moment de la journée : son point sur la ligne, et ce qu'on y a mangé. */ @Composable -private fun MealBlock(type: MealType, meals: List, onDelete: (Meal) -> Unit) { +private fun MealBlock( + type: MealType, + meals: List, + onDelete: (Meal) -> Unit, + onMove: (Meal) -> Unit, +) { val filled = meals.isNotEmpty() Row(modifier = Modifier.fillMaxWidth()) { TimelineMarker(filled = filled) @@ -134,7 +162,13 @@ private fun MealBlock(type: MealType, meals: List, onDelete: (Meal) -> Uni ) } } - meals.forEach { meal -> MealLine(meal, onDelete = { onDelete(meal) }) } + meals.forEach { meal -> + MealLine( + meal = meal, + onDelete = { onDelete(meal) }, + onMove = { onMove(meal) }, + ) + } } } } @@ -166,7 +200,7 @@ private fun TimelineMarker(filled: Boolean) { } @Composable -private fun MealLine(meal: Meal, onDelete: () -> Unit) { +private fun MealLine(meal: Meal, onDelete: () -> Unit, onMove: () -> Unit) { val colors = LocalMacroColors.current Row( modifier = Modifier.fillMaxWidth(), @@ -193,6 +227,13 @@ private fun MealLine(meal: Meal, onDelete: () -> Unit) { } } } + IconButton(onClick = onMove) { + Icon( + Icons.Filled.SwapVert, + contentDescription = "Déplacer ${meal.name} vers un autre repas", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } IconButton(onClick = onDelete) { Icon( Icons.Filled.Delete, @@ -203,6 +244,37 @@ private fun MealLine(meal: Meal, onDelete: () -> Unit) { } } +/** Choix du moment où ranger la ligne. Le moment courant est écarté. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MoveMealSheet(meal: Meal, onDismiss: () -> Unit, onPick: (MealType) -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(text = meal.name, style = MaterialTheme.typography.titleMedium) + Text( + text = "Déplacer vers", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + MealType.entries.filter { it.id != meal.mealType }.forEach { type -> + Text( + text = type.label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .clickable { onPick(type) } + .padding(vertical = 14.dp, horizontal = 12.dp), + ) + } + } + } +} + @Composable private fun MacroTag(text: String, color: Color) { Text( diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/NeedsScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/NeedsScreen.kt new file mode 100644 index 0000000..a56ba00 --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/NeedsScreen.kt @@ -0,0 +1,339 @@ +package com.kaz229.fittrack.ui.screens + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.kaz229.fittrack.data.Goal +import com.kaz229.fittrack.data.Intensity +import com.kaz229.fittrack.data.Needs +import com.kaz229.fittrack.data.NeedsProfile +import com.kaz229.fittrack.data.Sex +import com.kaz229.fittrack.ui.formatWeight + +/** + * Questionnaire d'où sortent l'objectif calorique et les macros. + * + * Les chiffres se recalculent à chaque réponse et restent affichés : on voit tout de + * suite ce que coûte une séance de plus ou un rythme de perte plus ambitieux. Rien + * n'est appliqué au profil tant qu'on n'a pas validé. + */ +@Composable +fun NeedsScreen( + answers: NeedsProfile, + needs: Needs?, + weightKg: Double, + onChange: (NeedsProfile) -> Unit, + onApply: () -> Unit, + onRefreshWeight: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column { + Text( + text = "Quelques questions", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = "Mes besoins", style = MaterialTheme.typography.headlineMedium) + } + + needs?.let { ResultCard(it, answers) } + + Section("Toi") + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + NumberField( + label = "Taille (cm)", + value = answers.heightCm, + onValue = { onChange(answers.copy(heightCm = it)) }, + modifier = Modifier.weight(1f), + ) + NumberField( + label = "Âge", + value = answers.age, + onValue = { onChange(answers.copy(age = it)) }, + modifier = Modifier.weight(1f), + ) + } + ChipRow( + values = Sex.entries.toList(), + selected = answers.sex, + labelOf = { it.label }, + onSelect = { onChange(answers.copy(sex = it)) }, + ) + WeightLine(weightKg = weightKg, onRefresh = onRefreshWeight) + + Section("Au quotidien") + NumberField( + label = "Pas par jour, en moyenne", + value = answers.dailySteps, + onValue = { onChange(answers.copy(dailySteps = it)) }, + ) + + Section("Salle") + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + NumberField( + label = "Séances / semaine", + value = answers.gymPerWeek, + onValue = { onChange(answers.copy(gymPerWeek = it)) }, + modifier = Modifier.weight(1f), + ) + NumberField( + label = "Minutes", + value = answers.gymMinutes, + onValue = { onChange(answers.copy(gymMinutes = it)) }, + modifier = Modifier.weight(1f), + ) + } + ChipRow( + values = Intensity.entries.toList(), + selected = answers.gymIntensity, + labelOf = { it.label }, + onSelect = { onChange(answers.copy(gymIntensity = it)) }, + ) + + Section("Course et cardio") + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + NumberField( + label = "Sorties / semaine", + value = answers.runsPerWeek, + onValue = { onChange(answers.copy(runsPerWeek = it)) }, + modifier = Modifier.weight(1f), + ) + NumberField( + label = "Minutes", + value = answers.runMinutes, + onValue = { onChange(answers.copy(runMinutes = it)) }, + modifier = Modifier.weight(1f), + ) + } + ChipRow( + values = Intensity.entries.toList(), + selected = answers.runIntensity, + labelOf = { it.label }, + onSelect = { onChange(answers.copy(runIntensity = it)) }, + ) + + Section("Objectif") + ChipRow( + values = Goal.entries.toList(), + selected = answers.goal, + labelOf = { it.label }, + onSelect = { onChange(answers.copy(goal = it)) }, + ) + if (answers.goal == Goal.LOSE) { + ChipRow( + values = listOf(0.25, 0.5, 0.75, 1.0), + selected = answers.weeklyLossKg, + labelOf = { "$it kg / semaine" }, + onSelect = { onChange(answers.copy(weeklyLossKg = it)) }, + ) + } + + Button( + onClick = onApply, + enabled = needs != null, + modifier = Modifier.fillMaxWidth(), + ) { Text("Utiliser ces objectifs") } + + Text( + text = "Ces formules sont des estimations de population, pas une prescription. " + + "Les calories brûlées pendant une séance sont déjà comptées ici : l'objectif " + + "est celui d'une journée, sport compris.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ResultCard(needs: Needs, answers: NeedsProfile) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Objectif proposé", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = needs.target.toString(), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.ExtraBold, + ) + Text( + text = " kcal / jour", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + Text( + text = "P ${needs.macros.proteinG} g · G ${needs.macros.carbsG} g · " + + "L ${needs.macros.fatG} g", + style = MaterialTheme.typography.titleMedium, + ) + HorizontalDivider() + Detail("Métabolisme de base", "${needs.bmr} kcal") + Detail("Marche", "+ ${needs.stepsKcal} kcal") + Detail("Entraînements", "+ ${needs.trainingKcal} kcal") + Detail("Maintien", "${needs.maintenance} kcal") + if (answers.goal == Goal.LOSE && needs.weeklyDeficit > 0) { + Detail( + "Perte attendue", + "${"%.2f".format(needs.achievableWeeklyLossKg)} kg / semaine", + ) + } + if (needs.cappedByFloor) { + Text( + text = "Ce rythme demanderait de descendre sous le minimum vital. " + + "L'objectif a été ramené au plancher : tu perdras environ " + + "${"%.2f".format(needs.achievableWeeklyLossKg)} kg par semaine, " + + "pas ${answers.weeklyLossKg}.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else if (needs.tooAggressive) { + Text( + text = "Un déficit de cette taille est difficile à tenir sans carences. " + + "Un rythme plus lent se garde mieux.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +@Composable +private fun Detail(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = value, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun WeightLine(weightKg: Double, onRefresh: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text(text = "Poids", style = MaterialTheme.typography.bodyMedium) + Text( + text = "${formatWeight(weightKg)} · repris de Health Connect", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton(onClick = onRefresh) { Text("Actualiser") } + } +} + +@Composable +private fun Section(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 4.dp), + ) +} + +/** + * Champ numérique qui garde son texte à part. + * + * Reconstruire la chaîne depuis l'entier à chaque frappe renvoie le curseur au début : + * « 180 » se tapait « 801 ». Le texte saisi reste donc local, et seule la valeur + * remonte. + */ +@Composable +private fun NumberField( + label: String, + value: Int, + onValue: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + // Clé stable : une clé liée à la valeur recréerait le champ à la première frappe, + // et l'affichage divergerait de ce qui a été saisi. + var text by rememberSaveable { mutableStateOf(if (value == 0) "" else value.toString()) } + OutlinedTextField( + value = text, + onValueChange = { entry -> + text = entry.filter { it.isDigit() }.take(6) + onValue(text.toIntOrNull() ?: 0) + }, + label = { Text(label) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = modifier.fillMaxWidth(), + ) +} + +@Composable +private fun ChipRow( + values: List, + selected: T, + labelOf: (T) -> String, + onSelect: (T) -> Unit, +) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + values.forEach { value -> + FilterChip( + selected = value == selected, + onClick = { onSelect(value) }, + label = { Text(labelOf(value)) }, + ) + } + } +} diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt index 459dd6c..69f5d96 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt @@ -36,6 +36,7 @@ fun ProfileScreen( onSave: (weightKg: Double, goal: Int, macroGoals: MacroGoals) -> Unit, onOpenHealthConnect: () -> Unit, onOpenKazis: () -> Unit, + onOpenNeeds: () -> Unit, modifier: Modifier = Modifier, ) { var weight by remember(profile.weightKg) { @@ -77,6 +78,18 @@ fun ProfileScreen( style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) + Button( + onClick = onOpenNeeds, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Calculer mes objectifs") + } + Text( + text = "Quelques questions sur ta semaine, et l'objectif calorique comme les " + + "macros se calculent tout seuls.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) Text(text = "Apparence", style = MaterialTheme.typography.labelLarge) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ThemeChoice.entries.forEach { choice -> diff --git a/app/src/test/java/com/kaz229/fittrack/NeedsTest.kt b/app/src/test/java/com/kaz229/fittrack/NeedsTest.kt new file mode 100644 index 0000000..6ba45f7 --- /dev/null +++ b/app/src/test/java/com/kaz229/fittrack/NeedsTest.kt @@ -0,0 +1,166 @@ +package com.kaz229.fittrack + +import com.kaz229.fittrack.data.Goal +import com.kaz229.fittrack.data.Intensity +import com.kaz229.fittrack.data.NeedsProfile +import com.kaz229.fittrack.data.Sex +import com.kaz229.fittrack.data.computeNeeds +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Calcul des besoins journaliers à partir du questionnaire. */ +class NeedsTest { + + private val base = NeedsProfile( + heightCm = 180, + age = 30, + sex = Sex.MALE, + dailySteps = 8000, + gymPerWeek = 3, + gymMinutes = 60, + gymIntensity = Intensity.MODERATE, + runsPerWeek = 1, + runMinutes = 30, + runIntensity = Intensity.MODERATE, + goal = Goal.MAINTAIN, + ) + + @Test + fun `le metabolisme de base suit Mifflin-St Jeor`() { + val needs = computeNeeds(base, weightKg = 75.0)!! + + // 10×75 + 6,25×180 − 5×30 + 5 = 1730 + assertEquals(1730, needs.bmr) + } + + @Test + fun `une femme a un metabolisme de base plus bas a gabarit egal`() { + val homme = computeNeeds(base, 75.0)!! + val femme = computeNeeds(base.copy(sex = Sex.FEMALE), 75.0)!! + + // La formule retranche 161 chez la femme et ajoute 5 chez l'homme + assertEquals(166, homme.bmr - femme.bmr) + } + + @Test + fun `les pas et les entrainements s'ajoutent au maintien`() { + val sedentaire = computeNeeds( + base.copy(dailySteps = 0, gymPerWeek = 0, runsPerWeek = 0), + 75.0, + )!! + val actif = computeNeeds(base, 75.0)!! + + assertTrue(actif.stepsKcal > 0) + assertTrue(actif.trainingKcal > 0) + assertTrue(actif.maintenance > sedentaire.maintenance) + } + + @Test + fun `les seances sont reparties sur la semaine`() { + val troisFois = computeNeeds(base.copy(gymPerWeek = 3, runsPerWeek = 0), 75.0)!! + val sixFois = computeNeeds(base.copy(gymPerWeek = 6, runsPerWeek = 0), 75.0)!! + + // Doubler le nombre de séances double la part quotidienne, à l'arrondi près + assertEquals(troisFois.trainingKcal * 2.0, sixFois.trainingKcal.toDouble(), 1.0) + } + + @Test + fun `une intensite plus forte coute plus cher`() { + val tranquille = computeNeeds(base.copy(gymIntensity = Intensity.EASY), 75.0)!! + val intense = computeNeeds(base.copy(gymIntensity = Intensity.HARD), 75.0)!! + + assertTrue(intense.trainingKcal > tranquille.trainingKcal) + } + + @Test + fun `perdre un demi-kilo par semaine creuse un deficit de 550 kcal par jour`() { + val maintien = computeNeeds(base, 75.0)!! + val perte = computeNeeds(base.copy(goal = Goal.LOSE, weeklyLossKg = 0.5), 75.0)!! + + // 0,5 kg × 7700 kcal ÷ 7 jours = 550 + assertEquals(550, maintien.target - perte.target) + assertEquals(3850, perte.weeklyDeficit) + } + + @Test + fun `un rythme de perte trop ambitieux est signale`() { + val raisonnable = computeNeeds(base.copy(goal = Goal.LOSE, weeklyLossKg = 0.5), 75.0)!! + val excessif = computeNeeds(base.copy(goal = Goal.LOSE, weeklyLossKg = 1.5), 75.0)!! + + assertTrue(!raisonnable.tooAggressive) + assertTrue(excessif.tooAggressive) + } + + @Test + fun `un rythme borne par le plancher dit ce qui est reellement atteignable`() { + // 1,5 kg par semaine demanderait 1650 kcal de déficit : la cible bute sur le plancher + val needs = computeNeeds(base.copy(goal = Goal.LOSE, weeklyLossKg = 1.5), 75.0)!! + + assertTrue("Le plancher a borné la cible", needs.cappedByFloor) + assertTrue(needs.tooAggressive) + // Ce qui est vraiment perdu est en dessous du rythme demandé + assertTrue(needs.achievableWeeklyLossKg < 1.5) + assertEquals(0.84, needs.achievableWeeklyLossKg, 0.02) + } + + @Test + fun `l'objectif ne descend jamais sous un plancher vital`() { + // Petit gabarit, sédentaire, perte maximale : le calcul brut passerait sous 1200 + val extreme = NeedsProfile( + heightCm = 155, age = 60, sex = Sex.FEMALE, + goal = Goal.LOSE, weeklyLossKg = 1.5, + ) + + val needs = computeNeeds(extreme, 50.0)!! + + assertEquals(1200, needs.target) + } + + @Test + fun `prendre du muscle ajoute un surplus mesure`() { + val maintien = computeNeeds(base, 75.0)!! + val prise = computeNeeds(base.copy(goal = Goal.GAIN), 75.0)!! + + assertEquals(250, prise.target - maintien.target) + } + + @Test + fun `les proteines montent en perte pour garder le muscle`() { + val maintien = computeNeeds(base, 75.0)!! + val perte = computeNeeds(base.copy(goal = Goal.LOSE), 75.0)!! + + assertEquals(135, maintien.macros.proteinG) // 1,8 g/kg + assertEquals(165, perte.macros.proteinG) // 2,2 g/kg + } + + @Test + fun `les macros couvrent a peu pres l'objectif calorique`() { + val needs = computeNeeds(base, 75.0)!! + val m = needs.macros + + val fromMacros = m.proteinG * 4 + m.carbsG * 4 + m.fatG * 9 + // Les glucides absorbent le solde : l'écart tient à l'arrondi au gramme près + assertTrue("Écart de ${needs.target - fromMacros}", needs.target - fromMacros in 0..4) + } + + @Test + fun `les glucides ne deviennent jamais negatifs`() { + // Objectif très bas et poids élevé : protéines et lipides dépassent les calories + val needs = computeNeeds( + NeedsProfile(heightCm = 150, age = 70, sex = Sex.FEMALE, goal = Goal.LOSE, weeklyLossKg = 1.5), + weightKg = 120.0, + )!! + + assertTrue(needs.macros.carbsG >= 0) + assertTrue(needs.macros.fatG >= 0) + } + + @Test + fun `sans taille ni age aucun calcul n'est propose`() { + assertNull(computeNeeds(NeedsProfile(), 75.0)) + assertNull(computeNeeds(base.copy(heightCm = 0), 75.0)) + assertNull(computeNeeds(base, weightKg = 0.0)) + } +}