Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ captures/
*.apk
*.aab
*.keystore
.kotlin/
49 changes: 39 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
# FitTrack

Application Android pour **suivre ses séances de salle** et **tracker ses calories**.
100 % locale : aucune donnée ne quitte le téléphone, pas de compte, pas de serveur, pas de réseau.
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.

Une seule exception au « tout local » : quand tu scannes un code-barres, ce code-barres est envoyé à
[Open Food Facts](https://world.openfoodfacts.org) pour récupérer les valeurs nutritionnelles du
produit. Rien d'autre ne sort du téléphone — ni séances, ni repas, ni poids — et le produit reçu est
mis en cache localement pour rester consultable hors ligne.

## Fonctionnalités

Expand All @@ -13,10 +18,13 @@ Application Android pour **suivre ses séances de salle** et **tracker ses calor
- **Progression** : chaque fiche d'exercice affiche ton **record personnel** et tout l'historique
de tes séries sur cet exercice.
- **Cardio / sport** : tapis, rameur, foot… une activité et une durée suffisent.
- **Repas** : saisie des calories par type de repas.
- **Accueil** : bilan calorique du jour (ingéré / brûlé / net), volume soulevé, progression vers
l'objectif, historique des 7 derniers jours.
- **Profil** : poids et objectif calorique journalier, utilisés pour les calculs.
- **Repas au code-barres** : on scanne le produit, l'app récupère ses valeurs nutritionnelles chez
Open Food Facts, on saisit la quantité en grammes et l'apport est calculé au prorata —
calories, protéines, glucides, lipides.
- **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.

### Estimation des calories brûlées

Expand All @@ -39,6 +47,8 @@ corde à sauter 11,0 · rameur 7,0…). C'est une estimation, pas une mesure mé
| Base de données | Room (SQLite) |
| Préférences | DataStore |
| Catalogue | JSON embarqué dans les assets, chargé en mémoire au lancement |
| Scan | CameraX + ML Kit (barcode-scanning groupé, fonctionne sans Play Services) |
| Nutrition | API Open Food Facts v2 en lecture, `HttpURLConnection`, cache Room |
| Build | Gradle 8.11 + AGP 8.7, `minSdk 26`, `targetSdk 35` |

Architecture : `data` (entités Room, DAO, catalogue, repository) → `ui` (ViewModel exposant des
Expand All @@ -52,10 +62,14 @@ Prérequis : [Android Studio](https://developer.android.com/studio) (version ré
```bash
git clone https://github.com/Kaz229/fittrack-android.git
cd fittrack-android
./gradlew assembleDebug # génère app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # lance les tests unitaires
./gradlew assembleDebug # génère app/build/outputs/apk/debug/app-debug.apk
./gradlew testDebugUnitTest # tests unitaires (calculs, parsing des réponses OFF)
./gradlew connectedDebugAndroidTest # tests sur appareil : journal alimentaire de bout en bout
```

Les tests instrumentés interrogent réellement Open Food Facts. Sans connexion, ils n'échouent pas
mais tracent leur abandon dans logcat (`FoodJournalTest`).

Dans Android Studio : `File > Open`, sélectionner le dossier, puis **Run** sur un émulateur ou un
téléphone en mode développeur.

Expand All @@ -71,6 +85,8 @@ app/src/main/
├── MainActivity.kt
├── data/
│ ├── Model.kt # Session, ExerciseSet, Meal, bilans
│ ├── Nutrition.kt # FoodProduct, portions, macros et objectifs
│ ├── OpenFoodFacts.kt # client de lecture de l'API + parsing
│ ├── Exercise.kt # exercice du catalogue + libellés FR
│ ├── ExerciseCatalog.kt # chargement, recherche, filtres
│ ├── Activities.kt # activités cardio (MET) et calcul des calories
Expand All @@ -83,7 +99,7 @@ app/src/main/
├── FitTrackViewModel.kt
├── Format.kt # affichage des poids, volumes et dates
├── theme/Theme.kt
└── screens/ # Accueil, Séances, Séance, Exercices, Fiche, Repas, Profil
└── screens/ # Accueil, Séances, Séance, Exercices, Fiche, Repas, Scan, Profil
```

## Données des exercices
Expand All @@ -98,10 +114,23 @@ ne sont pas redistribuables sans licence propre : ils ne sont donc **pas** embar
illustrer les exercices, il faudra soit obtenir une licence auprès de Gym visual, soit utiliser des
médias libres.

## Données nutritionnelles

Les produits scannés viennent d'[Open Food Facts](https://world.openfoodfacts.org), base
alimentaire ouverte et collaborative. Ses données sont publiées sous licence
[ODbL](https://opendatacommons.org/licenses/odbl/) ; elles sont renseignées par des contributeurs
et peuvent être incomplètes ou erronées.

L'API est utilisée en lecture seule, sans authentification, avec un User-Agent identifiant l'app
comme sa politique d'usage l'exige. Elle limite les lectures à **15 requêtes par minute et par IP** :
le cache local des produits déjà scannés sert autant à respecter cette limite qu'à fonctionner
hors ligne.

## Idées pour la suite

- Modèles de séances récurrentes (Push / Pull / Legs) à charger en un tap.
- Chronomètre de repos entre les séries.
- Graphiques de progression par exercice et courbe de poids.
- Scan de code-barres pour les aliments (Open Food Facts).
- Réutiliser un produit déjà scanné sans rescanner (liste des aliments récents).
- Typage des séries (échauffement, échec, drop-set) pour un volume plus juste.
- Export / import CSV et intégration Health Connect.
11 changes: 11 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ dependencies {

implementation(libs.androidx.datastore.preferences)

implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.mlkit.barcode.scanning)

testImplementation(libs.junit)
// org.json n'est qu'un stub dans android.jar : la vraie implémentation permet de
// tester le parsing des réponses Open Food Facts en test unitaire JVM.
testImplementation(libs.org.json)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.kotlinx.coroutines.test)
}
175 changes: 175 additions & 0 deletions app/src/androidTest/java/com/kaz229/fittrack/FoodJournalTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package com.kaz229.fittrack

import android.util.Log
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kaz229.fittrack.data.ExerciseCatalog
import com.kaz229.fittrack.data.FitTrackDatabase
import com.kaz229.fittrack.data.FitTrackRepository
import com.kaz229.fittrack.data.FoodLookup
import com.kaz229.fittrack.data.MealType
import com.kaz229.fittrack.data.OpenFoodFactsClient
import com.kaz229.fittrack.data.UserPreferences
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.LocalDate

/**
* Parcours complet du journal alimentaire, sur appareil : recherche d'un code-barres
* chez Open Food Facts, mise en cache, enregistrement de la portion, agrégation des
* macros du jour.
*
* Le test touche le vrai réseau : c'est délibéré, c'est le seul moyen de vérifier que
* le contrat de l'API tient toujours. Une coupure de connexion ne fait pas échouer le
* build, mais l'abandon est tracé dans logcat (voir [skipOffline]) — sans quoi un test
* vert pourrait masquer une API cassée.
*/
@RunWith(AndroidJUnit4::class)
class FoodJournalTest {

private lateinit var db: FitTrackDatabase
private lateinit var repository: FitTrackRepository
private val today = LocalDate.of(2026, 8, 20)

/** Nutella : produit stable et très complété, bon témoin pour l'API. */
private val nutellaBarcode = "3017620422003"

/**
* Signale dans logcat qu'un test réseau a été ignoré. Un test vert qui a en réalité
* sauté sa vérification ne prouve rien : il faut pouvoir le constater.
*/
private fun skipOffline(test: String) {
Log.w(TAG, "IGNORÉ ($test) : Open Food Facts injoignable depuis cet appareil.")
}

@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
db = Room.inMemoryDatabaseBuilder(context, FitTrackDatabase::class.java).build()
repository = FitTrackRepository(
sessionDao = db.sessionDao(),
exerciseSetDao = db.exerciseSetDao(),
mealDao = db.mealDao(),
foodProductDao = db.foodProductDao(),
userPreferences = UserPreferences(context),
exercises = ExerciseCatalog.get(context),
openFoodFacts = OpenFoodFactsClient(),
)
}

@After
fun tearDown() {
db.close()
}

@Test
fun un_code_barres_donne_un_produit_puis_vient_du_cache() = runTest {
val first = repository.lookupFood(nutellaBarcode, today)
if (first is FoodLookup.NetworkError) {
skipOffline("lookup + cache")
return@runTest
}
Log.i(TAG, "Réseau OK : $first")

assertTrue("Attendu un produit, obtenu $first", first is FoodLookup.Found)
val found = first as FoodLookup.Found
assertFalse("Premier appel : doit venir du réseau", found.fromCache)
assertTrue("Les kcal doivent être renseignées", found.product.kcalPer100g > 0)
assertTrue(
"Les protéines doivent être renseignées",
found.product.proteinPer100g > 0,
)

// Deuxième appel : le produit doit sortir du cache local, sans requête réseau.
val second = repository.lookupFood(nutellaBarcode, today)
assertTrue(second is FoodLookup.Found)
assertTrue("Second appel : doit venir du cache", (second as FoodLookup.Found).fromCache)
assertEquals(found.product.barcode, second.product.barcode)
}

@Test
fun un_produit_scanne_alimente_le_journal_et_les_macros_du_jour() = runTest {
val lookup = repository.lookupFood(nutellaBarcode, today)
if (lookup !is FoodLookup.Found) {
skipOffline("journal + macros")
return@runTest
}
val product = lookup.product

repository.addFoodMeal(product, grams = 30.0, mealType = MealType.BREAKFAST, date = today)

val meals = repository.mealsForDay(today).first()
assertEquals(1, meals.size)
val meal = meals.single()
assertEquals(nutellaBarcode, meal.barcode)
assertEquals(30.0, meal.grams!!, 0.001)
assertTrue("La ligne doit porter des macros", meal.hasMacros)

// Les macros enregistrées valent bien 30 % des valeurs pour 100 g.
val expected = product.proteinPer100g * 0.3
assertEquals(expected, meal.proteinG, 0.15)

val macros = repository.macrosForDay(today).first()
assertEquals(meal.proteinG, macros.proteinG, 0.001)
assertEquals(meal.carbsG, macros.carbsG, 0.001)
assertEquals(meal.fatG, macros.fatG, 0.001)

// Les calories du jour tiennent compte de la ligne ajoutée.
val summary = repository.summaryForDay(today).first()
assertEquals(meal.calories, summary.caloriesIn)
}

@Test
fun un_code_barres_inexistant_ne_casse_rien() = runTest {
val result = repository.lookupFood("0000000000000", today)
// Selon l'humeur d'OFF : produit absent, ou réponse sans nutriments. Jamais un plantage.
assertTrue(
"Attendu NotFound / NoNutrition / NetworkError, obtenu $result",
result is FoodLookup.NotFound ||
result is FoodLookup.NoNutrition ||
result is FoodLookup.NetworkError,
)
}

@Test
fun une_saisie_libre_sans_macros_reste_enregistrable() = runTest {
repository.addMeal(
name = "Poulet basquaise maison",
calories = 620,
mealType = MealType.DINNER,
date = today,
)

val meal = repository.mealsForDay(today).first().single()
assertEquals(620, meal.calories)
assertFalse("Sans macros saisies, hasMacros doit être faux", meal.hasMacros)
assertNotNull(meal.name)

val macros = repository.macrosForDay(today).first()
assertEquals(0.0, macros.proteinG, 0.001)
}

@Test
fun les_macros_de_plusieurs_lignes_s_additionnent() = runTest {
repository.addMeal("Riz", 350, MealType.LUNCH, 7.0, 77.0, 0.6, today)
repository.addMeal("Blanc de poulet", 165, MealType.LUNCH, 31.0, 0.0, 3.6, today)

val macros = repository.macrosForDay(today).first()
assertEquals(38.0, macros.proteinG, 0.001)
assertEquals(77.0, macros.carbsG, 0.001)
assertEquals(4.2, macros.fatG, 0.001)
}

private companion object {
const val TAG = "FoodJournalTest"
}
}
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<!-- Scan des codes-barres alimentaires. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Interrogation d'Open Food Facts : seul le code-barres scanné sort du téléphone. -->
<uses-permission android:name="android.permission.INTERNET" />

<uses-feature
android:name="android.hardware.camera.any"
android:required="false" />

<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
Expand Down
20 changes: 20 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 @@ -3,6 +3,7 @@ package com.kaz229.fittrack.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -89,4 +90,23 @@ interface MealDao {
"WHERE epochDay >= :fromDay GROUP BY epochDay",
)
fun observeDailyTotals(fromDay: Long): Flow<List<DayTotal>>

/** Somme des macros de la journée, en grammes. */
@Query(
"SELECT COALESCE(SUM(proteinG), 0.0) AS proteinG, " +
"COALESCE(SUM(carbsG), 0.0) AS carbsG, " +
"COALESCE(SUM(fatG), 0.0) AS fatG " +
"FROM meals WHERE epochDay = :epochDay",
)
fun observeMacrosForDay(epochDay: Long): Flow<MacroTotals>
}

/** Cache local des produits Open Food Facts déjà scannés. */
@Dao
interface FoodProductDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(product: FoodProduct)

@Query("SELECT * FROM food_products WHERE barcode = :barcode")
suspend fun findByBarcode(barcode: String): FoodProduct?
}
Loading
Loading