diff --git a/README.md b/README.md index df33ffe..814a6d2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ Deux exceptions au « tout local » : - **Open Food Facts** : quand tu scannes un code-barres, ce code-barres est envoyé à [Open Food Facts](https://world.openfoodfacts.org) pour récupérer les valeurs nutritionnelles du produit. Le produit reçu est mis en cache localement pour rester consultable hors ligne. +- **Kazis**, uniquement si tu le configures : les repas dictés à ton assistant personnel sont + récupérés depuis ton serveur, sur ton réseau privé, et tes séances, séries et repas y sont + sauvegardés. C'est la seule copie de tes charges hors du téléphone. - **Health Connect**, uniquement si tu l'actives : tes séances et ton journal alimentaire sont partagés avec le magasin de santé d'Android, qui les garde sur l'appareil et ne les transmet qu'aux applications que tu autorises explicitement. @@ -56,6 +59,7 @@ corde à sauter 11,0 · rameur 7,0…). C'est une estimation, pas une mesure mé | 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) | +| 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` | Architecture : `data` (entités Room, DAO, catalogue, repository) → `ui` (ViewModel exposant des @@ -95,6 +99,7 @@ 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 │ ├── 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/src/androidTest/java/com/kaz229/fittrack/KazisBackupTest.kt b/app/src/androidTest/java/com/kaz229/fittrack/KazisBackupTest.kt new file mode 100644 index 0000000..c97e129 --- /dev/null +++ b/app/src/androidTest/java/com/kaz229/fittrack/KazisBackupTest.kt @@ -0,0 +1,118 @@ +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.KazisBackup +import com.kaz229.fittrack.data.KazisClient +import com.kaz229.fittrack.data.KazisSettings +import com.kaz229.fittrack.data.MealType +import com.kaz229.fittrack.data.UserPreferences +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 + +/** + * Sauvegarde du contenu de l'app vers Kazis, contre un vrai serveur. + * + * Sans serveur joignable, le test vérifie seulement que l'échec est propre, et le dit + * dans logcat pour qu'un vert ne trompe personne. + */ +@RunWith(AndroidJUnit4::class) +class KazisBackupTest { + + private lateinit var db: FitTrackDatabase + private lateinit var repository: FitTrackRepository + private lateinit var preferences: UserPreferences + private val today: LocalDate = LocalDate.now() + + private val url = "http://10.0.2.2:8799" + private val token = "jeton-de-test" + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FitTrackDatabase::class.java).build() + preferences = UserPreferences(context) + repository = FitTrackRepository( + sessionDao = db.sessionDao(), + exerciseSetDao = db.exerciseSetDao(), + mealDao = db.mealDao(), + foodProductDao = db.foodProductDao(), + userPreferences = preferences, + exercises = ExerciseCatalog.get(context), + kazis = KazisClient(), + ) + } + + @After + fun tearDown() { + db.close() + } + + private suspend fun remplir() { + val id = repository.startGymSession(today) + repository.addSet(id, "0025", reps = 10, weightKg = 60.0) + repository.addSet(id, "0025", reps = 8, weightKg = 65.0) + repository.finishGymSession(id, durationMinutes = 75, note = "Push day") + repository.addMeal("ravioles fraiches", 420, MealType.LUNCH, 24.0, 36.0, 21.0, today) + } + + @Test + fun sans_reglages_rien_n_est_envoye() = runTest { + preferences.setKazis(KazisSettings()) + + assertEquals(KazisBackup.NotConfigured, repository.backupToKazis()) + } + + @Test + fun un_jeton_refuse_est_signale() = runTest { + preferences.setKazis(KazisSettings(url, "mauvais-jeton")) + + val result = repository.backupToKazis() + + if (result is KazisBackup.Failed) { + Log.w(TAG, "IGNORÉ : serveur de test injoignable ($result)") + return@runTest + } + assertEquals(KazisBackup.Unauthorized, result) + } + + @Test + fun tout_le_contenu_part_en_sauvegarde() = runTest { + preferences.setKazis(KazisSettings(url, token)) + remplir() + + val result = repository.backupToKazis() + if (result is KazisBackup.Failed) { + Log.w(TAG, "IGNORÉ : serveur de test injoignable ($result)") + return@runTest + } + Log.i(TAG, "Sauvegarde : $result") + + assertTrue("Attendu un succès, obtenu $result", result is KazisBackup.Success) + val success = result as KazisBackup.Success + assertEquals(1, success.sessions) + // Les séries sont le cœur : ce que Health Connect ne conserve pas. + assertEquals(2, success.sets) + assertEquals(1, success.meals) + + // Rejouée, la sauvegarde renvoie la même chose : côté serveur rien ne double. + val again = repository.backupToKazis() + assertEquals(result, again) + } + + private companion object { + const val TAG = "KazisBackupTest" + } +} diff --git a/app/src/androidTest/java/com/kaz229/fittrack/KazisFetchTest.kt b/app/src/androidTest/java/com/kaz229/fittrack/KazisFetchTest.kt new file mode 100644 index 0000000..a5bcc1d --- /dev/null +++ b/app/src/androidTest/java/com/kaz229/fittrack/KazisFetchTest.kt @@ -0,0 +1,126 @@ +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.KazisClient +import com.kaz229.fittrack.data.KazisFetch +import com.kaz229.fittrack.data.KazisSettings +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 + +/** + * Récupération des repas dictés, contre un vrai serveur Kazis. + * + * Le serveur de test tourne sur la machine hôte, joignable depuis l'émulateur par + * 10.0.2.2. Sans lui, le test constate seulement que l'échec est propre et que rien + * n'est enregistré — et le dit dans logcat, pour qu'un vert ne trompe personne. + */ +@RunWith(AndroidJUnit4::class) +class KazisFetchTest { + + private lateinit var db: FitTrackDatabase + private lateinit var repository: FitTrackRepository + private lateinit var preferences: UserPreferences + private val today: LocalDate = LocalDate.now() + + private val url = "http://10.0.2.2:8799" + private val token = "jeton-de-test" + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FitTrackDatabase::class.java).build() + preferences = UserPreferences(context) + repository = FitTrackRepository( + sessionDao = db.sessionDao(), + exerciseSetDao = db.exerciseSetDao(), + mealDao = db.mealDao(), + foodProductDao = db.foodProductDao(), + userPreferences = preferences, + exercises = ExerciseCatalog.get(context), + kazis = KazisClient(), + ) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun sans_reglages_rien_n_est_tente() = runTest { + preferences.setKazis(KazisSettings()) + + assertEquals(KazisFetch.NotConfigured, repository.fetchKazisMeals(today)) + assertTrue(repository.mealsForDay(today).first().isEmpty()) + } + + @Test + fun un_jeton_refuse_est_signale_sans_rien_enregistrer() = runTest { + preferences.setKazis(KazisSettings(url, "mauvais-jeton")) + + val result = repository.fetchKazisMeals(today) + + if (result is KazisFetch.Failed) { + Log.w(TAG, "IGNORÉ : serveur de test injoignable ($result)") + return@runTest + } + assertEquals(KazisFetch.Unauthorized, result) + assertTrue(repository.mealsForDay(today).first().isEmpty()) + } + + @Test + fun les_repas_dictes_arrivent_dans_le_journal() = runTest { + preferences.setKazis(KazisSettings(url, token)) + + val result = repository.fetchKazisMeals(today) + if (result is KazisFetch.Failed) { + Log.w(TAG, "IGNORÉ : serveur de test injoignable ($result)") + return@runTest + } + Log.i(TAG, "Récupération : $result") + + assertTrue("Attendu un succès, obtenu $result", result is KazisFetch.Success) + val meals = repository.mealsForDay(today).first() + assertTrue("Aucun repas enregistré", meals.isNotEmpty()) + + val riz = meals.firstOrNull { it.name.contains("riz") } + if (riz != null) { + assertEquals(195, riz.calories) + assertEquals(4.0, riz.proteinG, 0.001) + assertEquals(MealType.LUNCH.id, riz.mealType) + } + + // Le produit donné par code-barres doit avoir été résolu chez Open Food Facts. + val produit = meals.firstOrNull { it.barcode == "3017620422003" } + if (produit != null) { + Log.i(TAG, "Produit résolu : ${produit.name} ${produit.calories} kcal") + assertTrue("Le produit scanné doit porter ses calories", produit.calories > 0) + assertEquals(30.0, produit.grams!!, 0.001) + } + + // Deuxième passage : la file a été vidée par l'accusé de réception. + val again = repository.fetchKazisMeals(today) + assertTrue(again is KazisFetch.Success) + assertEquals(0, (again as KazisFetch.Success).added) + } + + private companion object { + const val TAG = "KazisFetchTest" + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ad7055b..5a86cd6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,6 +31,7 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.FitTrack"> 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 4cfe9dd..10d0a02 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Daos.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Daos.kt @@ -32,6 +32,10 @@ interface SessionDao { @Query("SELECT * FROM sessions WHERE epochDay >= :fromDay ORDER BY epochDay, id") suspend fun since(fromDay: Long): List + /** Tout l'historique, pour la sauvegarde. */ + @Query("SELECT * FROM sessions ORDER BY id") + suspend fun all(): List + @Query("SELECT COALESCE(SUM(caloriesBurned), 0) FROM sessions WHERE epochDay = :epochDay") fun observeCaloriesForDay(epochDay: Long): Flow @@ -73,6 +77,10 @@ interface ExerciseSetDao { @Query("SELECT COUNT(*) FROM exercise_sets WHERE epochDay = :epochDay") fun observeSetCountForDay(epochDay: Long): Flow + + /** 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 } @Dao @@ -90,6 +98,10 @@ interface MealDao { @Query("SELECT * FROM meals WHERE epochDay >= :fromDay ORDER BY epochDay, id") suspend fun since(fromDay: Long): List + /** Tout le journal, pour la sauvegarde. */ + @Query("SELECT * FROM meals ORDER BY id") + suspend fun all(): 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 289445a..7e55d3a 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -20,6 +20,7 @@ class FitTrackRepository( val exercises: ExerciseCatalog, private val openFoodFacts: OpenFoodFactsClient = OpenFoodFactsClient(), private val healthConnect: HealthConnectManager? = null, + private val kazis: KazisClient = KazisClient(), ) { val profile: Flow = userPreferences.profile @@ -228,6 +229,92 @@ class FitTrackRepository( } } + // --- Repas dictés à Kazis ------------------------------------------------ + + val kazisSettings: Flow = userPreferences.kazis + + suspend fun setKazisSettings(settings: KazisSettings) = userPreferences.setKazis(settings) + + /** + * Récupère les repas déposés par un assistant, les enregistre, puis confirme. + * + * Un repas donné par code-barres arrive sans valeurs : c'est ici qu'il est résolu + * chez Open Food Facts, en passant par le cache. Un produit introuvable est laissé + * dans la file plutôt que d'être enregistré faux — il sera retenté, et reste + * annulable depuis Telegram. + */ + suspend fun fetchKazisMeals(today: LocalDate = LocalDate.now()): KazisFetch { + val settings = userPreferences.kazis.first() + if (!settings.configured) return KazisFetch.NotConfigured + return try { + val pending = kazis.pendingMeals(settings.url, settings.token) + val saved = mutableListOf() + var skipped = 0 + for (meal in pending) { + if (saveKazisMeal(meal, today)) saved += meal.id else skipped++ + } + kazis.acknowledge(settings.url, settings.token, saved) + KazisFetch.Success(added = saved.size, skipped = skipped) + } catch (_: KazisUnauthorized) { + KazisFetch.Unauthorized + } catch (e: Exception) { + KazisFetch.Failed(e.message ?: "serveur injoignable") + } + } + + /** Enregistre un repas venu de Kazis. Faux si le produit n'a pas pu être résolu. */ + private suspend fun saveKazisMeal(meal: KazisMeal, today: LocalDate): Boolean { + if (!meal.needsLookup) { + mealDao.insert( + Meal( + name = meal.name, + calories = meal.calories, + mealType = meal.mealType.id, + epochDay = meal.day.toEpochDay(), + proteinG = meal.proteinG, + carbsG = meal.carbsG, + fatG = meal.fatG, + barcode = meal.barcode, + grams = meal.grams, + ), + ) + return true + } + val lookup = lookupFood(meal.barcode!!, today) + if (lookup !is FoodLookup.Found) return false + addFoodMeal(lookup.product, meal.grams!!, meal.mealType, meal.day) + return true + } + + val lastKazisBackup: Flow = userPreferences.lastBackup + + /** + * Envoie tout le contenu de l'app à Kazis, qui le conserve. + * + * C'est la seule copie hors du téléphone des séries et des charges : Health Connect + * ne les porte pas. + */ + suspend fun backupToKazis(): KazisBackup { + val settings = userPreferences.kazis.first() + if (!settings.configured) return KazisBackup.NotConfigured + return try { + val result = kazis.backup( + baseUrl = settings.url, + token = settings.token, + device = userPreferences.deviceId(), + sessions = sessionDao.all(), + sets = exerciseSetDao.all(), + meals = mealDao.all(), + ) + userPreferences.setLastBackup(System.currentTimeMillis() / 1000) + result + } catch (_: KazisUnauthorized) { + KazisBackup.Unauthorized + } catch (e: Exception) { + KazisBackup.Failed(e.message ?: "serveur injoignable") + } + } + // --- Health Connect ------------------------------------------------------ val lastHealthSync: Flow = userPreferences.lastHealthSync @@ -284,6 +371,7 @@ class FitTrackRepository( userPreferences = UserPreferences(context.applicationContext), exercises = ExerciseCatalog.get(context), healthConnect = HealthConnectManager(context.applicationContext), + kazis = KazisClient(), ).also { instance = it } } } diff --git a/app/src/main/java/com/kaz229/fittrack/data/Kazis.kt b/app/src/main/java/com/kaz229/fittrack/data/Kazis.kt new file mode 100644 index 0000000..9e6d4ce --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/data/Kazis.kt @@ -0,0 +1,190 @@ +package com.kaz229.fittrack.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.time.LocalDate + +/** + * Un repas déposé par un assistant sur Kazis, en attente de récupération. + * + * Deux formes : soit tout est renseigné (nom, calories, macros), soit seuls + * [barcode] et [grams] le sont — c'est alors à FitTrack de retrouver le produit + * chez Open Food Facts, avec le cache qu'il possède déjà. + */ +data class KazisMeal( + val id: Long, + val name: String, + val calories: Int, + val proteinG: Double, + val carbsG: Double, + val fatG: Double, + val mealType: MealType, + val day: LocalDate, + val barcode: String?, + val grams: Double?, +) { + /** Un produit sans valeurs : il faut interroger Open Food Facts avant d'enregistrer. */ + val needsLookup: Boolean get() = calories <= 0 && barcode != null && grams != null +} + +/** Ce que la sauvegarde a donné. */ +sealed interface KazisBackup { + data class Success(val sessions: Int, val sets: Int, val meals: Int) : KazisBackup + data object NotConfigured : KazisBackup + data object Unauthorized : KazisBackup + data class Failed(val message: String) : KazisBackup +} + +/** Ce que la récupération a donné, pour l'afficher sans interpréter une exception. */ +sealed interface KazisFetch { + data class Success(val added: Int, val skipped: Int) : KazisFetch + data object NotConfigured : KazisFetch + data object Unauthorized : KazisFetch + data class Failed(val message: String) : KazisFetch +} + +/** + * Client du serveur de synchro Kazis. + * + * Le serveur n'écoute que sur le réseau privé (Tailscale) et exige le même jeton que + * l'app Kazis. Un seul appel HTTP à la fois, donc `HttpURLConnection` suffit. + */ +class KazisClient { + + /** Les repas en attente. Lève [KazisUnauthorized] si le jeton est refusé. */ + suspend fun pendingMeals(baseUrl: String, token: String): List = + withContext(Dispatchers.IO) { + val body = request(baseUrl, token, "/v1/meals", "GET", null) + parseMeals(body) + } + + /** + * Envoie tout le contenu de l'app : séances, séries et journal. + * + * Rien n'est filtré sur la date. L'historique complet est petit (quelques milliers + * de lignes), et le serveur remplace ce qu'il a déjà : une sauvegarde partielle + * laisserait des trous après une restauration. + */ + suspend fun backup( + baseUrl: String, + token: String, + device: String, + sessions: List, + sets: List, + meals: List, + ): KazisBackup.Success = withContext(Dispatchers.IO) { + val payload = JSONObject() + .put("device", device) + .put("sessions", JSONArray().apply { sessions.forEach { put(it.toJson()) } }) + .put("sets", JSONArray().apply { sets.forEach { put(it.toJson()) } }) + .put("meals", JSONArray().apply { meals.forEach { put(it.toJson()) } }) + request(baseUrl, token, "/v1/fittrack", "POST", payload.toString()) + KazisBackup.Success(sessions.size, sets.size, meals.size) + } + + /** 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 + withContext(Dispatchers.IO) { + val payload = JSONObject().put("ids", JSONArray().apply { ids.forEach { put(it) } }) + request(baseUrl, token, "/v1/meals/ack", "POST", payload.toString()) + } + } + + private fun request( + baseUrl: String, + token: String, + path: String, + method: String, + body: String?, + ): String { + val connection = (URL(baseUrl.trimEnd('/') + path).openConnection() as HttpURLConnection).apply { + requestMethod = method + setRequestProperty("Authorization", "Bearer $token") + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + if (body != null) { + doOutput = true + setRequestProperty("Content-Type", "application/json") + } + } + try { + body?.let { connection.outputStream.use { stream -> stream.write(it.toByteArray()) } } + return when (connection.responseCode) { + HttpURLConnection.HTTP_OK -> + connection.inputStream.bufferedReader().use { it.readText() } + HttpURLConnection.HTTP_UNAUTHORIZED -> throw KazisUnauthorized() + else -> throw KazisError("le serveur a répondu ${connection.responseCode}") + } + } finally { + connection.disconnect() + } + } + + companion object { + private const val TIMEOUT_MS = 8_000 + + /** Lit la réponse de `/v1/meals`. Fonction pure, testable sans réseau. */ + fun parseMeals(json: String): List { + val array = JSONObject(json).optJSONArray("meals") ?: return emptyList() + return (0 until array.length()).mapNotNull { index -> + val item = array.optJSONObject(index) ?: return@mapNotNull null + val name = item.optString("name").takeIf { it.isNotBlank() } ?: return@mapNotNull null + val day = runCatching { LocalDate.parse(item.optString("day")) }.getOrNull() + ?: return@mapNotNull null + KazisMeal( + id = item.optLong("id"), + name = name, + calories = item.optInt("calories"), + proteinG = item.optDouble("protein_g", 0.0).orZero(), + carbsG = item.optDouble("carbs_g", 0.0).orZero(), + fatG = item.optDouble("fat_g", 0.0).orZero(), + mealType = MealType.byId(item.optString("meal_type")), + day = day, + barcode = item.optString("barcode").takeIf { it.isNotBlank() && it != "null" }, + grams = item.optDouble("grams").takeIf { !it.isNaN() && it > 0 }, + ) + } + } + + private fun Double.orZero(): Double = if (isNaN()) 0.0 else this + } +} + +private fun Session.toJson(): JSONObject = JSONObject() + .put("id", id) + .put("kind", kind) + .put("day", LocalDate.ofEpochDay(epochDay).toString()) + .put("activity", activityId ?: JSONObject.NULL) + .put("duration_min", durationMinutes) + .put("kcal", caloriesBurned) + .put("note", note) + +private fun ExerciseSet.toJson(): JSONObject = JSONObject() + .put("id", id) + .put("session_id", sessionId) + .put("exercise_id", exerciseId) + .put("position", position) + .put("reps", reps) + .put("weight_kg", weightKg) + .put("day", LocalDate.ofEpochDay(epochDay).toString()) + +private fun Meal.toJson(): JSONObject = JSONObject() + .put("id", id) + .put("name", name) + .put("calories", calories) + .put("protein_g", proteinG) + .put("carbs_g", carbsG) + .put("fat_g", fatG) + .put("meal_type", mealType) + .put("day", LocalDate.ofEpochDay(epochDay).toString()) + +/** Jeton refusé : inutile de réessayer sans le corriger. */ +class KazisUnauthorized : Exception("jeton refusé par Kazis") + +/** Serveur injoignable ou réponse inattendue. */ +class KazisError(message: String) : Exception(message) diff --git a/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt b/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt index 74ba085..7daf541 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt @@ -6,8 +6,10 @@ import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map private val Context.dataStore: androidx.datastore.core.DataStore by preferencesDataStore( @@ -29,6 +31,11 @@ data class UserProfile( } } +/** Coordonnées du serveur Kazis. Vides tant que l'utilisateur ne les a pas saisies. */ +data class KazisSettings(val url: String = "", val token: String = "") { + val configured: Boolean get() = url.isNotBlank() && token.isNotBlank() +} + class UserPreferences(private val context: Context) { private val weightKey = doublePreferencesKey("weight_kg") @@ -37,6 +44,10 @@ class UserPreferences(private val context: Context) { private val carbsGoalKey = intPreferencesKey("carbs_goal_g") private val fatGoalKey = intPreferencesKey("fat_goal_g") private val lastHealthSyncKey = longPreferencesKey("last_health_sync_epoch_second") + private val kazisUrlKey = stringPreferencesKey("kazis_url") + private val kazisTokenKey = stringPreferencesKey("kazis_token") + private val deviceIdKey = stringPreferencesKey("device_id") + private val lastBackupKey = longPreferencesKey("last_kazis_backup_epoch_second") val profile: Flow = context.dataStore.data.map { prefs -> UserProfile( @@ -58,6 +69,43 @@ class UserPreferences(private val context: Context) { context.dataStore.edit { it[goalKey] = goal } } + /** + * Adresse et jeton du serveur Kazis, d'où viennent les repas dictés à l'assistant. + * Le jeton n'ouvre qu'un serveur du réseau privé ; il est stocké comme le reste + * des préférences, dans l'espace privé de l'application. + */ + val kazis: Flow = context.dataStore.data.map { prefs -> + KazisSettings(url = prefs[kazisUrlKey] ?: "", token = prefs[kazisTokenKey] ?: "") + } + + suspend fun setKazis(settings: KazisSettings) { + context.dataStore.edit { + it[kazisUrlKey] = settings.url.trim() + it[kazisTokenKey] = settings.token.trim() + } + } + + /** + * Identifiant de cette installation, tiré au premier besoin. + * + * Les identifiants des séances repartent à 1 après une réinstallation : sans lui, + * la sauvegarde écraserait celle de l'installation précédente. + */ + suspend fun deviceId(): String { + val existing = context.dataStore.data.map { it[deviceIdKey] }.first() + if (existing != null) return existing + val fresh = java.util.UUID.randomUUID().toString() + context.dataStore.edit { it[deviceIdKey] = fresh } + return fresh + } + + /** Horodatage de la dernière sauvegarde réussie vers Kazis, en secondes. */ + val lastBackup: Flow = context.dataStore.data.map { it[lastBackupKey] } + + suspend fun setLastBackup(epochSecond: Long) { + context.dataStore.edit { it[lastBackupKey] = epochSecond } + } + /** Horodatage de la dernière synchronisation Health Connect réussie, en secondes. */ val lastHealthSync: Flow = context.dataStore.data.map { it[lastHealthSyncKey] } 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 e0c550a..09b78bc 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -48,6 +48,7 @@ import com.kaz229.fittrack.ui.screens.ExerciseDetailScreen import com.kaz229.fittrack.ui.screens.ExercisesScreen import com.kaz229.fittrack.ui.screens.HealthConnectScreen import com.kaz229.fittrack.ui.screens.HomeScreen +import com.kaz229.fittrack.ui.screens.KazisScreen import com.kaz229.fittrack.ui.screens.MealsScreen import com.kaz229.fittrack.ui.screens.ProfileScreen import com.kaz229.fittrack.ui.screens.ScanScreen @@ -71,6 +72,7 @@ private const val EXERCISE_DETAIL = "exercise" private const val PROFILE = "profile" private const val SCAN = "scan" private const val HEALTH = "health" +private const val KAZIS = "kazis" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -95,6 +97,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { val macros by viewModel.todayMacros.collectAsStateWithLifecycle() val scanState by viewModel.scan.collectAsStateWithLifecycle() val healthState by viewModel.health.collectAsStateWithLifecycle() + val kazisState by viewModel.kazis.collectAsStateWithLifecycle() val catalog by viewModel.catalog.collectAsStateWithLifecycle() val exercisesById by viewModel.exercisesById.collectAsStateWithLifecycle() val openSession by viewModel.openSession.collectAsStateWithLifecycle() @@ -108,6 +111,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { PROFILE -> "Profil" SCAN -> "Scanner un produit" HEALTH -> "Health Connect" + KAZIS -> "Repas dictés" else -> currentTab?.label ?: "FitTrack" } @@ -233,6 +237,14 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { onDelete = viewModel::deleteMeal, ) } + composable(KAZIS) { + KazisScreen( + state = kazisState, + onSave = viewModel::saveKazisSettings, + onFetch = viewModel::fetchKazisMeals, + onBackup = viewModel::backupToKazis, + ) + } composable(HEALTH) { HealthConnectScreen( state = healthState, @@ -280,6 +292,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { ProfileScreen( profile = profile, onOpenHealthConnect = { navController.navigate(HEALTH) }, + onOpenKazis = { navController.navigate(KAZIS) }, onSave = { weight, goal, macroGoals -> viewModel.setWeight(weight) viewModel.setDailyCalorieGoal(goal) 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 58aa3a8..37184c8 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt @@ -11,6 +11,9 @@ import com.kaz229.fittrack.data.ExerciseSet import com.kaz229.fittrack.data.FitTrackRepository 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.KazisSettings import com.kaz229.fittrack.data.FoodProduct import com.kaz229.fittrack.data.Labels import com.kaz229.fittrack.data.MacroGoals @@ -43,6 +46,16 @@ data class CatalogState( val loading: Boolean = true, ) +/** Ce que l'écran « repas dictés » doit afficher. */ +data class KazisState( + val settings: KazisSettings = KazisSettings(), + val fetching: Boolean = false, + val lastResult: KazisFetch? = null, + val backingUp: Boolean = false, + val lastBackupEpochSecond: Long? = null, + val lastBackupResult: KazisBackup? = null, +) + /** Ce que l'écran Health Connect doit afficher. */ data class HealthState( val availability: HealthConnectAvailability = HealthConnectAvailability.UNSUPPORTED, @@ -213,6 +226,57 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application _scan.value = ScanState() } + // --- Repas dictés à Kazis ------------------------------------------------ + + private val kazisFetching = MutableStateFlow(false) + private val kazisResult = MutableStateFlow(null) + + private val backingUp = MutableStateFlow(false) + private val backupResult = MutableStateFlow(null) + + val kazis: StateFlow = combine( + repository.kazisSettings, + kazisFetching, + kazisResult, + backingUp, + combine(repository.lastKazisBackup, backupResult) { last, result -> last to result }, + ) { settings, fetching, result, saving, backup -> + KazisState( + settings = settings, + fetching = fetching, + lastResult = result, + backingUp = saving, + lastBackupEpochSecond = backup.first, + lastBackupResult = backup.second, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), KazisState()) + + init { + viewModelScope.launch { + // Au lancement : ce qui a été dicté pendant qu'on n'avait pas l'app sous la + // main, puis la sauvegarde de ce que le téléphone est seul à détenir. + repository.fetchKazisMeals(today) + repository.backupToKazis() + } + } + + fun backupToKazis() = viewModelScope.launch { + backingUp.value = true + backupResult.value = repository.backupToKazis() + backingUp.value = false + } + + fun saveKazisSettings(settings: KazisSettings) = viewModelScope.launch { + repository.setKazisSettings(settings) + kazisResult.value = null + } + + fun fetchKazisMeals() = viewModelScope.launch { + kazisFetching.value = true + kazisResult.value = repository.fetchKazisMeals(today) + kazisFetching.value = false + } + // --- Health Connect ------------------------------------------------------ private val healthSyncing = MutableStateFlow(false) 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 new file mode 100644 index 0000000..4bcbd0b --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/KazisScreen.kt @@ -0,0 +1,219 @@ +package com.kaz229.fittrack.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.text.input.KeyboardType +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.KazisSettings +import com.kaz229.fittrack.ui.KazisState + +/** + * Réglages des repas dictés à Kazis. + * + * FitTrack va chercher ce qui a été déposé ; rien n'est poussé vers lui. La + * récupération se fait au lancement de l'app, et à la demande depuis cet écran. + */ +@Composable +fun KazisScreen( + state: KazisState, + onSave: (KazisSettings) -> Unit, + onFetch: () -> Unit, + onBackup: () -> Unit, + modifier: Modifier = Modifier, +) { + var url by remember(state.settings.url) { mutableStateOf(state.settings.url) } + var token by remember(state.settings.token) { mutableStateOf(state.settings.token) } + val valid = url.isNotBlank() && token.isNotBlank() + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Repas dictés", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Dis à Kazis ce que tu as mangé, sur Telegram ou depuis un assistant, " + + "et retrouve-le ici. FitTrack vient chercher les repas au lancement, " + + "et quand tu appuies sur le bouton.", + style = MaterialTheme.typography.bodyMedium, + ) + + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("Adresse du serveur") }, + placeholder = { Text("http://100.x.y.z:8765") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = token, + onValueChange = { token = it }, + label = { Text("Jeton de synchro") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { onSave(KazisSettings(url, token)) }, + enabled = valid, + modifier = Modifier.fillMaxWidth(), + ) { Text("Enregistrer") } + + if (state.settings.configured) { + OutlinedButton( + onClick = onFetch, + enabled = !state.fetching, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (state.fetching) "Récupération…" else "Récupérer maintenant") } + } + if (state.fetching || state.backingUp) CircularProgressIndicator() + state.lastResult?.let { ResultCard(it) } + + if (state.settings.configured) { + HorizontalDivider() + Text( + text = "Sauvegarde", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Tes séances, tes séries et ton journal sont envoyés à Kazis, qui les " + + "conserve. C'est la seule copie hors du téléphone de tes charges : " + + "Health Connect ne les porte pas.", + style = MaterialTheme.typography.bodySmall, + ) + OutlinedButton( + onClick = onBackup, + enabled = !state.backingUp, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (state.backingUp) "Sauvegarde…" else "Sauvegarder maintenant") } + state.lastBackupEpochSecond?.let { LastBackupLine(it) } + state.lastBackupResult?.let { BackupCard(it) } + } + + HorizontalDivider() + Text( + text = "Comment ça marche", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Les repas attendent sur ton serveur tant que FitTrack ne les a pas pris : " + + "rien ne se perd si le téléphone est éteint. Un repas donné par code-barres " + + "arrive sans valeurs, et c'est FitTrack qui le retrouve chez Open Food Facts. " + + "Un produit introuvable reste en attente plutôt que d'être enregistré faux.", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = "L'adresse est celle du serveur Kazis sur ton réseau privé, et le jeton " + + "est le même que celui de l'app Kazis.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun LastBackupLine(epochSecond: Long) { + val formatted = remember(epochSecond) { + java.time.Instant.ofEpochSecond(epochSecond) + .atZone(java.time.ZoneId.systemDefault()) + .format(java.time.format.DateTimeFormatter.ofPattern("dd/MM/yyyy 'à' HH:mm")) + } + Text( + text = "Dernière sauvegarde : $formatted", + style = MaterialTheme.typography.bodySmall, + ) +} + +@Composable +private fun BackupCard(result: KazisBackup) { + val (title, body) = when (result) { + is KazisBackup.Success -> + "Sauvegardé" to "${result.sessions} séance(s), ${result.sets} série(s) et " + + "${result.meals} ligne(s) de repas sont à l'abri." + + KazisBackup.NotConfigured -> + "À configurer" to "Renseigne l'adresse du serveur et le jeton." + + KazisBackup.Unauthorized -> + "Jeton refusé" to "Le serveur n'a pas accepté ce jeton." + + is KazisBackup.Failed -> + "Sauvegarde impossible" to "${result.message}. Tes données restent sur le téléphone." + } + 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) { + is KazisFetch.Success -> when { + result.added == 0 && result.skipped == 0 -> + "Rien de nouveau" to "Aucun repas en attente." + result.skipped == 0 -> + "${result.added} repas récupéré(s)" to "Ils sont dans le journal du jour concerné." + else -> + "${result.added} repas récupéré(s)" to + "${result.skipped} produit(s) introuvable(s) chez Open Food Facts : " + + "ils restent en attente, et tu peux les annuler depuis Telegram." + } + + KazisFetch.NotConfigured -> + "À configurer" to "Renseigne l'adresse du serveur et le jeton." + + KazisFetch.Unauthorized -> + "Jeton refusé" to "Le serveur n'a pas accepté ce jeton. Vérifie-le côté Kazis." + + is KazisFetch.Failed -> + "Serveur injoignable" to + "${result.message}. Vérifie que Kazis tourne et que tu es bien sur le réseau privé." + } + 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) + } + } +} diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt index 3d5ab9f..d8b2b7a 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/ProfileScreen.kt @@ -30,6 +30,7 @@ fun ProfileScreen( profile: UserProfile, onSave: (weightKg: Double, goal: Int, macroGoals: MacroGoals) -> Unit, onOpenHealthConnect: () -> Unit, + onOpenKazis: () -> Unit, modifier: Modifier = Modifier, ) { var weight by remember(profile.weightKg) { @@ -138,6 +139,12 @@ fun ProfileScreen( ) { Text("Synchronisation Health Connect") } + OutlinedButton( + onClick = onOpenKazis, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Repas dictés à Kazis") + } Card(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..a89d832 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,17 @@ + + + + + + + + + + openfoodfacts.org + + diff --git a/app/src/test/java/com/kaz229/fittrack/KazisTest.kt b/app/src/test/java/com/kaz229/fittrack/KazisTest.kt new file mode 100644 index 0000000..7527ef2 --- /dev/null +++ b/app/src/test/java/com/kaz229/fittrack/KazisTest.kt @@ -0,0 +1,103 @@ +package com.kaz229.fittrack + +import com.kaz229.fittrack.data.KazisClient +import com.kaz229.fittrack.data.KazisSettings +import com.kaz229.fittrack.data.MealType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** Lecture de la file de repas servie par Kazis. */ +class KazisTest { + + @Test + fun `un repas complet est lu avec ses macros`() { + val json = """ + {"meals": [{"id": 3, "name": "150 g de riz", "calories": 195, + "protein_g": 4.0, "carbs_g": 42.0, "fat_g": 0.5, + "meal_type": "dejeuner", "day": "2026-11-02", "barcode": null, "grams": null}]} + """.trimIndent() + + val meal = KazisClient.parseMeals(json).single() + + assertEquals(3L, meal.id) + assertEquals("150 g de riz", meal.name) + assertEquals(195, meal.calories) + assertEquals(4.0, meal.proteinG, 0.001) + assertEquals(MealType.LUNCH, meal.mealType) + assertEquals(LocalDate.of(2026, 11, 2), meal.day) + assertNull(meal.barcode) + assertFalse(meal.needsLookup) + } + + @Test + fun `un produit a code-barres attend d'etre resolu`() { + val json = """ + {"meals": [{"id": 4, "name": "Produit 3017620422003", "calories": 0, + "protein_g": 0, "carbs_g": 0, "fat_g": 0, "meal_type": "petit_dejeuner", + "day": "2026-11-02", "barcode": "3017620422003", "grams": 30.0}]} + """.trimIndent() + + val meal = KazisClient.parseMeals(json).single() + + // Sans valeurs mais avec un code : c'est à FitTrack d'interroger Open Food Facts. + assertTrue(meal.needsLookup) + assertEquals("3017620422003", meal.barcode) + assertEquals(30.0, meal.grams!!, 0.001) + assertEquals(MealType.BREAKFAST, meal.mealType) + } + + @Test + fun `une file vide ne donne rien`() { + assertEquals(emptyList(), KazisClient.parseMeals("""{"meals": []}""")) + assertEquals(emptyList(), KazisClient.parseMeals("""{}""")) + } + + @Test + fun `une ligne inexploitable est ignoree sans faire tomber les autres`() { + val json = """ + {"meals": [ + {"id": 1, "name": "", "calories": 100, "meal_type": "dejeuner", "day": "2026-11-02"}, + {"id": 2, "name": "Sans date", "calories": 100, "meal_type": "dejeuner", "day": "jamais"}, + {"id": 3, "name": "Pomme", "calories": 72, "meal_type": "collation", "day": "2026-11-02"} + ]} + """.trimIndent() + + val meals = KazisClient.parseMeals(json) + + assertEquals(listOf("Pomme"), meals.map { it.name }) + } + + @Test + fun `un type de repas inconnu retombe sur collation`() { + val json = """ + {"meals": [{"id": 1, "name": "Truc", "calories": 100, + "meal_type": "quatre_heures", "day": "2026-11-02"}]} + """.trimIndent() + + assertEquals(MealType.SNACK, KazisClient.parseMeals(json).single().mealType) + } + + @Test + fun `les macros absentes valent zero`() { + val json = """{"meals": [{"id": 1, "name": "Pomme", "calories": 72, + "meal_type": "collation", "day": "2026-11-02"}]}""" + + val meal = KazisClient.parseMeals(json).single() + + assertEquals(0.0, meal.proteinG, 0.001) + assertEquals(0.0, meal.carbsG, 0.001) + assertNull(meal.grams) + } + + @Test + fun `les reglages ne sont complets qu'avec l'adresse et le jeton`() { + assertFalse(KazisSettings().configured) + assertFalse(KazisSettings(url = "http://100.1.2.3:8765").configured) + assertFalse(KazisSettings(token = "secret").configured) + assertTrue(KazisSettings("http://100.1.2.3:8765", "secret").configured) + } +}