diff --git a/.gitignore b/.gitignore index af52d23..f6d06b2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ captures/ *.apk *.aab *.keystore +.kotlin/ diff --git a/README.md b/README.md index 1856144..fc7110c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 @@ -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 @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0dbb95e..c158f31 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/src/androidTest/java/com/kaz229/fittrack/FoodJournalTest.kt b/app/src/androidTest/java/com/kaz229/fittrack/FoodJournalTest.kt new file mode 100644 index 0000000..ad98d89 --- /dev/null +++ b/app/src/androidTest/java/com/kaz229/fittrack/FoodJournalTest.kt @@ -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() + 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" + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 55b1913..00e4c0b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,15 @@ + + + + + + + = :fromDay GROUP BY epochDay", ) fun observeDailyTotals(fromDay: Long): Flow> + + /** 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 +} + +/** 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? } diff --git a/app/src/main/java/com/kaz229/fittrack/data/FitTrackDatabase.kt b/app/src/main/java/com/kaz229/fittrack/data/FitTrackDatabase.kt index 80aed7b..2b736ca 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackDatabase.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackDatabase.kt @@ -4,27 +4,58 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase @Database( - entities = [Session::class, ExerciseSet::class, Meal::class], - version = 1, + entities = [Session::class, ExerciseSet::class, Meal::class, FoodProduct::class], + version = 2, exportSchema = false, ) abstract class FitTrackDatabase : RoomDatabase() { abstract fun sessionDao(): SessionDao abstract fun exerciseSetDao(): ExerciseSetDao abstract fun mealDao(): MealDao + abstract fun foodProductDao(): FoodProductDao companion object { @Volatile private var instance: FitTrackDatabase? = null + /** + * v1 → v2 : arrivée des macronutriments et du cache Open Food Facts. + * + * Migration explicite et non destructive : les repas déjà saisis gardent leurs + * calories et se retrouvent simplement avec des macros à zéro. + */ + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE meals ADD COLUMN proteinG REAL NOT NULL DEFAULT 0.0") + db.execSQL("ALTER TABLE meals ADD COLUMN carbsG REAL NOT NULL DEFAULT 0.0") + db.execSQL("ALTER TABLE meals ADD COLUMN fatG REAL NOT NULL DEFAULT 0.0") + db.execSQL("ALTER TABLE meals ADD COLUMN barcode TEXT") + db.execSQL("ALTER TABLE meals ADD COLUMN grams REAL") + db.execSQL( + "CREATE TABLE IF NOT EXISTS food_products (" + + "barcode TEXT NOT NULL PRIMARY KEY, " + + "name TEXT NOT NULL, " + + "brands TEXT NOT NULL, " + + "kcalPer100g REAL NOT NULL, " + + "proteinPer100g REAL NOT NULL, " + + "carbsPer100g REAL NOT NULL, " + + "fatPer100g REAL NOT NULL, " + + "servingSizeG REAL, " + + "fetchedAtEpochDay INTEGER NOT NULL)", + ) + } + } + fun get(context: Context): FitTrackDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, FitTrackDatabase::class.java, "fittrack.db", - ).build().also { instance = it } + ).addMigrations(MIGRATION_1_2).build().also { instance = it } } } } 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 c0decd6..fc1ce9e 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -15,8 +15,10 @@ class FitTrackRepository( private val sessionDao: SessionDao, private val exerciseSetDao: ExerciseSetDao, private val mealDao: MealDao, + private val foodProductDao: FoodProductDao, private val userPreferences: UserPreferences, val exercises: ExerciseCatalog, + private val openFoodFacts: OpenFoodFactsClient = OpenFoodFactsClient(), ) { val profile: Flow = userPreferences.profile @@ -121,10 +123,14 @@ class FitTrackRepository( fun mealsForDay(date: LocalDate): Flow> = mealDao.observeForDay(date.toEpochDay()) + /** Saisie libre : plat maison ou produit absent d'Open Food Facts. Macros optionnelles. */ suspend fun addMeal( name: String, calories: Int, mealType: MealType, + proteinG: Double = 0.0, + carbsG: Double = 0.0, + fatG: Double = 0.0, date: LocalDate = LocalDate.now(), ) { mealDao.insert( @@ -133,14 +139,64 @@ class FitTrackRepository( calories = calories, mealType = mealType.id, epochDay = date.toEpochDay(), + proteinG = proteinG, + carbsG = carbsG, + fatG = fatG, ), ) } suspend fun deleteMeal(meal: Meal) = mealDao.delete(meal) + // --- Aliments scannés ---------------------------------------------------- + + /** + * Cherche un code-barres : cache local d'abord, réseau ensuite. + * + * Le cache évite de retomber sur la limite d'Open Food Facts (15 requêtes par + * minute) et rend un produit déjà scanné utilisable sans réseau. + */ + suspend fun lookupFood(barcode: String, today: LocalDate = LocalDate.now()): FoodLookup { + foodProductDao.findByBarcode(barcode)?.let { + return FoodLookup.Found(it, fromCache = true) + } + val result = openFoodFacts.fetch(barcode) + if (result is FoodLookup.Found) { + val product = result.product.copy(fetchedAtEpochDay = today.toEpochDay()) + foodProductDao.upsert(product) + return FoodLookup.Found(product, fromCache = false) + } + return result + } + + /** Enregistre une portion pesée d'un produit scanné, macros calculées au prorata. */ + suspend fun addFoodMeal( + product: FoodProduct, + grams: Double, + mealType: MealType, + date: LocalDate = LocalDate.now(), + ) { + val portion = product.portion(grams) + mealDao.insert( + Meal( + name = product.displayName, + calories = portion.calories, + mealType = mealType.id, + epochDay = date.toEpochDay(), + proteinG = portion.proteinG, + carbsG = portion.carbsG, + fatG = portion.fatG, + barcode = product.barcode, + grams = grams, + ), + ) + } + // --- Bilans -------------------------------------------------------------- + fun macrosForDay(date: LocalDate): Flow = + mealDao.observeMacrosForDay(date.toEpochDay()) + fun summaryForDay(date: LocalDate): Flow { val day = date.toEpochDay() return combine( @@ -177,6 +233,8 @@ class FitTrackRepository( suspend fun setDailyCalorieGoal(goal: Int) = userPreferences.setDailyCalorieGoal(goal) + suspend fun setMacroGoals(goals: MacroGoals) = userPreferences.setMacroGoals(goals) + companion object { @Volatile private var instance: FitTrackRepository? = null @@ -188,6 +246,7 @@ class FitTrackRepository( sessionDao = db.sessionDao(), exerciseSetDao = db.exerciseSetDao(), mealDao = db.mealDao(), + foodProductDao = db.foodProductDao(), userPreferences = UserPreferences(context.applicationContext), exercises = ExerciseCatalog.get(context), ).also { instance = it } diff --git a/app/src/main/java/com/kaz229/fittrack/data/Model.kt b/app/src/main/java/com/kaz229/fittrack/data/Model.kt index 7cdfa74..394fd34 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Model.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Model.kt @@ -63,7 +63,14 @@ data class ExerciseSet( val volume: Double get() = reps * weightKg } -/** Un repas ou une collation, avec les calories ingérées. */ +/** + * Une ligne du journal alimentaire : un aliment ou un plat, ses calories et ses macros. + * + * Un « repas » au sens courant est l'ensemble des lignes qui partagent le même + * [mealType] sur la même journée — pas besoin d'une table de regroupement. + * [barcode] et [grams] ne sont renseignés que pour un produit scanné ; une saisie + * libre les laisse à null et peut n'avoir que des calories. + */ @Entity(tableName = "meals") data class Meal( @PrimaryKey(autoGenerate = true) val id: Long = 0, @@ -71,7 +78,15 @@ data class Meal( val calories: Int, val mealType: String, val epochDay: Long, -) + val proteinG: Double = 0.0, + val carbsG: Double = 0.0, + val fatG: Double = 0.0, + val barcode: String? = null, + val grams: Double? = null, +) { + /** Vrai dès qu'au moins une macro est connue : sert à ne pas afficher « 0 g » partout. */ + val hasMacros: Boolean get() = proteinG > 0 || carbsG > 0 || fatG > 0 +} /** Bilan d'une journée : ingéré, brûlé, et solde net. */ data class DailySummary( diff --git a/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt b/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt new file mode 100644 index 0000000..0d5aff6 --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt @@ -0,0 +1,91 @@ +package com.kaz229.fittrack.data + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlin.math.roundToInt + +/** + * Un produit alimentaire tel qu'Open Food Facts le décrit, mis en cache localement. + * + * Les valeurs nutritionnelles sont toujours ramenées à 100 g : c'est l'unité de + * référence d'OFF, et la seule qui permette de calculer une portion arbitraire. + * Le cache existe pour deux raisons : l'API est limitée à 15 requêtes par minute, + * et un produit déjà scanné doit rester consultable sans réseau. + */ +@Entity(tableName = "food_products") +data class FoodProduct( + @PrimaryKey val barcode: String, + val name: String, + val brands: String = "", + val kcalPer100g: Double, + val proteinPer100g: Double, + val carbsPer100g: Double, + val fatPer100g: Double, + /** Poids d'une portion telle qu'indiquée sur l'emballage, quand OFF la connaît. */ + val servingSizeG: Double? = null, + val fetchedAtEpochDay: Long = 0, +) { + /** « Nutella — Ferrero », ou juste le nom quand la marque est inconnue. */ + val displayName: String get() = if (brands.isBlank()) name else "$name — $brands" +} + +/** Les valeurs d'une portion : ce qui finit réellement enregistré dans le journal. */ +data class Portion( + val calories: Int, + val proteinG: Double, + val carbsG: Double, + val fatG: Double, +) + +/** Calcule au prorata ce qu'apportent [grams] grammes de ce produit. */ +fun FoodProduct.portion(grams: Double): Portion { + val ratio = grams / 100.0 + return Portion( + calories = (kcalPer100g * ratio).roundToInt(), + proteinG = round1(proteinPer100g * ratio), + carbsG = round1(carbsPer100g * ratio), + fatG = round1(fatPer100g * ratio), + ) +} + +/** Somme des macros d'une journée, en grammes. */ +data class MacroTotals( + val proteinG: Double = 0.0, + val carbsG: Double = 0.0, + val fatG: Double = 0.0, +) { + /** + * Calories reconstituées depuis les macros (4 kcal par gramme de protéines et de + * glucides, 9 pour les lipides). Sert de repère : l'écart avec les calories + * déclarées vient des fibres, du sucre-alcool et des arrondis de l'étiquetage. + */ + val caloriesFromMacros: Int get() = (proteinG * 4 + carbsG * 4 + fatG * 9).roundToInt() +} + +/** Objectifs journaliers de macros, en grammes. */ +data class MacroGoals( + val proteinG: Int = DEFAULT_PROTEIN_G, + val carbsG: Int = DEFAULT_CARBS_G, + val fatG: Int = DEFAULT_FAT_G, +) { + companion object { + const val DEFAULT_PROTEIN_G = 150 + const val DEFAULT_CARBS_G = 250 + const val DEFAULT_FAT_G = 70 + } +} + +/** + * Extrait un poids en grammes d'un `serving_size` d'Open Food Facts, champ libre + * rempli à la main par les contributeurs : « 30 g », « 2 biscuits (25g) », « 100ml ». + * Renvoie null quand aucun nombre suivi de g/ml n'est identifiable — on demandera + * alors le poids à l'utilisateur. + */ +fun parseServingGrams(raw: String?): Double? { + if (raw.isNullOrBlank()) return null + val match = Regex("""(\d+(?:[.,]\d+)?)\s*(g|ml)\b""", RegexOption.IGNORE_CASE).find(raw) + val value = match?.groupValues?.get(1)?.replace(',', '.')?.toDoubleOrNull() ?: return null + return value.takeIf { it > 0 } +} + +private fun round1(value: Double): Double = (value * 10).roundToInt() / 10.0 diff --git a/app/src/main/java/com/kaz229/fittrack/data/OpenFoodFacts.kt b/app/src/main/java/com/kaz229/fittrack/data/OpenFoodFacts.kt new file mode 100644 index 0000000..fc4f36a --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/data/OpenFoodFacts.kt @@ -0,0 +1,115 @@ +package com.kaz229.fittrack.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +/** Résultat d'une recherche de code-barres, pour que l'écran sache quoi afficher. */ +sealed interface FoodLookup { + /** Produit trouvé. [fromCache] distingue un scan hors ligne d'un appel réseau. */ + data class Found(val product: FoodProduct, val fromCache: Boolean) : FoodLookup + + /** Code-barres absent de la base : à l'utilisateur de saisir les valeurs. */ + data object NotFound : FoodLookup + + /** Pas de réseau, serveur en erreur, ou quota de 15 requêtes/min dépassé. */ + data object NetworkError : FoodLookup + + /** Produit connu d'OFF mais sans valeurs nutritionnelles exploitables. */ + data object NoNutrition : FoodLookup +} + +/** + * Client de lecture d'Open Food Facts. + * + * L'API est publique et sans authentification, mais impose un User-Agent identifiant + * l'application et limite les lectures à 15 requêtes par minute et par IP — d'où le + * cache local en amont (voir [FitTrackRepository.lookupFood]). Un seul appel HTTP, + * donc `HttpURLConnection` suffit : pas de client HTTP tiers à embarquer. + * + * Seul le code-barres quitte le téléphone. Aucune donnée de séance, de repas ou de + * profil n'est transmise. + */ +class OpenFoodFactsClient(private val baseUrl: String = BASE_URL) { + + suspend fun fetch(barcode: String): FoodLookup = withContext(Dispatchers.IO) { + val url = "$baseUrl/api/v2/product/$barcode.json?fields=$FIELDS" + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("User-Agent", USER_AGENT) + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + } + try { + when (connection.responseCode) { + HttpURLConnection.HTTP_OK -> { + val body = connection.inputStream.bufferedReader().use { it.readText() } + parseProduct(barcode, body) + } + HttpURLConnection.HTTP_NOT_FOUND -> FoodLookup.NotFound + else -> FoodLookup.NetworkError + } + } catch (_: Exception) { + // Pas de réseau, DNS muet, timeout, JSON illisible : même conséquence pour + // l'utilisateur, il saisira le produit à la main. + FoodLookup.NetworkError + } finally { + connection.disconnect() + } + } + + companion object { + private const val BASE_URL = "https://world.openfoodfacts.org" + private const val TIMEOUT_MS = 8_000 + private const val FIELDS = "code,product_name,product_name_fr,brands,serving_size,nutriments" + + /** Format imposé par la politique d'usage d'OFF : nom, version, contact. */ + private const val USER_AGENT = "FitTrack/0.1.0 (github.com/Kaz229/fittrack-android)" + + /** + * Transforme une réponse `/api/v2/product` en [FoodProduct]. + * + * Fonction pure, testable sans réseau. Le nom français est préféré quand il + * existe ; les macros sont lues dans les champs `*_100g`, seuls comparables + * entre produits. + */ + fun parseProduct(barcode: String, json: String): FoodLookup { + val root = JSONObject(json) + if (root.optInt("status", 1) == 0) return FoodLookup.NotFound + val product = root.optJSONObject("product") ?: return FoodLookup.NotFound + val nutriments = product.optJSONObject("nutriments") ?: return FoodLookup.NoNutrition + + val kcal = nutriments.optDoubleOrNull("energy-kcal_100g") + // Certains produits ne renseignent que les kilojoules. + ?: nutriments.optDoubleOrNull("energy-kj_100g")?.let { it / 4.184 } + ?: return FoodLookup.NoNutrition + + val name = product.optStringOrNull("product_name_fr") + ?: product.optStringOrNull("product_name") + ?: return FoodLookup.NoNutrition + + return FoodLookup.Found( + product = FoodProduct( + barcode = barcode, + name = name, + brands = product.optStringOrNull("brands")?.substringBefore(',')?.trim() ?: "", + kcalPer100g = kcal, + proteinPer100g = nutriments.optDoubleOrNull("proteins_100g") ?: 0.0, + carbsPer100g = nutriments.optDoubleOrNull("carbohydrates_100g") ?: 0.0, + fatPer100g = nutriments.optDoubleOrNull("fat_100g") ?: 0.0, + servingSizeG = parseServingGrams(product.optStringOrNull("serving_size")), + ), + fromCache = false, + ) + } + + /** `optDouble` renvoie NaN pour un champ absent : on préfère un null explicite. */ + private fun JSONObject.optDoubleOrNull(key: String): Double? = + optDouble(key).takeIf { !it.isNaN() } + + private fun JSONObject.optStringOrNull(key: String): String? = + optString(key).takeIf { it.isNotBlank() } + } +} 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 cdfb2ad..a511bea 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt @@ -13,10 +13,14 @@ private val Context.dataStore: androidx.datastore.core.DataStore by name = "fittrack_prefs", ) -/** Profil utilisateur : poids (pour l'estimation calorique) et objectif journalier. */ +/** + * Profil utilisateur : poids (pour l'estimation calorique), objectif calorique + * journalier et objectifs de macros. + */ data class UserProfile( val weightKg: Double = DEFAULT_WEIGHT_KG, val dailyCalorieGoal: Int = DEFAULT_GOAL, + val macroGoals: MacroGoals = MacroGoals(), ) { companion object { const val DEFAULT_WEIGHT_KG = 75.0 @@ -28,11 +32,19 @@ class UserPreferences(private val context: Context) { private val weightKey = doublePreferencesKey("weight_kg") private val goalKey = intPreferencesKey("daily_calorie_goal") + private val proteinGoalKey = intPreferencesKey("protein_goal_g") + private val carbsGoalKey = intPreferencesKey("carbs_goal_g") + private val fatGoalKey = intPreferencesKey("fat_goal_g") val profile: Flow = context.dataStore.data.map { prefs -> UserProfile( weightKg = prefs[weightKey] ?: UserProfile.DEFAULT_WEIGHT_KG, dailyCalorieGoal = prefs[goalKey] ?: UserProfile.DEFAULT_GOAL, + macroGoals = MacroGoals( + proteinG = prefs[proteinGoalKey] ?: MacroGoals.DEFAULT_PROTEIN_G, + carbsG = prefs[carbsGoalKey] ?: MacroGoals.DEFAULT_CARBS_G, + fatG = prefs[fatGoalKey] ?: MacroGoals.DEFAULT_FAT_G, + ), ) } @@ -43,4 +55,12 @@ class UserPreferences(private val context: Context) { suspend fun setDailyCalorieGoal(goal: Int) { context.dataStore.edit { it[goalKey] = goal } } + + suspend fun setMacroGoals(goals: MacroGoals) { + context.dataStore.edit { + it[proteinGoalKey] = goals.proteinG + it[carbsGoalKey] = goals.carbsG + it[fatGoalKey] = goals.fatG + } + } } 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 3681544..18ab6f9 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.FitnessCenter import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.MenuBook import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Restaurant import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton @@ -48,6 +49,7 @@ import com.kaz229.fittrack.ui.screens.ExercisesScreen import com.kaz229.fittrack.ui.screens.HomeScreen import com.kaz229.fittrack.ui.screens.MealsScreen import com.kaz229.fittrack.ui.screens.ProfileScreen +import com.kaz229.fittrack.ui.screens.ScanScreen import com.kaz229.fittrack.ui.screens.SessionDetailScreen import com.kaz229.fittrack.ui.screens.SessionsScreen @@ -66,6 +68,7 @@ private enum class Tab( private const val SESSION_DETAIL = "session" private const val EXERCISE_DETAIL = "exercise" private const val PROFILE = "profile" +private const val SCAN = "scan" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -87,6 +90,8 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { val setCountToday by viewModel.todaySetCount.collectAsStateWithLifecycle() val sessions by viewModel.sessions.collectAsStateWithLifecycle() val meals by viewModel.todayMeals.collectAsStateWithLifecycle() + val macros by viewModel.todayMacros.collectAsStateWithLifecycle() + val scanState by viewModel.scan.collectAsStateWithLifecycle() val catalog by viewModel.catalog.collectAsStateWithLifecycle() val exercisesById by viewModel.exercisesById.collectAsStateWithLifecycle() val openSession by viewModel.openSession.collectAsStateWithLifecycle() @@ -98,6 +103,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { SESSION_DETAIL -> "Séance" EXERCISE_DETAIL -> "Exercice" PROFILE -> "Profil" + SCAN -> "Scanner un produit" else -> currentTab?.label ?: "FitTrack" } @@ -158,8 +164,21 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { text = { Text("Séance salle") }, ) } - Tab.MEALS -> FloatingActionButton(onClick = { showMealDialog = true }) { - Icon(Icons.Filled.Add, contentDescription = "Ajouter un repas") + Tab.MEALS -> Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SmallFloatingActionButton(onClick = { showMealDialog = true }) { + Icon(Icons.Filled.Add, contentDescription = "Saisir un aliment à la main") + } + ExtendedFloatingActionButton( + onClick = { + viewModel.resetScan() + navController.navigate(SCAN) + }, + icon = { Icon(Icons.Filled.QrCodeScanner, contentDescription = null) }, + text = { Text("Scanner") }, + ) } else -> Unit } @@ -174,6 +193,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { HomeScreen( summary = summary, profile = profile, + macros = macros, lastWeek = lastWeek, volumeToday = volumeToday, setCountToday = setCountToday, @@ -202,7 +222,27 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { ) } composable(Tab.MEALS.route) { - MealsScreen(meals = meals, onDelete = viewModel::deleteMeal) + MealsScreen( + meals = meals, + macros = macros, + macroGoals = profile.macroGoals, + onDelete = viewModel::deleteMeal, + ) + } + composable(SCAN) { + ScanScreen( + state = scanState, + onBarcode = viewModel::onBarcodeScanned, + onConfirm = { grams, mealType -> + viewModel.addScannedFood(grams, mealType) + navController.popBackStack() + }, + onRetry = viewModel::resetScan, + onManualEntry = { + navController.popBackStack() + showMealDialog = true + }, + ) } composable(SESSION_DETAIL) { SessionDetailScreen( @@ -227,9 +267,10 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { composable(PROFILE) { ProfileScreen( profile = profile, - onSave = { weight, goal -> + onSave = { weight, goal, macroGoals -> viewModel.setWeight(weight) viewModel.setDailyCalorieGoal(goal) + viewModel.setMacroGoals(macroGoals) }, ) } @@ -248,8 +289,8 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { if (showMealDialog) { AddMealDialog( onDismiss = { showMealDialog = false }, - onConfirm = { name, calories, mealType -> - viewModel.addMeal(name, calories, mealType) + onConfirm = { name, calories, mealType, protein, carbs, fat -> + viewModel.addMeal(name, calories, mealType, protein, carbs, fat) showMealDialog = false }, ) 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 9dedd77..13890c0 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt @@ -9,7 +9,11 @@ import com.kaz229.fittrack.data.ExerciseBlock import com.kaz229.fittrack.data.ExerciseCatalog import com.kaz229.fittrack.data.ExerciseSet import com.kaz229.fittrack.data.FitTrackRepository +import com.kaz229.fittrack.data.FoodLookup +import com.kaz229.fittrack.data.FoodProduct import com.kaz229.fittrack.data.Labels +import com.kaz229.fittrack.data.MacroGoals +import com.kaz229.fittrack.data.MacroTotals import com.kaz229.fittrack.data.Meal import com.kaz229.fittrack.data.MealType import com.kaz229.fittrack.data.Session @@ -37,6 +41,24 @@ data class CatalogState( val loading: Boolean = true, ) +/** Pourquoi un code-barres scanné n'a rien donné. */ +enum class ScanError { NOT_FOUND, NO_NUTRITION, NETWORK } + +/** + * État de l'écran de scan. Tant que [product] est null et qu'aucune [error] n'est + * remontée, la caméra reste active et cherche un code-barres. + */ +data class ScanState( + val searching: Boolean = false, + val barcode: String? = null, + val product: FoodProduct? = null, + val fromCache: Boolean = false, + val error: ScanError? = null, +) { + /** La caméra doit s'arrêter dès qu'un code a été capté, succès ou échec. */ + val cameraActive: Boolean get() = !searching && product == null && error == null +} + /** La fiche d'un exercice : sa description et ce que l'utilisateur y a déjà fait. */ data class ExerciseDetail( val exercise: Exercise, @@ -142,6 +164,44 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application openSessionId.value?.let { repository.finishGymSession(it, durationMinutes, note) } } + // --- Scan d'un code-barres alimentaire ----------------------------------- + + private val _scan = MutableStateFlow(ScanState()) + val scan: StateFlow = _scan + + /** + * Appelé par l'analyseur d'images à chaque code-barres reconnu. La caméra en + * envoie plusieurs par seconde : on ignore tout ce qui arrive dès qu'une + * recherche est lancée, sinon on épuiserait le quota d'Open Food Facts. + */ + fun onBarcodeScanned(barcode: String) { + if (!_scan.value.cameraActive) return + _scan.value = ScanState(searching = true, barcode = barcode) + viewModelScope.launch { + _scan.value = when (val result = repository.lookupFood(barcode, today)) { + is FoodLookup.Found -> ScanState( + barcode = barcode, + product = result.product, + fromCache = result.fromCache, + ) + FoodLookup.NotFound -> ScanState(barcode = barcode, error = ScanError.NOT_FOUND) + FoodLookup.NoNutrition -> + ScanState(barcode = barcode, error = ScanError.NO_NUTRITION) + FoodLookup.NetworkError -> ScanState(barcode = barcode, error = ScanError.NETWORK) + } + } + } + + /** Remet la caméra en recherche : nouveau scan, ou abandon de l'écran. */ + fun resetScan() { _scan.value = ScanState() } + + /** Enregistre la portion pesée du produit scanné, puis libère l'écran de scan. */ + fun addScannedFood(grams: Double, mealType: MealType) = viewModelScope.launch { + val product = _scan.value.product ?: return@launch + repository.addFoodMeal(product, grams, mealType, today) + _scan.value = ScanState() + } + // --- Séances, repas, bilans --------------------------------------------- val profile: StateFlow = repository.profile @@ -169,6 +229,9 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application val todayMeals: StateFlow> = repository.mealsForDay(today) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val todayMacros: StateFlow = repository.macrosForDay(today) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MacroTotals()) + fun addCardioSession(activityId: String, durationMinutes: Int, note: String) = viewModelScope.launch { repository.addCardioSession(activityId, durationMinutes, note, today) @@ -178,8 +241,15 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application repository.deleteSession(session) } - fun addMeal(name: String, calories: Int, mealType: MealType) = viewModelScope.launch { - repository.addMeal(name, calories, mealType, today) + fun addMeal( + name: String, + calories: Int, + mealType: MealType, + proteinG: Double = 0.0, + carbsG: Double = 0.0, + fatG: Double = 0.0, + ) = viewModelScope.launch { + repository.addMeal(name, calories, mealType, proteinG, carbsG, fatG, today) } fun deleteMeal(meal: Meal) = viewModelScope.launch { repository.deleteMeal(meal) } @@ -189,4 +259,8 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application fun setDailyCalorieGoal(goal: Int) = viewModelScope.launch { repository.setDailyCalorieGoal(goal) } + + fun setMacroGoals(goals: MacroGoals) = viewModelScope.launch { + repository.setMacroGoals(goals) + } } diff --git a/app/src/main/java/com/kaz229/fittrack/ui/Format.kt b/app/src/main/java/com/kaz229/fittrack/ui/Format.kt index 99ae94c..68c53a4 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/Format.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/Format.kt @@ -13,6 +13,14 @@ fun formatWeight(weightKg: Double): String = String.format(Locale.FRENCH, "%.1f kg", weightKg) } +/** « 12,5 g » mais « 12 g » : mêmes règles que les poids, pour les macros et les portions. */ +fun formatGrams(grams: Double): String = + if (grams % 1.0 == 0.0) { + "${grams.toInt()} g" + } else { + String.format(Locale.FRENCH, "%.1f g", grams) + } + /** Le volume se compte vite en tonnes : au-delà de 1000 kg on bascule. */ fun formatVolume(volumeKg: Double): String = when { volumeKg >= 1000 -> String.format(Locale.FRENCH, "%.1f t", volumeKg / 1000) diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/AddDialogs.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/AddDialogs.kt index 4f82b78..a496d06 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/AddDialogs.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/AddDialogs.kt @@ -7,8 +7,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button 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 @@ -19,12 +22,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.ui.formatGrams +import kotlin.math.roundToInt import com.kaz229.fittrack.data.Activities import com.kaz229.fittrack.data.Exercise import com.kaz229.fittrack.data.ExerciseSet +import com.kaz229.fittrack.data.FoodProduct import com.kaz229.fittrack.data.MealType +import com.kaz229.fittrack.data.portion /** * Saisie d'une série. Les champs sont pré-remplis avec la série précédente du même @@ -178,21 +186,107 @@ fun AddCardioDialog( ) } +/** + * Quantité d'un produit scanné et type de repas, avec l'apport recalculé en direct. + * + * Le champ est pré-rempli avec la portion de l'emballage quand Open Food Facts la + * connaît, sinon 100 g — la référence des valeurs nutritionnelles. + */ +@Composable +fun PortionForm( + product: FoodProduct, + onConfirm: (grams: Double, mealType: MealType) -> Unit, +) { + var grams by remember(product.barcode) { + mutableStateOf((product.servingSizeG ?: 100.0).let { formatInput(it) }) + } + var mealType by remember { mutableStateOf(MealType.LUNCH) } + val parsedGrams = grams.replace(',', '.').toDoubleOrNull() + val valid = parsedGrams != null && parsedGrams > 0 + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Pour 100 g : ${product.kcalPer100g.roundToInt()} kcal · " + + "P ${formatGrams(product.proteinPer100g)} · " + + "G ${formatGrams(product.carbsPer100g)} · " + + "L ${formatGrams(product.fatPer100g)}", + style = MaterialTheme.typography.bodySmall, + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MealType.entries.forEach { type -> + FilterChip( + selected = type == mealType, + onClick = { mealType = type }, + label = { Text(type.label) }, + ) + } + } + OutlinedTextField( + value = grams, + onValueChange = { grams = it }, + label = { Text("Quantité (g)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.fillMaxWidth(), + ) + if (valid) { + val portion = product.portion(parsedGrams) + HorizontalDivider() + Text( + text = "${portion.calories} kcal", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + MacroRow( + proteinG = portion.proteinG, + carbsG = portion.carbsG, + fatG = portion.fatG, + ) + } + Button( + onClick = { onConfirm(parsedGrams ?: 0.0, mealType) }, + enabled = valid, + modifier = Modifier.fillMaxWidth(), + ) { Text("Ajouter au journal") } + } +} + +/** + * Saisie libre d'un aliment : plat maison, ou produit absent d'Open Food Facts. + * Les macros sont optionnelles — mieux vaut une ligne avec seulement des calories + * qu'un repas non enregistré. + */ @Composable fun AddMealDialog( onDismiss: () -> Unit, - onConfirm: (name: String, calories: Int, mealType: MealType) -> Unit, + onConfirm: ( + name: String, + calories: Int, + mealType: MealType, + proteinG: Double, + carbsG: Double, + fatG: Double, + ) -> Unit, ) { var name by remember { mutableStateOf("") } var calories by remember { mutableStateOf("") } + var protein by remember { mutableStateOf("") } + var carbs by remember { mutableStateOf("") } + var fat by remember { mutableStateOf("") } var mealType by remember { mutableStateOf(MealType.LUNCH) } val parsedCalories = calories.toIntOrNull() AlertDialog( onDismissRequest = onDismiss, - title = { Text("Nouveau repas") }, + title = { Text("Nouvel aliment") }, text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { Row( modifier = Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -220,14 +314,55 @@ fun AddMealDialog( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), ) + Text( + text = "Macros (optionnel, en grammes)", + style = MaterialTheme.typography.labelLarge, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MacroField("Prot.", protein, { protein = it }, Modifier.weight(1f)) + MacroField("Gluc.", carbs, { carbs = it }, Modifier.weight(1f)) + MacroField("Lip.", fat, { fat = it }, Modifier.weight(1f)) + } } }, confirmButton = { TextButton( - onClick = { onConfirm(name.trim(), parsedCalories ?: 0, mealType) }, + onClick = { + onConfirm( + name.trim(), + parsedCalories ?: 0, + mealType, + protein.toGramsOrZero(), + carbs.toGramsOrZero(), + fat.toGramsOrZero(), + ) + }, enabled = name.isNotBlank() && parsedCalories != null && parsedCalories > 0, ) { Text("Ajouter") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } }, ) } + +@Composable +private fun MacroField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = modifier, + ) +} + +private fun String.toGramsOrZero(): Double = replace(',', '.').toDoubleOrNull() ?: 0.0 + +/** « 30 » plutôt que « 30.0 » dans un champ de saisie. */ +private fun formatInput(value: Double): String = + if (value % 1.0 == 0.0) value.toInt().toString() else value.toString() diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/HomeScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/HomeScreen.kt index 1a67b8d..9f7be72 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/HomeScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.kaz229.fittrack.data.DailySummary +import com.kaz229.fittrack.data.MacroTotals import com.kaz229.fittrack.data.UserProfile import com.kaz229.fittrack.ui.formatDay import com.kaz229.fittrack.ui.formatVolume @@ -27,6 +28,7 @@ import kotlin.math.abs fun HomeScreen( summary: DailySummary, profile: UserProfile, + macros: MacroTotals, lastWeek: List, volumeToday: Double, setCountToday: Int, @@ -40,6 +42,9 @@ fun HomeScreen( item { TodayCard(summary = summary, goal = profile.dailyCalorieGoal) } + item { + MacrosCard(macros = macros, profile = profile) + } item { TrainingCard(volumeToday = volumeToday, setCountToday = setCountToday) } @@ -106,6 +111,21 @@ private fun TodayCard(summary: DailySummary, goal: Int) { } } +@Composable +private fun MacrosCard(macros: MacroTotals, profile: UserProfile) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Macros du jour", style = MaterialTheme.typography.titleMedium) + MacroGoalsBlock(totals = macros, goals = profile.macroGoals) + } + } +} + @Composable private fun TrainingCard(volumeToday: Double, setCountToday: Int) { Card(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/MacroViews.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/MacroViews.kt new file mode 100644 index 0000000..63a3b75 --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/MacroViews.kt @@ -0,0 +1,85 @@ +package com.kaz229.fittrack.ui.screens + +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.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.kaz229.fittrack.data.MacroGoals +import com.kaz229.fittrack.data.MacroTotals +import com.kaz229.fittrack.ui.formatGrams + +/** Les trois macros côte à côte, sans objectif : pour une ligne de repas ou un aperçu. */ +@Composable +fun MacroRow( + proteinG: Double, + carbsG: Double, + fatG: Double, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + MacroCell("Protéines", proteinG) + MacroCell("Glucides", carbsG) + MacroCell("Lipides", fatG) + } +} + +/** Les macros du jour face aux objectifs, chacune avec sa barre de progression. */ +@Composable +fun MacroGoalsBlock( + totals: MacroTotals, + goals: MacroGoals, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MacroGoalBar("Protéines", totals.proteinG, goals.proteinG) + MacroGoalBar("Glucides", totals.carbsG, goals.carbsG) + MacroGoalBar("Lipides", totals.fatG, goals.fatG) + } +} + +@Composable +private fun MacroGoalBar(label: String, valueG: Double, goalG: Int) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, style = MaterialTheme.typography.labelLarge) + Text( + text = "${formatGrams(valueG)} / $goalG g", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + LinearProgressIndicator( + progress = { if (goalG > 0) (valueG / goalG).toFloat().coerceIn(0f, 1f) else 0f }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun MacroCell(label: String, valueG: Double) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = label, style = MaterialTheme.typography.labelMedium) + Text( + text = formatGrams(valueG), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + } +} 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 1e19bdb..e25647e 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 @@ -23,18 +23,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.kaz229.fittrack.data.MacroGoals +import com.kaz229.fittrack.data.MacroTotals import com.kaz229.fittrack.data.Meal import com.kaz229.fittrack.data.MealType +import com.kaz229.fittrack.ui.formatGrams @Composable fun MealsScreen( meals: List, + macros: MacroTotals, + macroGoals: MacroGoals, onDelete: (Meal) -> Unit, modifier: Modifier = Modifier, ) { if (meals.isEmpty()) { EmptyState( - message = "Aucun repas aujourd'hui.\nAppuie sur + pour ajouter ce que tu as mangé.", + message = "Aucun repas aujourd'hui.\n" + + "Scanne un code-barres, ou saisis l'aliment à la main.", modifier = modifier, ) return @@ -42,15 +48,27 @@ fun MealsScreen( val total = meals.sumOf { it.calories } LazyColumn( modifier = modifier.fillMaxWidth(), - contentPadding = PaddingValues(16.dp), + // Marge basse dégagée : les deux boutons flottants (saisie et scan) recouvrent + // sinon la dernière ligne du journal. + contentPadding = PaddingValues(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 104.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { item { - Text( - text = "Total du jour : $total kcal", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Total du jour : $total kcal", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + MacroGoalsBlock(totals = macros, goals = macroGoals) + } + } } items(meals, key = { it.id }) { meal -> MealCard(meal = meal, onDelete = { onDelete(meal) }) @@ -74,9 +92,21 @@ private fun MealCard(meal: Meal, onDelete: () -> Unit) { fontWeight = FontWeight.SemiBold, ) Text( - text = "${MealType.byId(meal.mealType).label} · ${meal.calories} kcal", + text = buildString { + append(MealType.byId(meal.mealType).label) + meal.grams?.let { append(" · ${formatGrams(it)}") } + append(" · ${meal.calories} kcal") + }, style = MaterialTheme.typography.bodyMedium, ) + if (meal.hasMacros) { + Text( + text = "P ${formatGrams(meal.proteinG)} · " + + "G ${formatGrams(meal.carbsG)} · " + + "L ${formatGrams(meal.fatG)}", + style = MaterialTheme.typography.bodySmall, + ) + } } IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, contentDescription = "Supprimer le repas") 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 d340819..e32c2be 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 @@ -21,12 +21,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.unit.dp +import com.kaz229.fittrack.data.MacroGoals import com.kaz229.fittrack.data.UserProfile @Composable fun ProfileScreen( profile: UserProfile, - onSave: (weightKg: Double, goal: Int) -> Unit, + onSave: (weightKg: Double, goal: Int, macroGoals: MacroGoals) -> Unit, modifier: Modifier = Modifier, ) { var weight by remember(profile.weightKg) { @@ -35,10 +36,26 @@ fun ProfileScreen( var goal by remember(profile.dailyCalorieGoal) { mutableStateOf(profile.dailyCalorieGoal.toString()) } + var protein by remember(profile.macroGoals.proteinG) { + mutableStateOf(profile.macroGoals.proteinG.toString()) + } + var carbs by remember(profile.macroGoals.carbsG) { + mutableStateOf(profile.macroGoals.carbsG.toString()) + } + var fat by remember(profile.macroGoals.fatG) { + mutableStateOf(profile.macroGoals.fatG.toString()) + } val parsedWeight = weight.replace(',', '.').toDoubleOrNull() val parsedGoal = goal.toIntOrNull() - val valid = parsedWeight != null && parsedWeight > 0 && parsedGoal != null && parsedGoal > 0 + val parsedProtein = protein.toIntOrNull() + val parsedCarbs = carbs.toIntOrNull() + val parsedFat = fat.toIntOrNull() + val valid = parsedWeight != null && parsedWeight > 0 && + parsedGoal != null && parsedGoal > 0 && + parsedProtein != null && parsedProtein > 0 && + parsedCarbs != null && parsedCarbs > 0 && + parsedFat != null && parsedFat > 0 Column( modifier = modifier @@ -68,8 +85,46 @@ fun ProfileScreen( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), ) + Text( + text = "Objectifs de macros (grammes par jour)", + style = MaterialTheme.typography.labelLarge, + ) + OutlinedTextField( + value = protein, + onValueChange = { protein = it }, + label = { Text("Protéines (g)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = carbs, + onValueChange = { carbs = it }, + label = { Text("Glucides (g)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = fat, + onValueChange = { fat = it }, + label = { Text("Lipides (g)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) Button( - onClick = { onSave(parsedWeight ?: profile.weightKg, parsedGoal ?: profile.dailyCalorieGoal) }, + onClick = { + onSave( + parsedWeight ?: profile.weightKg, + parsedGoal ?: profile.dailyCalorieGoal, + MacroGoals( + proteinG = parsedProtein ?: profile.macroGoals.proteinG, + carbsG = parsedCarbs ?: profile.macroGoals.carbsG, + fatG = parsedFat ?: profile.macroGoals.fatG, + ), + ) + }, enabled = valid, modifier = Modifier.fillMaxWidth(), ) { @@ -88,6 +143,15 @@ fun ProfileScreen( "C'est une estimation, pas une mesure médicale.", style = MaterialTheme.typography.bodyMedium, ) + Text("D'où viennent les valeurs nutritionnelles ?", fontWeight = FontWeight.SemiBold) + Text( + text = "Les produits scannés sont cherchés dans Open Food Facts, une base " + + "de données alimentaire ouverte et collaborative. Seul le code-barres " + + "quitte le téléphone ; le produit est ensuite gardé en cache local. " + + "Les valeurs sont renseignées par des contributeurs : elles peuvent " + + "être incomplètes ou erronées.", + style = MaterialTheme.typography.bodyMedium, + ) } } } diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt new file mode 100644 index 0000000..7574363 --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt @@ -0,0 +1,322 @@ +package com.kaz229.fittrack.ui.screens + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.OptIn as AndroidXOptIn +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import java.util.concurrent.Executors +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import com.kaz229.fittrack.data.FoodProduct +import com.kaz229.fittrack.data.MealType +import com.kaz229.fittrack.ui.ScanError +import com.kaz229.fittrack.ui.ScanState + +/** + * Écran de scan d'un code-barres alimentaire. + * + * Trois états successifs : la caméra cherche, la recherche est en cours, puis soit le + * produit est présenté pour saisir la quantité, soit l'échec est expliqué avec une + * porte de sortie vers la saisie manuelle. + */ +@Composable +fun ScanScreen( + state: ScanState, + onBarcode: (String) -> Unit, + onConfirm: (grams: Double, mealType: MealType) -> Unit, + onRetry: () -> Unit, + onManualEntry: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED, + ) + } + var permissionRefused by remember { mutableStateOf(false) } + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + hasPermission = granted + permissionRefused = !granted + } + + LaunchedEffect(Unit) { + if (!hasPermission) launcher.launch(Manifest.permission.CAMERA) + } + + if (!hasPermission) { + CenteredMessage( + title = if (permissionRefused) "Caméra refusée" else "Accès à la caméra", + body = if (permissionRefused) { + "Sans la caméra, impossible de lire un code-barres. Tu peux autoriser " + + "l'accès dans les réglages Android, ou saisir l'aliment à la main." + } else { + "FitTrack a besoin de la caméra pour lire le code-barres des produits." + }, + actionLabel = "Saisir à la main", + onAction = onManualEntry, + modifier = modifier, + ) + return + } + + Box(modifier = modifier.fillMaxSize()) { + if (state.cameraActive) { + CameraPreview(onBarcode = onBarcode, modifier = Modifier.fillMaxSize()) + Text( + text = "Cadre le code-barres du produit", + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(24.dp) + .background( + color = Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + when { + state.searching -> Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator() + Text("Recherche du produit…") + } + + state.product != null -> ScannedProductPanel( + product = state.product, + fromCache = state.fromCache, + onConfirm = onConfirm, + onRetry = onRetry, + modifier = Modifier.align(Alignment.Center), + ) + + state.error != null -> CenteredMessage( + title = when (state.error) { + ScanError.NOT_FOUND -> "Produit inconnu" + ScanError.NO_NUTRITION -> "Valeurs nutritionnelles absentes" + ScanError.NETWORK -> "Pas de connexion" + }, + body = when (state.error) { + ScanError.NOT_FOUND -> + "Le code-barres ${state.barcode} n'est pas dans Open Food Facts. " + + "Tu peux saisir l'aliment à la main." + ScanError.NO_NUTRITION -> + "Ce produit est référencé mais personne n'a encore renseigné ses " + + "valeurs nutritionnelles. Saisis-les à la main." + ScanError.NETWORK -> + "Impossible d'interroger Open Food Facts. Vérifie ta connexion, " + + "ou saisis l'aliment à la main." + }, + actionLabel = "Saisir à la main", + onAction = onManualEntry, + secondaryLabel = "Réessayer", + onSecondary = onRetry, + modifier = Modifier.align(Alignment.Center), + ) + } + } +} + +/** + * Aperçu caméra branché sur l'analyseur de codes-barres de ML Kit. + * + * Le binding est fait une fois pour toute la vie de l'écran : le rejouer à chaque + * recomposition rouvre et referme la caméra en boucle, et l'aperçu reste noir. + */ +@AndroidXOptIn(ExperimentalGetImage::class) +@Composable +private fun CameraPreview(onBarcode: (String) -> Unit, modifier: Modifier = Modifier) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val previewView = remember { PreviewView(context) } + // Les seuls formats présents sur un emballage alimentaire : restreindre accélère + // la détection et évite de lire un QR code au passage. + val scanner = remember { + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats( + Barcode.FORMAT_EAN_13, + Barcode.FORMAT_EAN_8, + Barcode.FORMAT_UPC_A, + Barcode.FORMAT_UPC_E, + ) + .build(), + ) + } + // L'analyse tourne hors du thread principal : décoder 30 images par seconde + // saccaderait l'aperçu. + val analysisExecutor = remember { Executors.newSingleThreadExecutor() } + var provider by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + val future = ProcessCameraProvider.getInstance(context) + future.addListener({ + val cameraProvider = future.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { imageAnalysis -> + imageAnalysis.setAnalyzer(analysisExecutor) { imageProxy -> + imageProxy.scanBarcode(scanner, onBarcode) + } + } + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + provider = cameraProvider + }, ContextCompat.getMainExecutor(context)) + } + + DisposableEffect(Unit) { + onDispose { + // La navigation ne détruit pas l'activité : sans libération explicite, la + // caméra resterait ouverte après la sortie de l'écran. + provider?.unbindAll() + scanner.close() + analysisExecutor.shutdown() + } + } + + AndroidView(factory = { previewView }, modifier = modifier) +} + +/** Passe une image de la caméra à ML Kit et remonte le premier code-barres lisible. */ +@AndroidXOptIn(ExperimentalGetImage::class) +private fun ImageProxy.scanBarcode(scanner: BarcodeScanner, onBarcode: (String) -> Unit) { + val mediaImage = image + if (mediaImage == null) { + close() + return + } + val input = InputImage.fromMediaImage(mediaImage, imageInfo.rotationDegrees) + scanner.process(input) + .addOnSuccessListener { barcodes -> + barcodes.firstNotNullOfOrNull { it.rawValue }?.let(onBarcode) + } + .addOnCompleteListener { close() } +} + +/** Produit reconnu : on demande la quantité et on montre l'apport correspondant. */ +@Composable +private fun ScannedProductPanel( + product: FoodProduct, + fromCache: Boolean, + onConfirm: (grams: Double, mealType: MealType) -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Card(modifier = modifier.padding(16.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = product.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (fromCache) { + Text( + text = "Déjà scanné : valeurs reprises du cache local.", + style = MaterialTheme.typography.bodySmall, + ) + } + PortionForm(product = product, onConfirm = onConfirm) + TextButton(onClick = onRetry) { Text("Scanner un autre produit") } + } + } +} + +@Composable +private fun CenteredMessage( + title: String, + body: String, + actionLabel: String, + onAction: () -> Unit, + modifier: Modifier = Modifier, + secondaryLabel: String? = null, + onSecondary: (() -> Unit)? = null, +) { + Card(modifier = modifier.padding(24.dp)) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Button(onClick = onAction) { Text(actionLabel) } + if (secondaryLabel != null && onSecondary != null) { + TextButton(onClick = onSecondary) { Text(secondaryLabel) } + } + } + } +} diff --git a/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt b/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt new file mode 100644 index 0000000..54c97f0 --- /dev/null +++ b/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt @@ -0,0 +1,149 @@ +package com.kaz229.fittrack + +import com.kaz229.fittrack.data.FoodLookup +import com.kaz229.fittrack.data.FoodProduct +import com.kaz229.fittrack.data.MacroTotals +import com.kaz229.fittrack.data.OpenFoodFactsClient +import com.kaz229.fittrack.data.parseServingGrams +import com.kaz229.fittrack.data.portion +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NutritionTest { + + private val nutella = FoodProduct( + barcode = "3017620422003", + name = "Nutella", + brands = "Ferrero", + kcalPer100g = 539.0, + proteinPer100g = 6.3, + carbsPer100g = 57.5, + fatPer100g = 30.9, + ) + + @Test + fun `une portion de 100 g reprend les valeurs de reference`() { + val portion = nutella.portion(100.0) + assertEquals(539, portion.calories) + assertEquals(6.3, portion.proteinG, 0.001) + assertEquals(57.5, portion.carbsG, 0.001) + assertEquals(30.9, portion.fatG, 0.001) + } + + @Test + fun `une portion est calculee au prorata du poids`() { + val portion = nutella.portion(30.0) + assertEquals(162, portion.calories) + assertEquals(1.9, portion.proteinG, 0.001) + assertEquals(17.3, portion.carbsG, 0.001) + assertEquals(9.3, portion.fatG, 0.001) + } + + @Test + fun `une portion nulle n'apporte rien`() { + val portion = nutella.portion(0.0) + assertEquals(0, portion.calories) + assertEquals(0.0, portion.proteinG, 0.001) + } + + @Test + fun `les calories des macros suivent la regle 4-4-9`() { + val totals = MacroTotals(proteinG = 100.0, carbsG = 200.0, fatG = 50.0) + // 100×4 + 200×4 + 50×9 = 1650 + assertEquals(1650, totals.caloriesFromMacros) + } + + @Test + fun `le poids d'une portion est extrait des formats courants`() { + assertEquals(30.0, parseServingGrams("30 g")!!, 0.001) + assertEquals(25.0, parseServingGrams("2 biscuits (25g)")!!, 0.001) + assertEquals(250.0, parseServingGrams("250 ml")!!, 0.001) + assertEquals(12.5, parseServingGrams("12,5 g")!!, 0.001) + } + + @Test + fun `un serving_size inexploitable ne renvoie rien`() { + assertNull(parseServingGrams(null)) + assertNull(parseServingGrams("")) + assertNull(parseServingGrams("1 tranche")) + assertNull(parseServingGrams("0 g")) + } + + @Test + fun `une reponse Open Food Facts complete donne un produit`() { + val json = """ + {"code":"3017620422003","product":{ + "product_name":"Nutella","brands":"Nutella, Ferrero","serving_size":"15 g", + "nutriments":{"energy-kcal_100g":539,"proteins_100g":6.3, + "carbohydrates_100g":57.5,"fat_100g":30.9}}} + """.trimIndent() + + val result = OpenFoodFactsClient.parseProduct("3017620422003", json) + + assertTrue(result is FoodLookup.Found) + val product = (result as FoodLookup.Found).product + assertEquals("Nutella", product.name) + // Seule la première marque est gardée : OFF en accumule plusieurs séparées par des virgules. + assertEquals("Nutella", product.brands) + assertEquals(539.0, product.kcalPer100g, 0.001) + assertEquals(15.0, product.servingSizeG!!, 0.001) + } + + @Test + fun `le nom francais est prefere quand il existe`() { + val json = """ + {"product":{"product_name":"Chocolate spread","product_name_fr":"Pâte à tartiner", + "nutriments":{"energy-kcal_100g":500}}} + """.trimIndent() + + val result = OpenFoodFactsClient.parseProduct("123", json) + + assertEquals("Pâte à tartiner", (result as FoodLookup.Found).product.name) + } + + @Test + fun `les kilojoules servent de repli quand les kcal manquent`() { + val json = """ + {"product":{"product_name":"Truc","nutriments":{"energy-kj_100g":2252}}} + """.trimIndent() + + val result = OpenFoodFactsClient.parseProduct("123", json) + + // 2252 kJ ÷ 4,184 = 538,2 kcal + assertEquals(538.2, (result as FoodLookup.Found).product.kcalPer100g, 0.1) + } + + @Test + fun `un statut zero signale un produit absent`() { + val result = OpenFoodFactsClient.parseProduct("000", """{"status":0}""") + assertEquals(FoodLookup.NotFound, result) + } + + @Test + fun `un produit sans energie est inexploitable`() { + val json = """{"product":{"product_name":"Truc","nutriments":{"salt_100g":1.2}}}""" + assertEquals(FoodLookup.NoNutrition, OpenFoodFactsClient.parseProduct("123", json)) + } + + @Test + fun `un produit sans nom est inexploitable`() { + val json = """{"product":{"nutriments":{"energy-kcal_100g":100}}}""" + assertEquals(FoodLookup.NoNutrition, OpenFoodFactsClient.parseProduct("123", json)) + } + + @Test + fun `les macros absentes valent zero et non NaN`() { + val json = """ + {"product":{"product_name":"Eau","nutriments":{"energy-kcal_100g":0}}} + """.trimIndent() + + val product = (OpenFoodFactsClient.parseProduct("123", json) as FoodLookup.Found).product + + assertEquals(0.0, product.proteinPer100g, 0.001) + assertEquals(0.0, product.carbsPer100g, 0.001) + assertEquals(0.0, product.fatPer100g, 0.001) + assertNull(product.servingSizeG) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9ee813..f0b74bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,8 +9,12 @@ composeBom = "2024.12.01" navigation = "2.8.5" room = "2.6.1" datastore = "1.1.1" +camerax = "1.4.1" +mlkitBarcode = "17.3.0" junit = "4.13.2" +orgJson = "20240303" androidxJunit = "1.2.1" +androidxTestRunner = "1.6.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,8 +34,16 @@ androidx-room-runtime = { group = "androidx.room", name = "room-runtime", versio androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } junit = { group = "junit", name = "junit", version.ref = "junit" } +org-json = { group = "org.json", name = "json", version.ref = "orgJson" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.9.0" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }