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)) + } +}