From 3f22228ce399a8c278377fc6e7cb52a80f6125f3 Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Mon, 2 Nov 2026 14:49:52 +0100 Subject: [PATCH 1/5] =?UTF-8?q?Place=20l'onglet=20Repas=20juste=20apr?= =?UTF-8?q?=C3=A8s=20l'Accueil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le journal alimentaire se remplit plusieurs fois par jour, les séances une fois. L'ordre des onglets suit maintenant la fréquence d'usage : Accueil, Repas, Séances, Exercices. --- app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..4567f3c 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -62,9 +62,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" From a838e1c2f66bd09f02653cda1acdf5a085f449ab Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Mon, 2 Nov 2026 14:53:23 +0100 Subject: [PATCH 2/5] =?UTF-8?q?Restaure=20la=20sauvegarde=20conserv=C3=A9e?= =?UTF-8?q?=20par=20Kazis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La sauvegarde n'était qu'une copie : rien ne la réinjectait dans une app réinstallée. Le bouton « Restaurer depuis Kazis » récupère séances, séries et journal, identifiants d'origine compris — sans quoi les séries ne retrouveraient pas leur séance. La restauration est refusée si l'application contient déjà des données : les identifiants restaurés écraseraient ce qui a été créé depuis. C'est le cas d'un téléphone neuf, le seul où elle a un sens. Une ligne illisible est ignorée plutôt que d'abandonner toute la restauration. --- .../java/com/kaz229/fittrack/data/Daos.kt | 10 +++ .../fittrack/data/FitTrackRepository.kt | 27 +++++++ .../java/com/kaz229/fittrack/data/Kazis.kt | 72 +++++++++++++++++++ .../com/kaz229/fittrack/ui/FitTrackApp.kt | 1 + .../kaz229/fittrack/ui/FitTrackViewModel.kt | 21 +++++- .../kaz229/fittrack/ui/screens/KazisScreen.kt | 44 ++++++++++++ .../java/com/kaz229/fittrack/KazisTest.kt | 45 ++++++++++++ 7 files changed, 217 insertions(+), 3 deletions(-) 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..08624d8 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,10 @@ interface SessionDao { @Query("SELECT * FROM sessions ORDER BY id") suspend fun all(): 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 +85,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 +109,9 @@ interface MealDao { @Query("SELECT * FROM meals ORDER BY id") suspend fun all(): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(meals: List) + @Query("SELECT COALESCE(SUM(calories), 0) FROM meals WHERE epochDay = :epochDay") fun observeCaloriesForDay(epochDay: Long): Flow 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..1e58e2d 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -315,6 +315,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 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/ui/FitTrackApp.kt b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt index 4567f3c..412a25a 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -243,6 +243,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { 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..8a217b4 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt @@ -13,6 +13,7 @@ import com.kaz229.fittrack.data.FoodLookup 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 @@ -54,6 +55,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. */ @@ -233,21 +236,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 +269,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/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) From 37d789e26b7e17127c861fe3b4031166040606fe Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Mon, 2 Nov 2026 14:54:49 +0100 Subject: [PATCH 3/5] =?UTF-8?q?Reprend=20les=20s=C3=A9ances=20enregistr?= =?UTF-8?q?=C3=A9es=20par=20une=20autre=20application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une séance faite dans Lyfta ou sur une montre n'apparaissait pas dans FitTrack, alors que Health Connect la porte déjà. L'app lit maintenant les séances écrites par d'autres applications et les ajoute à l'historique, dans la foulée de la synchronisation. Celles que FitTrack a elle-même écrites sont écartées : les réimporter créerait un doublon à chaque passage. L'identifiant Health Connect est conservé sur la séance, avec un index unique (migration 2 → 3) : c'est ce qui garantit qu'une séance déjà reprise ne revient pas. Les calories viennent de la formule MET sur la durée annoncée, faute de mieux : l'application d'origine ne les expose pas toujours. --- app/src/main/AndroidManifest.xml | 3 + .../java/com/kaz229/fittrack/data/Daos.kt | 4 ++ .../kaz229/fittrack/data/FitTrackDatabase.kt | 18 +++++- .../fittrack/data/FitTrackRepository.kt | 38 +++++++++++ .../com/kaz229/fittrack/data/HealthConnect.kt | 64 ++++++++++++++++++- .../java/com/kaz229/fittrack/data/Model.kt | 10 ++- .../ui/screens/HealthConnectScreen.kt | 12 ++++ 7 files changed, 145 insertions(+), 4 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5a86cd6..3aaf305 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,9 @@ + + + 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 08624d8..161659c 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,10 @@ 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) 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 1e58e2d..db8a387 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -352,6 +352,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 @@ -368,6 +404,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 } 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/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/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)}.", From 1739ba88e1eee52910f4b7854ad4df20033e78eb Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Mon, 2 Nov 2026 14:59:37 +0100 Subject: [PATCH 4/5] =?UTF-8?q?Rappelle=20une=20s=C3=A9ance=20laiss=C3=A9e?= =?UTF-8?q?=20ouverte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 dix minutes sans nouvelle série, l'app demande si on est toujours là. Chaque série repousse le rappel plutôt que d'en empiler un second : seul le silence le déclenche. Il est annulé dès que la séance est terminée ou supprimée, et le worker revérifie l'état avant de notifier — la séance a pu être close entre-temps. L'autorisation de notifier est demandée au démarrage d'une séance, moment où elle prend son sens. Sans elle, l'app n'insiste pas. Vérifié sur appareil : la notification s'affiche bien, avec son titre et sa durée. --- README.md | 10 +- app/build.gradle.kts | 1 + .../kaz229/fittrack/SessionReminderTest.kt | 85 +++++++++++++ app/src/main/AndroidManifest.xml | 2 + .../fittrack/data/FitTrackRepository.kt | 17 ++- .../kaz229/fittrack/data/SessionReminder.kt | 120 ++++++++++++++++++ .../com/kaz229/fittrack/ui/FitTrackApp.kt | 22 ++++ gradle/libs.versions.toml | 2 + 8 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/java/com/kaz229/fittrack/SessionReminderTest.kt create mode 100644 app/src/main/java/com/kaz229/fittrack/data/SessionReminder.kt diff --git a/README.md b/README.md index 814a6d2..ded235d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ 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. @@ -58,7 +62,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 +104,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/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 3aaf305..3edc0f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + () + .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 412a25a..08d09a3 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 @@ -32,6 +37,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 @@ -84,6 +91,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) } @@ -166,6 +185,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) }, 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" } From d1d095b1ebd892e3add8eff33c6428c96efe3596 Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Mon, 2 Nov 2026 15:11:10 +0100 Subject: [PATCH 5/5] Cherche un aliment par son nom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retrouver un aliment supposait de rescanner son code-barres ou de tout ressaisir, même pour quelque chose mangé l'avant-veille. L'écran de recherche propose d'abord l'historique : « sky » retrouve le skyr enregistré il y a deux jours, qui se réinscrit en un tap, au repas choisi et à la date du jour. D'un même aliment, seule la dernière version est proposée : c'est celle dont les quantités sont à jour. Viennent ensuite les produits déjà scannés, puis ceux d'Open Food Facts. L'historique passe devant parce qu'on remange surtout les mêmes choses et qu'il répond sans réseau. La recherche par nom passe par search.openfoodfacts.org : l'ancienne (/cgi/search.pl) est régulièrement hors service, et celle-ci rend directement les valeurs nutritionnelles. Un produit sans calories ou sans nom est écarté, il n'apporterait rien au journal. La frappe est laissée finir avant d'interroger le réseau, sinon chaque lettre partirait en requête. --- README.md | 3 + .../com/kaz229/fittrack/FoodSearchTest.kt | 106 ++++++++++ .../java/com/kaz229/fittrack/data/Daos.kt | 19 ++ .../fittrack/data/FitTrackRepository.kt | 27 +++ .../com/kaz229/fittrack/data/Nutrition.kt | 13 ++ .../com/kaz229/fittrack/data/OpenFoodFacts.kt | 53 +++++ .../com/kaz229/fittrack/ui/FitTrackApp.kt | 27 +++ .../kaz229/fittrack/ui/FitTrackViewModel.kt | 57 +++++- .../fittrack/ui/screens/FoodSearchScreen.kt | 182 ++++++++++++++++++ .../java/com/kaz229/fittrack/NutritionTest.kt | 42 ++++ 10 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/kaz229/fittrack/FoodSearchTest.kt create mode 100644 app/src/main/java/com/kaz229/fittrack/ui/screens/FoodSearchScreen.kt diff --git a/README.md b/README.md index ded235d..f1e9510 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ Rien d'autre ne sort du téléphone, et les deux restent facultatifs. - **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. 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/main/java/com/kaz229/fittrack/data/Daos.kt b/app/src/main/java/com/kaz229/fittrack/data/Daos.kt index 161659c..bf5a490 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Daos.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Daos.kt @@ -116,6 +116,18 @@ interface MealDao { @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 @@ -143,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/FitTrackRepository.kt b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt index 14ad745..348eb5e 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -160,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 ---------------------------------------------------- /** 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/ui/FitTrackApp.kt b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt index 08d09a3..5b44b68 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -18,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 @@ -53,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 @@ -80,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 @@ -117,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() @@ -131,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" } @@ -201,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() @@ -259,6 +272,20 @@ 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, 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 8a217b4..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,12 +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 @@ -25,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 @@ -47,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(), @@ -229,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) 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/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)