diff --git a/README.md b/README.md index 814a6d2..f1e9510 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,16 @@ Rien d'autre ne sort du téléphone, et les deux restent facultatifs. - **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. +- **Séance oubliée** : au bout de 10 minutes sans nouvelle série, l'app demande si tu es + toujours là — une séance laissée ouverte fausse sa durée et ses calories. +- **Séances venues d'ailleurs** : celles enregistrées dans une autre application (Lyfta, une + montre) sont reprises depuis Health Connect, sans doublon. - **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. +- **Recherche par nom** : « sky » retrouve le skyr mangé il y a deux jours, à réinscrire en un + tap, puis propose les produits d'Open Food Facts. L'historique passe devant : on remange + surtout les mêmes choses, et il répond sans réseau. - **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. @@ -58,7 +65,8 @@ corde à sauter 11,0 · rameur 7,0…). C'est une estimation, pas une mesure mé | 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 | -| Santé | Health Connect (écriture séances, calories et nutrition ; lecture du poids) | +| Santé | Health Connect (écriture séances, calories et nutrition ; lecture du poids et des séances) | +| Rappels | WorkManager | | Repas dictés | file récupérée sur le serveur Kazis (réseau privé, jeton partagé) | | Build | Gradle 8.11 + AGP 8.7, `minSdk 26`, `targetSdk 35` | @@ -99,7 +107,8 @@ app/src/main/ │ ├── Nutrition.kt # FoodProduct, portions, macros et objectifs │ ├── OpenFoodFacts.kt # client de lecture de l'API + parsing │ ├── HealthConnect.kt # export des séances et repas, lecture du poids - │ ├── Kazis.kt # récupération des repas dictés à l'assistant + │ ├── Kazis.kt # repas dictés, sauvegarde et restauration + │ ├── SessionReminder.kt # rappel quand une séance reste ouverte │ ├── Exercise.kt # exercice du catalogue + libellés FR │ ├── ExerciseCatalog.kt # chargement, recherche, filtres │ ├── Activities.kt # activités cardio (MET) et calcul des calories diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 694aff0..454d2bb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.health.connect.client) + implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.camera.core) implementation(libs.androidx.camera.camera2) diff --git a/app/src/androidTest/java/com/kaz229/fittrack/FoodSearchTest.kt b/app/src/androidTest/java/com/kaz229/fittrack/FoodSearchTest.kt new file mode 100644 index 0000000..5dd55b8 --- /dev/null +++ b/app/src/androidTest/java/com/kaz229/fittrack/FoodSearchTest.kt @@ -0,0 +1,106 @@ +package com.kaz229.fittrack + +import android.content.Context +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.MealType +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.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.time.LocalDate + +/** Recherche d'aliments : historique local d'abord, Open Food Facts ensuite. */ +@RunWith(AndroidJUnit4::class) +class FoodSearchTest { + + private lateinit var db: FitTrackDatabase + private lateinit var repository: FitTrackRepository + private val today: LocalDate = LocalDate.now() + + @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), + ) + } + + @After + fun tearDown() = db.close() + + @Test + fun un_aliment_deja_saisi_se_retrouve_par_son_debut() = runTest { + repository.addMeal("Skyr nature Lidl", 130, MealType.BREAKFAST, 20.0, 8.0, 0.4, today.minusDays(2)) + + val found = repository.searchFoods("sky") + + assertEquals(listOf("Skyr nature Lidl"), found.history.map { it.name }) + assertEquals(130, found.history.single().calories) + } + + @Test + fun seule_la_derniere_version_d_un_aliment_est_proposee() = runTest { + repository.addMeal("Skyr nature", 120, MealType.BREAKFAST, 19.0, 7.0, 0.3, today.minusDays(3)) + repository.addMeal("Skyr nature", 180, MealType.BREAKFAST, 28.0, 11.0, 0.5, today.minusDays(1)) + + val found = repository.searchFoods("skyr") + + // Un même aliment revient souvent : la dernière version est la plus à jour + assertEquals(1, found.history.size) + assertEquals(180, found.history.single().calories) + } + + @Test + fun une_recherche_trop_courte_ne_cherche_rien() = runTest { + repository.addMeal("Skyr", 130, MealType.BREAKFAST, date = today) + + assertTrue(repository.searchFoods("s").isEmpty) + } + + @Test + fun un_aliment_repete_revient_au_jour_et_au_repas_choisis() = runTest { + repository.addMeal("Skyr nature", 130, MealType.BREAKFAST, 20.0, 8.0, 0.4, today.minusDays(2)) + val ancien = repository.searchFoods("skyr").history.single() + + repository.repeatMeal(ancien, MealType.SNACK, today) + + val aujourdhui = repository.mealsForDay(today).first() + assertEquals(1, aujourdhui.size) + assertEquals(MealType.SNACK.id, aujourdhui.single().mealType) + assertEquals(130, aujourdhui.single().calories) + assertEquals(20.0, aujourdhui.single().proteinG, 0.001) + } + + @Test + fun open_food_facts_complete_la_recherche() = runTest { + val found = repository.searchFoods("skyr") + + if (found.products.isEmpty()) { + Log.w(TAG, "IGNORÉ : Open Food Facts injoignable ou sans résultat") + return@runTest + } + Log.i(TAG, "Trouvés : ${found.products.take(3).map { it.displayName }}") + assertTrue("Les produits doivent porter leurs calories", found.products.all { it.kcalPer100g > 0 }) + } + + private companion object { + const val TAG = "FoodSearchTest" + } +} diff --git a/app/src/androidTest/java/com/kaz229/fittrack/SessionReminderTest.kt b/app/src/androidTest/java/com/kaz229/fittrack/SessionReminderTest.kt new file mode 100644 index 0000000..aadd42f --- /dev/null +++ b/app/src/androidTest/java/com/kaz229/fittrack/SessionReminderTest.kt @@ -0,0 +1,85 @@ +package com.kaz229.fittrack + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.kaz229.fittrack.data.SessionReminder +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Programmation du rappel de séance oubliée. */ +@RunWith(AndroidJUnit4::class) +class SessionReminderTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val work = WorkManager.getInstance(context) + + private fun infos(sessionId: Long): List = + work.getWorkInfosForUniqueWork("seance-inactive-$sessionId").get() + + @Test + fun demarrer_une_seance_programme_un_rappel() = runTest { + SessionReminder.schedule(context, 42) + + val info = infos(42).single() + assertEquals(WorkInfo.State.ENQUEUED, info.state) + } + + @Test + fun une_nouvelle_serie_repousse_le_rappel_au_lieu_d_en_empiler_un_second() = runTest { + SessionReminder.schedule(context, 43) + val premier = infos(43).single().id + + SessionReminder.schedule(context, 43) + + val apres = infos(43).filter { it.state == WorkInfo.State.ENQUEUED } + assertEquals(1, apres.size) + assertTrue("Le rappel doit être remplacé, pas doublé", apres.single().id != premier) + } + + @Test + fun terminer_la_seance_annule_le_rappel() = runTest { + SessionReminder.schedule(context, 44) + + SessionReminder.cancel(context, 44) + + assertTrue(infos(44).all { it.state == WorkInfo.State.CANCELLED }) + } + + @Test + fun le_rappel_affiche_bien_une_notification() = runTest { + val manager = context.getSystemService(android.app.NotificationManager::class.java) + manager.cancelAll() + + SessionReminder.notifyIdle(context, 99) + + val active = manager.activeNotifications.firstOrNull { it.id == 99 } + if (active == null) { + // Sans l'autorisation POST_NOTIFICATIONS, l'app n'insiste pas : c'est voulu. + android.util.Log.w("SessionReminderTest", "IGNORÉ : notifications non autorisées") + return@runTest + } + val extras = active.notification.extras + assertEquals("Toujours à la salle ?", extras.getCharSequence("android.title")) + assertTrue( + extras.getCharSequence("android.text").toString().contains("10 minutes"), + ) + manager.cancelAll() + } + + @Test + fun deux_seances_ont_chacune_leur_rappel() = runTest { + SessionReminder.schedule(context, 45) + SessionReminder.schedule(context, 46) + + SessionReminder.cancel(context, 45) + + assertTrue(infos(45).all { it.state == WorkInfo.State.CANCELLED }) + assertEquals(WorkInfo.State.ENQUEUED, infos(46).single().state) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5a86cd6..3edc0f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + + + + diff --git a/app/src/main/java/com/kaz229/fittrack/data/Daos.kt b/app/src/main/java/com/kaz229/fittrack/data/Daos.kt index 10d0a02..bf5a490 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Daos.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Daos.kt @@ -36,6 +36,14 @@ interface SessionDao { @Query("SELECT * FROM sessions ORDER BY id") suspend fun all(): List + /** Les identifiants Health Connect déjà importés, pour ne pas les reprendre. */ + @Query("SELECT externalId FROM sessions WHERE externalId IS NOT NULL") + suspend fun importedExternalIds(): List + + /** Réécrit les séances d'une restauration, identifiants d'origine compris. */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(sessions: List) + @Query("SELECT COALESCE(SUM(caloriesBurned), 0) FROM sessions WHERE epochDay = :epochDay") fun observeCaloriesForDay(epochDay: Long): Flow @@ -81,6 +89,9 @@ interface ExerciseSetDao { /** Toutes les séries, pour la sauvegarde : c'est ce que rien d'autre ne conserve. */ @Query("SELECT * FROM exercise_sets ORDER BY id") suspend fun all(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(sets: List) } @Dao @@ -102,6 +113,21 @@ interface MealDao { @Query("SELECT * FROM meals ORDER BY id") suspend fun all(): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(meals: List) + + /** + * Les aliments déjà saisis qui correspondent à la recherche, le plus récent d'abord. + * + * Un même aliment revenant souvent, on ne garde que sa dernière version : c'est + * celle dont la quantité et les macros sont les plus à jour. + */ + @Query( + "SELECT * FROM meals WHERE name LIKE '%' || :query || '%' " + + "GROUP BY LOWER(name) HAVING id = MAX(id) ORDER BY id DESC LIMIT :limit", + ) + suspend fun searchHistory(query: String, limit: Int = 8): List + @Query("SELECT COALESCE(SUM(calories), 0) FROM meals WHERE epochDay = :epochDay") fun observeCaloriesForDay(epochDay: Long): Flow @@ -129,4 +155,11 @@ interface FoodProductDao { @Query("SELECT * FROM food_products WHERE barcode = :barcode") suspend fun findByBarcode(barcode: String): FoodProduct? + + /** Produits déjà scannés qui correspondent à la recherche : disponibles hors ligne. */ + @Query( + "SELECT * FROM food_products WHERE name LIKE '%' || :query || '%' " + + "OR brands LIKE '%' || :query || '%' ORDER BY fetchedAtEpochDay DESC LIMIT :limit", + ) + suspend fun search(query: String, limit: Int = 8): List } 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 2b736ca..2d537b0 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackDatabase.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackDatabase.kt @@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [Session::class, ExerciseSet::class, Meal::class, FoodProduct::class], - version = 2, + version = 3, exportSchema = false, ) abstract class FitTrackDatabase : RoomDatabase() { @@ -50,12 +50,26 @@ abstract class FitTrackDatabase : RoomDatabase() { } } + /** + * v2 → v3 : les séances peuvent venir d'une autre application via Health Connect. + * L'index unique sur l'identifiant d'origine empêche les doublons à l'import. + */ + private val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE sessions ADD COLUMN externalId TEXT") + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS index_sessions_externalId" + + " ON sessions (externalId)", + ) + } + } + fun get(context: Context): FitTrackDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, FitTrackDatabase::class.java, "fittrack.db", - ).addMigrations(MIGRATION_1_2).build().also { instance = it } + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3).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 7e55d3a..348eb5e 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -12,6 +12,7 @@ import java.time.LocalDate * ni Room ni DataStore : le jour où le stockage change, seul ce fichier bouge. */ class FitTrackRepository( + private val context: Context? = null, private val sessionDao: SessionDao, private val exerciseSetDao: ExerciseSetDao, private val mealDao: MealDao, @@ -40,14 +41,17 @@ class FitTrackRepository( } /** Crée une séance salle vide et renvoie son identifiant. */ - suspend fun startGymSession(date: LocalDate = LocalDate.now()): Long = - sessionDao.insert( + suspend fun startGymSession(date: LocalDate = LocalDate.now()): Long { + val id = sessionDao.insert( Session( kind = SessionKind.GYM.id, epochDay = date.toEpochDay(), finished = false, ), ) + context?.let { SessionReminder.schedule(it, id) } + return id + } /** * Clôture une séance salle : la durée saisie sert à estimer les calories brûlées @@ -65,6 +69,7 @@ class FitTrackRepository( finished = true, ), ) + context?.let { SessionReminder.cancel(it, sessionId) } } /** Enregistre une séance cardio ou un sport, calories estimées immédiatement. */ @@ -89,7 +94,10 @@ class FitTrackRepository( ) } - suspend fun deleteSession(session: Session) = sessionDao.delete(session) + suspend fun deleteSession(session: Session) { + context?.let { SessionReminder.cancel(it, session.id) } + sessionDao.delete(session) + } // --- Séries -------------------------------------------------------------- @@ -105,6 +113,8 @@ class FitTrackRepository( epochDay = session.epochDay, ), ) + // Une série repousse le rappel : seul le silence doit le déclencher + context?.let { SessionReminder.schedule(it, sessionId) } } suspend fun deleteSet(set: ExerciseSet) = exerciseSetDao.delete(set) @@ -150,6 +160,33 @@ class FitTrackRepository( suspend fun deleteMeal(meal: Meal) = mealDao.delete(meal) + /** Réinscrit un aliment déjà saisi, à la date du jour et au repas choisi. */ + suspend fun repeatMeal(meal: Meal, mealType: MealType, date: LocalDate = LocalDate.now()) { + mealDao.insert( + meal.copy(id = 0, mealType = mealType.id, epochDay = date.toEpochDay()), + ) + } + + // --- Recherche d'aliments ------------------------------------------------ + + /** + * Ce que la recherche propose : d'abord ce qui est déjà connu du téléphone, + * ensuite seulement ce qu'Open Food Facts renvoie. + * + * L'historique passe devant parce qu'on remange surtout les mêmes choses, et qu'il + * répond sans réseau. Les deux listes sont rendues séparément : recopier un repas + * déjà saisi et peser un produit ne sont pas le même geste. + */ + suspend fun searchFoods(query: String): FoodSearch { + val trimmed = query.trim() + if (trimmed.length < 2) return FoodSearch() + val history = mealDao.searchHistory(trimmed) + val cached = foodProductDao.search(trimmed) + val remote = openFoodFacts.search(trimmed) + .filter { product -> cached.none { it.barcode == product.barcode } } + return FoodSearch(history = history, products = cached + remote) + } + // --- Aliments scannés ---------------------------------------------------- /** @@ -315,6 +352,33 @@ class FitTrackRepository( } } + /** + * Réinjecte la sauvegarde conservée par Kazis. + * + * Seulement dans une app vide : les identifiants d'origine sont restaurés tels + * quels, et ils écraseraient des séances créées depuis. C'est le cas d'un + * téléphone neuf, le seul où la restauration a un sens. + */ + suspend fun restoreFromKazis(): KazisRestore { + val settings = userPreferences.kazis.first() + if (!settings.configured) return KazisRestore.NotConfigured + if (sessionDao.all().isNotEmpty() || mealDao.all().isNotEmpty()) { + return KazisRestore.Failed("cette application contient déjà des données") + } + return try { + val archive = kazis.archive(settings.url, settings.token) + if (archive.isEmpty) return KazisRestore.Empty + sessionDao.insertAll(archive.sessions) + exerciseSetDao.insertAll(archive.sets) + mealDao.insertAll(archive.meals) + KazisRestore.Success(archive.sessions.size, archive.sets.size, archive.meals.size) + } catch (_: KazisUnauthorized) { + KazisRestore.Unauthorized + } catch (e: Exception) { + KazisRestore.Failed(e.message ?: "serveur injoignable") + } + } + // --- Health Connect ------------------------------------------------------ val lastHealthSync: Flow = userPreferences.lastHealthSync @@ -325,6 +389,42 @@ class FitTrackRepository( suspend fun healthConnectPermissionsGranted(): Boolean = healthConnect?.hasAllPermissions() ?: false + /** + * Reprend les séances enregistrées par d'autres applications (Lyfta, montre…). + * + * Les calories ne sont pas recalculées : celles de l'application d'origine valent + * mieux qu'une estimation MET faite sur une durée seule. Quand elle n'en donne pas, + * on retombe sur la formule MET, comme pour une séance saisie ici. + */ + suspend fun importHealthSessions(today: LocalDate = LocalDate.now()): Int { + val manager = healthConnect ?: return 0 + val known = sessionDao.importedExternalIds().toSet() + val weight = userPreferences.profile.first().weightKg + var imported = 0 + for (session in manager.foreignSessions(today, SYNC_DAYS)) { + if (session.id in known) continue + val activity = if (session.isStrength) { + Activities.strengthTraining + } else { + Activities.byId("autre") + } + sessionDao.insert( + Session( + kind = if (session.isStrength) SessionKind.GYM.id else SessionKind.CARDIO.id, + epochDay = session.day.toEpochDay(), + activityId = if (session.isStrength) null else activity.id, + durationMinutes = session.minutes, + caloriesBurned = Activities.caloriesBurned(activity, session.minutes, weight), + note = session.title ?: "Importé de ${session.source.substringAfterLast('.')}", + finished = true, + externalId = session.id, + ), + ) + imported++ + } + return imported + } + /** * Exporte les séances et repas du dernier mois, et relit le poids le plus récent. * Le poids n'est pas appliqué d'office : il est renvoyé pour que l'utilisateur @@ -341,6 +441,8 @@ class FitTrackRepository( ) if (result is SyncResult.Success) { userPreferences.setLastHealthSync(System.currentTimeMillis() / 1000) + // Dans la foulée : ce que les autres applications ont écrit de leur côté + return result.copy(imported = importHealthSessions(today)) } return result } @@ -364,6 +466,7 @@ class FitTrackRepository( instance ?: run { val db = FitTrackDatabase.get(context) FitTrackRepository( + context = context.applicationContext, sessionDao = db.sessionDao(), exerciseSetDao = db.exerciseSetDao(), mealDao = db.mealDao(), diff --git a/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt b/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt index a24cd07..0682d85 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt @@ -36,9 +36,30 @@ enum class HealthConnectAvailability { UNSUPPORTED, } +/** Une séance écrite dans Health Connect par une autre application. */ +data class ForeignSession( + val id: String, + val title: String?, + val exerciseType: Int, + val source: String, + val start: java.time.Instant, + val minutes: Int, + val day: LocalDate, +) { + /** Musculation ou cardio, selon le type déclaré par l'application d'origine. */ + val isStrength: Boolean + get() = exerciseType == ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING || + exerciseType == ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING +} + /** Résultat d'une synchronisation, pour l'afficher sans interpréter une exception. */ sealed interface SyncResult { - data class Success(val sessions: Int, val meals: Int, val weightKg: Double?) : SyncResult + data class Success( + val sessions: Int, + val meals: Int, + val weightKg: Double?, + val imported: Int = 0, + ) : SyncResult data object PermissionsMissing : SyncResult data object Unavailable : SyncResult data class Failed(val message: String) : SyncResult @@ -116,6 +137,45 @@ class HealthConnectManager(private val context: Context) { } } + /** + * Les séances enregistrées par d'autres applications sur la période. + * + * Celles que FitTrack a écrites sont écartées : elles viennent d'ici, les réimporter + * créerait un doublon à chaque synchronisation. + */ + suspend fun foreignSessions( + today: LocalDate = LocalDate.now(), + days: Int = 30, + zone: ZoneId = ZoneId.systemDefault(), + ): List = withContext(Dispatchers.IO) { + val client = client ?: return@withContext emptyList() + val from = today.minusDays((days - 1).toLong()).atStartOfDay(zone).toInstant() + try { + val own = context.packageName + client.readRecords( + ReadRecordsRequest( + recordType = ExerciseSessionRecord::class, + timeRangeFilter = TimeRangeFilter.between(from, Instant.now()), + ), + ).records + .filter { it.metadata.dataOrigin.packageName != own } + .map { record -> + ForeignSession( + id = record.metadata.id, + title = record.title?.takeIf { it.isNotBlank() }, + exerciseType = record.exerciseType, + source = record.metadata.dataOrigin.packageName, + start = record.startTime, + minutes = Duration.between(record.startTime, record.endTime) + .toMinutes().toInt().coerceAtLeast(1), + day = record.startTime.atZone(zone).toLocalDate(), + ) + } + } catch (_: Exception) { + emptyList() + } + } + /** Dernier poids enregistré par n'importe quelle application, en kilos. */ suspend fun latestWeightKg(): Double? = withContext(Dispatchers.IO) { val client = client ?: return@withContext null @@ -143,6 +203,8 @@ class HealthConnectManager(private val context: Context) { HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class), HealthPermission.getWritePermission(NutritionRecord::class), HealthPermission.getReadPermission(WeightRecord::class), + HealthPermission.getReadPermission(ExerciseSessionRecord::class), + HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class), ) /** diff --git a/app/src/main/java/com/kaz229/fittrack/data/Kazis.kt b/app/src/main/java/com/kaz229/fittrack/data/Kazis.kt index 9e6d4ce..9814b06 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Kazis.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Kazis.kt @@ -31,6 +31,24 @@ data class KazisMeal( val needsLookup: Boolean get() = calories <= 0 && barcode != null && grams != null } +/** Ce que la restauration a donné. */ +sealed interface KazisRestore { + data class Success(val sessions: Int, val sets: Int, val meals: Int) : KazisRestore + data object Empty : KazisRestore + data object NotConfigured : KazisRestore + data object Unauthorized : KazisRestore + data class Failed(val message: String) : KazisRestore +} + +/** Le contenu rendu par Kazis, prêt à être réécrit dans la base. */ +data class KazisArchive( + val sessions: List, + val sets: List, + val meals: List, +) { + val isEmpty: Boolean get() = sessions.isEmpty() && sets.isEmpty() && meals.isEmpty() +} + /** Ce que la sauvegarde a donné. */ sealed interface KazisBackup { data class Success(val sessions: Int, val sets: Int, val meals: Int) : KazisBackup @@ -86,6 +104,10 @@ class KazisClient { KazisBackup.Success(sessions.size, sets.size, meals.size) } + /** Récupère la sauvegarde conservée par Kazis. */ + suspend fun archive(baseUrl: String, token: String): KazisArchive = + withContext(Dispatchers.IO) { parseArchive(request(baseUrl, token, "/v1/fittrack", "GET", null)) } + /** Confirme l'enregistrement : les repas sortent de la file côté Kazis. */ suspend fun acknowledge(baseUrl: String, token: String, ids: List) { if (ids.isEmpty()) return @@ -152,6 +174,56 @@ class KazisClient { } private fun Double.orZero(): Double = if (isNaN()) 0.0 else this + + /** Relit ce que `GET /v1/fittrack` a rendu. Fonction pure, testable sans réseau. */ + fun parseArchive(json: String): KazisArchive { + val root = JSONObject(json) + return KazisArchive( + sessions = root.optJSONArray("sessions").map { item -> + Session( + id = item.optLong("id"), + kind = item.optString("kind"), + epochDay = LocalDate.parse(item.optString("day")).toEpochDay(), + activityId = item.optString("activity").takeIf { it.isNotBlank() && it != "null" }, + durationMinutes = item.optInt("duration_min"), + caloriesBurned = item.optInt("kcal"), + note = item.optString("note"), + finished = true, + ) + }, + sets = root.optJSONArray("sets").map { item -> + ExerciseSet( + id = item.optLong("id"), + sessionId = item.optLong("session_id"), + exerciseId = item.optString("exercise_id"), + position = item.optInt("position"), + reps = item.optInt("reps"), + weightKg = item.optDouble("weight_kg", 0.0).orZero(), + epochDay = LocalDate.parse(item.optString("day")).toEpochDay(), + ) + }, + meals = root.optJSONArray("meals").map { item -> + Meal( + id = item.optLong("id"), + name = item.optString("name"), + calories = item.optInt("calories"), + mealType = item.optString("meal_type"), + epochDay = LocalDate.parse(item.optString("day")).toEpochDay(), + proteinG = item.optDouble("protein_g", 0.0).orZero(), + carbsG = item.optDouble("carbs_g", 0.0).orZero(), + fatG = item.optDouble("fat_g", 0.0).orZero(), + ) + }, + ) + } + + /** Une ligne illisible est ignorée : mieux vaut restaurer le reste que rien. */ + private fun JSONArray?.map(build: (JSONObject) -> T): List { + if (this == null) return emptyList() + return (0 until length()).mapNotNull { index -> + optJSONObject(index)?.let { runCatching { build(it) }.getOrNull() } + } + } } } 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 394fd34..5ac7f36 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Model.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Model.kt @@ -22,7 +22,10 @@ enum class SessionKind(val id: String, val label: String) { * [activityId] renvoie au catalogue [Activities]. Les calories brûlées sont figées * à l'enregistrement (formule MET) pour que l'historique reste stable si le poids change. */ -@Entity(tableName = "sessions") +@Entity( + tableName = "sessions", + indices = [Index(value = ["externalId"], unique = true)], +) data class Session( @PrimaryKey(autoGenerate = true) val id: Long = 0, val kind: String, @@ -32,6 +35,11 @@ data class Session( val caloriesBurned: Int = 0, val note: String = "", val finished: Boolean = false, + /** + * Identifiant Health Connect quand la séance vient d'une autre application. + * L'index unique empêche de la réimporter à chaque synchronisation. + */ + val externalId: String? = null, ) /** diff --git a/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt b/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt index 64012f5..2cff7c8 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Nutrition.kt @@ -70,6 +70,19 @@ data class MacroTotals( val caloriesFromMacros: Int get() = (proteinG * 4 + carbsG * 4 + fatG * 9).roundToInt() } +/** + * Résultat d'une recherche d'aliment. + * + * [history] contient des lignes déjà saisies, à recopier telles quelles ; [products] + * des produits avec leurs valeurs pour 100 g, dont il faut peser la quantité. + */ +data class FoodSearch( + val history: List = emptyList(), + val products: List = emptyList(), +) { + val isEmpty: Boolean get() = history.isEmpty() && products.isEmpty() +} + /** Objectifs journaliers de macros, en grammes. */ data class MacroGoals( val proteinG: Int = DEFAULT_PROTEIN_G, diff --git a/app/src/main/java/com/kaz229/fittrack/data/OpenFoodFacts.kt b/app/src/main/java/com/kaz229/fittrack/data/OpenFoodFacts.kt index fc4f36a..064a028 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/OpenFoodFacts.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/OpenFoodFacts.kt @@ -60,8 +60,36 @@ class OpenFoodFactsClient(private val baseUrl: String = BASE_URL) { } } + /** + * Cherche un produit par son nom. + * + * L'ancienne recherche (`/cgi/search.pl`) est régulièrement hors service ; celle-ci + * passe par le service dédié, qui rend directement les valeurs nutritionnelles. + */ + suspend fun search(query: String, limit: Int = 8): List = + withContext(Dispatchers.IO) { + val encoded = java.net.URLEncoder.encode(query.trim(), "UTF-8") + val url = "$SEARCH_URL/search?q=$encoded&page_size=$limit&fields=$SEARCH_FIELDS" + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("User-Agent", USER_AGENT) + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + } + try { + if (connection.responseCode != HttpURLConnection.HTTP_OK) return@withContext emptyList() + parseSearch(connection.inputStream.bufferedReader().use { it.readText() }) + } catch (_: Exception) { + emptyList() + } finally { + connection.disconnect() + } + } + companion object { private const val BASE_URL = "https://world.openfoodfacts.org" + private const val SEARCH_URL = "https://search.openfoodfacts.org" + private const val SEARCH_FIELDS = "code,product_name,brands,nutriments" private const val TIMEOUT_MS = 8_000 private const val FIELDS = "code,product_name,product_name_fr,brands,serving_size,nutriments" @@ -105,6 +133,31 @@ class OpenFoodFactsClient(private val baseUrl: String = BASE_URL) { ) } + /** + * Lit une réponse du service de recherche. Les produits sans calories ou sans nom + * sont écartés : ils ne peuvent rien apporter au journal. + */ + fun parseSearch(json: String): List { + val hits = JSONObject(json).optJSONArray("hits") ?: return emptyList() + return (0 until hits.length()).mapNotNull { index -> + val item = hits.optJSONObject(index) ?: return@mapNotNull null + val nutriments = item.optJSONObject("nutriments") ?: return@mapNotNull null + val kcal = nutriments.optDoubleOrNull("energy-kcal_100g") ?: return@mapNotNull null + val name = item.optStringOrNull("product_name") ?: return@mapNotNull null + FoodProduct( + barcode = item.optString("code"), + name = name, + // « brands » est ici un tableau, et une chaîne dans l'autre API + brands = item.optJSONArray("brands")?.optString(0)?.trim() + ?: item.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, + ) + } + } + /** `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() } diff --git a/app/src/main/java/com/kaz229/fittrack/data/SessionReminder.kt b/app/src/main/java/com/kaz229/fittrack/data/SessionReminder.kt new file mode 100644 index 0000000..7a0963e --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/data/SessionReminder.kt @@ -0,0 +1,120 @@ +package com.kaz229.fittrack.data + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.kaz229.fittrack.R +import java.util.concurrent.TimeUnit + +/** + * Rappel quand une séance reste ouverte sans qu'on y ajoute rien. + * + * Une séance oubliée fausse tout : sa durée sert à estimer les calories, et elle + * traîne dans la liste marquée « en cours ». Au bout de [IDLE_MINUTES] sans nouvelle + * série, l'app demande si on est toujours là. + * + * Le rappel est reprogrammé à chaque série : seul le silence le déclenche. Il est + * annulé à la clôture de la séance. + */ +object SessionReminder { + + const val IDLE_MINUTES = 10L + private const val WORK_PREFIX = "seance-inactive-" + private const val CHANNEL_ID = "seance_en_cours" + + fun schedule(context: Context, sessionId: Long) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(IDLE_MINUTES, TimeUnit.MINUTES) + .setInputData(workDataOf(IdleSessionWorker.SESSION_ID to sessionId)) + .build() + // REPLACE : une nouvelle série repousse le rappel au lieu d'en empiler un second + WorkManager.getInstance(context) + .enqueueUniqueWork(WORK_PREFIX + sessionId, ExistingWorkPolicy.REPLACE, request) + } + + fun cancel(context: Context, sessionId: Long) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + sessionId) + } + + fun createChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val channel = NotificationChannel( + CHANNEL_ID, + "Séance en cours", + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { description = "Rappel quand une séance reste ouverte sans nouvelle série" } + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + /** Affiche le rappel. Sans autorisation de notification, on n'insiste pas. */ + fun notifyIdle(context: Context, sessionId: Long) { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + ) { + return + } + createChannel(context) + val open = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + val pending = open?.let { + android.app.PendingIntent.getActivity( + context, + sessionId.toInt(), + it, + android.app.PendingIntent.FLAG_IMMUTABLE, + ) + } + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Toujours à la salle ?") + .setContentText( + "Ta séance est ouverte depuis $IDLE_MINUTES minutes sans nouvelle série. " + + "Ajoute-la, ou termine la séance.", + ) + .setStyle( + NotificationCompat.BigTextStyle().bigText( + "Ta séance est ouverte depuis $IDLE_MINUTES minutes sans nouvelle série. " + + "Ajoute-la, ou termine la séance pour que la durée et les calories " + + "restent justes.", + ), + ) + .setAutoCancel(true) + .apply { pending?.let(::setContentIntent) } + .build() + NotificationManagerCompat.from(context).notify(sessionId.toInt(), notification) + } +} + +/** Vérifie, à l'heure dite, que la séance est encore ouverte avant de déranger. */ +class IdleSessionWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val sessionId = inputData.getLong(SESSION_ID, -1) + if (sessionId < 0) return Result.success() + val session = FitTrackDatabase.get(applicationContext).sessionDao().findById(sessionId) + // Séance terminée ou supprimée entre-temps : plus rien à rappeler + if (session == null || session.finished) return Result.success() + SessionReminder.notifyIdle(applicationContext, sessionId) + return Result.success() + } + + companion object { + const val SESSION_ID = "sessionId" + } +} 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 09b78bc..5b44b68 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -3,6 +3,11 @@ package com.kaz229.fittrack.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.DirectionsRun @@ -13,6 +18,7 @@ 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.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton @@ -32,6 +38,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -46,6 +54,7 @@ import com.kaz229.fittrack.ui.screens.AddCardioDialog import com.kaz229.fittrack.ui.screens.AddMealDialog import com.kaz229.fittrack.ui.screens.ExerciseDetailScreen import com.kaz229.fittrack.ui.screens.ExercisesScreen +import com.kaz229.fittrack.ui.screens.FoodSearchScreen import com.kaz229.fittrack.ui.screens.HealthConnectScreen import com.kaz229.fittrack.ui.screens.HomeScreen import com.kaz229.fittrack.ui.screens.KazisScreen @@ -62,9 +71,9 @@ private enum class Tab( val icon: ImageVector, ) { HOME("home", "Accueil", Icons.Filled.Home), + MEALS("meals", "Repas", Icons.Filled.Restaurant), SESSIONS("sessions", "Séances", Icons.Filled.FitnessCenter), EXERCISES("exercises", "Exercices", Icons.Filled.MenuBook), - MEALS("meals", "Repas", Icons.Filled.Restaurant), } private const val SESSION_DETAIL = "session" @@ -73,6 +82,7 @@ private const val PROFILE = "profile" private const val SCAN = "scan" private const val HEALTH = "health" private const val KAZIS = "kazis" +private const val FOOD_SEARCH = "food-search" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -84,6 +94,18 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { backStackEntry?.destination?.hierarchy?.any { it.route == tab.route } == true } + // Le rappel de séance oubliée ne sert à rien sans autorisation de notifier : + // on la demande au moment où elle prend son sens, en démarrant une séance. + val context = LocalContext.current + val notificationsGranted = remember { + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + } + val notificationPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { } + var showCardioDialog by remember { mutableStateOf(false) } var showMealDialog by remember { mutableStateOf(false) } @@ -98,6 +120,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { val scanState by viewModel.scan.collectAsStateWithLifecycle() val healthState by viewModel.health.collectAsStateWithLifecycle() val kazisState by viewModel.kazis.collectAsStateWithLifecycle() + val foodSearchState by viewModel.foodSearch.collectAsStateWithLifecycle() val catalog by viewModel.catalog.collectAsStateWithLifecycle() val exercisesById by viewModel.exercisesById.collectAsStateWithLifecycle() val openSession by viewModel.openSession.collectAsStateWithLifecycle() @@ -112,6 +135,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { SCAN -> "Scanner un produit" HEALTH -> "Health Connect" KAZIS -> "Repas dictés" + FOOD_SEARCH -> "Chercher un aliment" else -> currentTab?.label ?: "FitTrack" } @@ -166,6 +190,9 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { } ExtendedFloatingActionButton( onClick = { + if (!notificationsGranted) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } viewModel.startGymSession { navController.navigate(SESSION_DETAIL) } }, icon = { Icon(Icons.Filled.Add, contentDescription = null) }, @@ -179,6 +206,14 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { SmallFloatingActionButton(onClick = { showMealDialog = true }) { Icon(Icons.Filled.Add, contentDescription = "Saisir un aliment à la main") } + SmallFloatingActionButton( + onClick = { + viewModel.onFoodQueryChange("") + navController.navigate(FOOD_SEARCH) + }, + ) { + Icon(Icons.Filled.Search, contentDescription = "Chercher un aliment") + } ExtendedFloatingActionButton( onClick = { viewModel.resetScan() @@ -237,12 +272,27 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { onDelete = viewModel::deleteMeal, ) } + composable(FOOD_SEARCH) { + FoodSearchScreen( + state = foodSearchState, + onQueryChange = viewModel::onFoodQueryChange, + onRepeat = { meal, mealType -> + viewModel.repeatMeal(meal, mealType) + navController.popBackStack() + }, + onPickProduct = { product -> + viewModel.selectSearchedProduct(product) + navController.navigate(SCAN) + }, + ) + } composable(KAZIS) { KazisScreen( state = kazisState, onSave = viewModel::saveKazisSettings, onFetch = viewModel::fetchKazisMeals, onBackup = viewModel::backupToKazis, + onRestore = viewModel::restoreFromKazis, ) } composable(HEALTH) { 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 37184c8..129f439 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt @@ -10,11 +10,13 @@ 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.FoodSearch import com.kaz229.fittrack.data.HealthConnectAvailability import com.kaz229.fittrack.data.KazisBackup import com.kaz229.fittrack.data.KazisFetch +import com.kaz229.fittrack.data.KazisRestore import com.kaz229.fittrack.data.KazisSettings -import com.kaz229.fittrack.data.FoodProduct import com.kaz229.fittrack.data.Labels import com.kaz229.fittrack.data.MacroGoals import com.kaz229.fittrack.data.MacroTotals @@ -24,6 +26,8 @@ import com.kaz229.fittrack.data.Session import com.kaz229.fittrack.data.SyncResult import com.kaz229.fittrack.data.UserProfile import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -46,6 +50,16 @@ data class CatalogState( val loading: Boolean = true, ) +/** Ce que l'écran de recherche d'aliments affiche. */ +data class FoodSearchState( + val query: String = "", + val searching: Boolean = false, + val history: List = emptyList(), + val products: List = emptyList(), +) { + val isEmpty: Boolean get() = history.isEmpty() && products.isEmpty() +} + /** Ce que l'écran « repas dictés » doit afficher. */ data class KazisState( val settings: KazisSettings = KazisSettings(), @@ -54,6 +68,8 @@ data class KazisState( val backingUp: Boolean = false, val lastBackupEpochSecond: Long? = null, val lastBackupResult: KazisBackup? = null, + val restoring: Boolean = false, + val lastRestoreResult: KazisRestore? = null, ) /** Ce que l'écran Health Connect doit afficher. */ @@ -226,6 +242,48 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application _scan.value = ScanState() } + // --- Recherche d'aliments ------------------------------------------------ + + private val foodQuery = MutableStateFlow("") + private val foodSearching = MutableStateFlow(false) + private val foodResults = MutableStateFlow(FoodSearch()) + + val foodSearch: StateFlow = combine( + foodQuery, + foodSearching, + foodResults, + ) { query, searching, results -> + FoodSearchState(query, searching, results.history, results.products) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FoodSearchState()) + + private var searchJob: Job? = null + + fun onFoodQueryChange(value: String) { + foodQuery.value = value + searchJob?.cancel() + if (value.trim().length < 2) { + foodResults.value = FoodSearch() + foodSearching.value = false + return + } + searchJob = viewModelScope.launch { + // Laisser finir la frappe : sans cela, chaque lettre partirait en requête + delay(350) + foodSearching.value = true + foodResults.value = repository.searchFoods(value) + foodSearching.value = false + } + } + + fun repeatMeal(meal: Meal, mealType: MealType) = viewModelScope.launch { + repository.repeatMeal(meal, mealType, today) + } + + /** Un produit choisi dans la recherche rejoint l'écran de pesée, comme après un scan. */ + fun selectSearchedProduct(product: FoodProduct) { + _scan.value = ScanState(barcode = product.barcode, product = product, fromCache = true) + } + // --- Repas dictés à Kazis ------------------------------------------------ private val kazisFetching = MutableStateFlow(false) @@ -233,21 +291,27 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application private val backingUp = MutableStateFlow(false) private val backupResult = MutableStateFlow(null) + private val restoring = MutableStateFlow(false) + private val restoreResult = MutableStateFlow(null) val kazis: StateFlow = combine( repository.kazisSettings, kazisFetching, kazisResult, - backingUp, + combine(backingUp, restoring, restoreResult) { saving, loading, restored -> + Triple(saving, loading, restored) + }, combine(repository.lastKazisBackup, backupResult) { last, result -> last to result }, - ) { settings, fetching, result, saving, backup -> + ) { settings, fetching, result, progress, backup -> KazisState( settings = settings, fetching = fetching, lastResult = result, - backingUp = saving, + backingUp = progress.first, lastBackupEpochSecond = backup.first, lastBackupResult = backup.second, + restoring = progress.second, + lastRestoreResult = progress.third, ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), KazisState()) @@ -260,6 +324,12 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application } } + fun restoreFromKazis() = viewModelScope.launch { + restoring.value = true + restoreResult.value = repository.restoreFromKazis() + restoring.value = false + } + fun backupToKazis() = viewModelScope.launch { backingUp.value = true backupResult.value = repository.backupToKazis() diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/FoodSearchScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/FoodSearchScreen.kt new file mode 100644 index 0000000..0fb45d3 --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/FoodSearchScreen.kt @@ -0,0 +1,182 @@ +package com.kaz229.fittrack.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.kaz229.fittrack.data.FoodProduct +import com.kaz229.fittrack.data.Meal +import com.kaz229.fittrack.data.MealType +import com.kaz229.fittrack.ui.FoodSearchState +import com.kaz229.fittrack.ui.formatGrams +import kotlin.math.roundToInt + +/** + * Recherche d'un aliment par son nom. + * + * Ce qu'on a déjà mangé passe devant : on remange surtout les mêmes choses, et + * l'historique répond sans réseau. Les produits d'Open Food Facts viennent ensuite. + */ +@Composable +fun FoodSearchScreen( + state: FoodSearchState, + onQueryChange: (String) -> Unit, + onRepeat: (Meal, MealType) -> Unit, + onPickProduct: (FoodProduct) -> Unit, + modifier: Modifier = Modifier, +) { + var mealType by remember { mutableStateOf(MealType.LUNCH) } + + LazyColumn( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + OutlinedTextField( + value = state.query, + onValueChange = onQueryChange, + label = { Text("Chercher un aliment") }, + placeholder = { Text("skyr, poulet, riz…") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + item { + 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) }, + ) + } + } + } + + if (state.history.isNotEmpty()) { + item { SectionTitle("Déjà mangé") } + items(state.history, key = { "h${it.id}" }) { meal -> + HistoryRow(meal = meal, onClick = { onRepeat(meal, mealType) }) + } + } + + if (state.products.isNotEmpty()) { + item { SectionTitle("Open Food Facts") } + items(state.products, key = { "p${it.barcode}" }) { product -> + ProductRow(product = product, onClick = { onPickProduct(product) }) + } + } + + if (state.searching) { + item { CircularProgressIndicator() } + } + if (!state.searching && state.query.length >= 2 && state.isEmpty) { + item { + Text( + text = "Rien trouvé pour « ${state.query} ». Scanne le code-barres, " + + "ou saisis l'aliment à la main.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + if (state.query.length < 2) { + item { + Text( + text = "Tape au moins deux lettres. Tes aliments déjà enregistrés " + + "apparaissent en premier, et se réinscrivent en un tap.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + HorizontalDivider() + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp), + ) + } +} + +@Composable +private fun HistoryRow(meal: Meal, onClick: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = meal.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = buildString { + meal.grams?.let { append("${formatGrams(it)} · ") } + append("${meal.calories} kcal") + if (meal.hasMacros) { + append(" · P ${formatGrams(meal.proteinG)}") + append(" · G ${formatGrams(meal.carbsG)}") + append(" · L ${formatGrams(meal.fatG)}") + } + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun ProductRow(product: FoodProduct, onClick: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = product.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Pour 100 g : ${product.kcalPer100g.roundToInt()} kcal · " + + "P ${formatGrams(product.proteinPer100g)} · " + + "G ${formatGrams(product.carbsPer100g)} · " + + "L ${formatGrams(product.fatPer100g)}", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt index 2614d0b..a33bcab 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt @@ -143,6 +143,12 @@ fun HealthConnectScreen( "des doublons.", style = MaterialTheme.typography.bodySmall, ) + Text( + text = "Les séances enregistrées par une autre application (Lyfta, une montre…) " + + "sont reprises dans ton historique, sans doublon : chacune garde son " + + "identifiant d'origine.", + style = MaterialTheme.typography.bodySmall, + ) Text( text = "FitTrack ne connaît que le jour d'une séance, jamais son heure. Les " + "séances sont donc placées en fin d'après-midi et les repas à l'heure " + @@ -180,6 +186,12 @@ private fun ResultCard(result: SyncResult, onUseWeight: (Double) -> Unit) { "de repas envoyées.", style = MaterialTheme.typography.bodyMedium, ) + if (result.imported > 0) { + Text( + text = "${result.imported} séance(s) reprise(s) d'une autre application.", + style = MaterialTheme.typography.bodyMedium, + ) + } result.weightKg?.let { weight -> Text( text = "Poids trouvé dans Health Connect : ${formatWeight(weight)}.", diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/KazisScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/KazisScreen.kt index 4bcbd0b..1c12e2e 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/KazisScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/KazisScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import com.kaz229.fittrack.data.KazisBackup import com.kaz229.fittrack.data.KazisFetch +import com.kaz229.fittrack.data.KazisRestore import com.kaz229.fittrack.data.KazisSettings import com.kaz229.fittrack.ui.KazisState @@ -42,6 +43,7 @@ fun KazisScreen( onSave: (KazisSettings) -> Unit, onFetch: () -> Unit, onBackup: () -> Unit, + onRestore: () -> Unit, modifier: Modifier = Modifier, ) { var url by remember(state.settings.url) { mutableStateOf(state.settings.url) } @@ -120,6 +122,18 @@ fun KazisScreen( ) { Text(if (state.backingUp) "Sauvegarde…" else "Sauvegarder maintenant") } state.lastBackupEpochSecond?.let { LastBackupLine(it) } state.lastBackupResult?.let { BackupCard(it) } + + OutlinedButton( + onClick = onRestore, + enabled = !state.restoring, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (state.restoring) "Restauration…" else "Restaurer depuis Kazis") } + Text( + text = "À n'utiliser que sur une application vide, après une réinstallation " + + "ou un changement de téléphone.", + style = MaterialTheme.typography.bodySmall, + ) + state.lastRestoreResult?.let { RestoreCard(it) } } HorizontalDivider() @@ -183,6 +197,36 @@ private fun BackupCard(result: KazisBackup) { } } +@Composable +private fun RestoreCard(result: KazisRestore) { + val (title, body) = when (result) { + is KazisRestore.Success -> + "Restauré" to "${result.sessions} séance(s), ${result.sets} série(s) et " + + "${result.meals} ligne(s) de repas sont revenues." + + KazisRestore.Empty -> + "Rien à restaurer" to "Kazis ne conserve aucune sauvegarde pour l'instant." + + KazisRestore.NotConfigured -> + "À configurer" to "Renseigne l'adresse du serveur et le jeton." + + KazisRestore.Unauthorized -> + "Jeton refusé" to "Le serveur n'a pas accepté ce jeton." + + is KazisRestore.Failed -> + "Restauration impossible" to result.message.replaceFirstChar { it.uppercase() } + "." + } + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = title, fontWeight = FontWeight.SemiBold) + Text(text = body, style = MaterialTheme.typography.bodyMedium) + } + } +} + @Composable private fun ResultCard(result: KazisFetch) { val (title, body) = when (result) { diff --git a/app/src/test/java/com/kaz229/fittrack/KazisTest.kt b/app/src/test/java/com/kaz229/fittrack/KazisTest.kt index 7527ef2..e09be55 100644 --- a/app/src/test/java/com/kaz229/fittrack/KazisTest.kt +++ b/app/src/test/java/com/kaz229/fittrack/KazisTest.kt @@ -93,6 +93,51 @@ class KazisTest { assertNull(meal.grams) } + @Test + fun `une archive est relue avec ses charges`() { + val json = """ + {"device": "pixel", "devices": ["pixel"], + "sessions": [{"id": 4, "kind": "gym", "day": "2026-11-02", "activity": null, + "duration_min": 75, "kcal": 492, "note": "Push day"}], + "sets": [{"id": 1, "session_id": 4, "exercise_id": "0025", "position": 0, + "reps": 10, "weight_kg": 60.0, "day": "2026-11-02"}, + {"id": 2, "session_id": 4, "exercise_id": "0025", "position": 1, + "reps": 8, "weight_kg": 65.0, "day": "2026-11-02"}], + "meals": [{"id": 1, "name": "ravioles fraiches", "calories": 420, "protein_g": 24.0, + "carbs_g": 36.0, "fat_g": 21.0, "meal_type": "dejeuner", "day": "2026-11-02"}]} + """.trimIndent() + + val archive = KazisClient.parseArchive(json) + + // Les identifiants d'origine sont conservés : les séries pointent vers leur séance + assertEquals(4L, archive.sessions.single().id) + assertEquals("Push day", archive.sessions.single().note) + assertEquals(listOf(10 to 60.0, 8 to 65.0), archive.sets.map { it.reps to it.weightKg }) + assertTrue(archive.sets.all { it.sessionId == 4L }) + assertEquals(420, archive.meals.single().calories) + assertFalse(archive.isEmpty) + } + + @Test + fun `une archive vide est reconnue`() { + val vide = KazisClient.parseArchive("""{"device": "", "sessions": [], "sets": [], "meals": []}""") + + assertTrue(vide.isEmpty) + assertTrue(KazisClient.parseArchive("{}").isEmpty) + } + + @Test + fun `une ligne illisible n'empeche pas de restaurer le reste`() { + val json = """ + {"sessions": [{"id": 1, "kind": "gym", "day": "jamais"}, + {"id": 2, "kind": "gym", "day": "2026-11-02"}], + "sets": [], "meals": []} + """.trimIndent() + + // Mieux vaut récupérer une séance que d'abandonner toute la restauration + assertEquals(listOf(2L), KazisClient.parseArchive(json).sessions.map { it.id }) + } + @Test fun `les reglages ne sont complets qu'avec l'adresse et le jeton`() { assertFalse(KazisSettings().configured) diff --git a/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt b/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt index 40d47f4..f3f36ef 100644 --- a/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt +++ b/app/src/test/java/com/kaz229/fittrack/NutritionTest.kt @@ -135,6 +135,48 @@ class NutritionTest { assertEquals(FoodLookup.NoNutrition, OpenFoodFactsClient.parseProduct("123", json)) } + @Test + fun `une recherche rend les produits avec leurs valeurs`() { + val json = """ + {"hits": [ + {"code": "4316268627979", "product_name": "Skyr nature", "brands": ["Lidl"], + "nutriments": {"energy-kcal_100g": 65, "proteins_100g": 10.6, + "carbohydrates_100g": 4.4, "fat_100g": 0.2}}, + {"code": "8710624358174", "product_name": "Skyr vanille", "brands": ["Arla"], + "nutriments": {"energy-kcal_100g": 85, "proteins_100g": 9.0, + "carbohydrates_100g": 10.0, "fat_100g": 0.2}} + ]} + """.trimIndent() + + val produits = OpenFoodFactsClient.parseSearch(json) + + assertEquals(listOf("Skyr nature", "Skyr vanille"), produits.map { it.name }) + // « brands » est un tableau ici, une chaîne dans la recherche par code-barres + assertEquals("Lidl", produits.first().brands) + assertEquals(65.0, produits.first().kcalPer100g, 0.001) + assertEquals(10.6, produits.first().proteinPer100g, 0.001) + } + + @Test + fun `un resultat sans calories ou sans nom est ecarte`() { + val json = """ + {"hits": [ + {"code": "1", "product_name": "Sans calories", "nutriments": {"proteins_100g": 5}}, + {"code": "2", "nutriments": {"energy-kcal_100g": 100}}, + {"code": "3", "product_name": "Complet", "nutriments": {"energy-kcal_100g": 100}} + ]} + """.trimIndent() + + // Un produit sans calories n'apporte rien au journal : autant ne pas le proposer + assertEquals(listOf("Complet"), OpenFoodFactsClient.parseSearch(json).map { it.name }) + } + + @Test + fun `une recherche sans resultat ne rend rien`() { + assertTrue(OpenFoodFactsClient.parseSearch("""{"hits": []}""").isEmpty()) + assertTrue(OpenFoodFactsClient.parseSearch("{}").isEmpty()) + } + @Test fun `le nom affiche ne repete pas la marque`() { assertEquals("Nutella", nutella.copy(brands = "Nutella").displayName) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b7e8b8e..a2c1b42 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ navigation = "2.8.5" room = "2.6.1" datastore = "1.1.1" healthConnect = "1.1.0" +work = "2.12.0" concurrentFutures = "1.3.0" camerax = "1.6.2" mlkitBarcode = "17.3.0" @@ -36,6 +37,7 @@ 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-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } androidx-health-connect-client = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" } androidx-concurrent-futures-ktx = { group = "androidx.concurrent", name = "concurrent-futures-ktx", version.ref = "concurrentFutures" } androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }