Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/kaz229/fittrack/data/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
33 changes: 33 additions & 0 deletions app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,33 @@ class FitTrackRepository(

val theme: Flow<ThemeChoice> = userPreferences.theme

val needsProfile: Flow<NeedsProfile> = 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 -------------------------------------------------------------
Expand Down Expand Up @@ -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(
Expand Down
170 changes: 170 additions & 0 deletions app/src/main/java/com/kaz229/fittrack/data/Needs.kt
Original file line number Diff line number Diff line change
@@ -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)
}
47 changes: 47 additions & 0 deletions app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserProfile> = context.dataStore.data.map { prefs ->
Expand Down Expand Up @@ -113,6 +125,41 @@ class UserPreferences(private val context: Context) {

val theme: Flow<ThemeChoice> = context.dataStore.data.map { ThemeChoice.byId(it[themeKey]) }

/** Les réponses du questionnaire, d'où sortent l'objectif et les macros. */
val needsProfile: Flow<NeedsProfile> = 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 }
}
Expand Down
Loading
Loading