From 7035eeb4092f8dc7d25b990626b02f8bdcf2ad08 Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Sun, 1 Nov 2026 19:06:21 +0100 Subject: [PATCH 1/2] =?UTF-8?q?R=C3=A9cup=C3=A8re=20les=20repas=20dict?= =?UTF-8?q?=C3=A9s=20=C3=A0=20Kazis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un assistant (Telegram ou MCP) dépose des repas sur le serveur Kazis ; FitTrack vient les chercher au lancement, et à la demande depuis les réglages. - 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 reste dans la file plutôt que d'être enregistré faux — il sera retenté, et reste annulable depuis Telegram. - L'accusé de réception ne porte que les repas réellement enregistrés. - Écran de réglages dans le profil : adresse, jeton, récupération manuelle, et le détail de ce que la dernière tentative a donné. Le trafic en clair devait être autorisé : Android le refuse depuis API 28, et le serveur Kazis parle en HTTP simple sur Tailscale, où le chiffrement vient du tunnel WireGuard. Open Food Facts reste en HTTPS obligatoire, déclaré explicitement dans la configuration réseau. Vérifié sur appareil contre un vrai serveur Kazis : trois repas récupérés, dont un code-barres résolu en « Nutella 162 kcal » pour 30 g, file vidée côté Kazis, et un second passage qui ne ramène rien. --- README.md | 4 + .../com/kaz229/fittrack/KazisFetchTest.kt | 126 ++++++++++++++ app/src/main/AndroidManifest.xml | 1 + .../fittrack/data/FitTrackRepository.kt | 59 +++++++ .../java/com/kaz229/fittrack/data/Kazis.kt | 130 +++++++++++++++ .../kaz229/fittrack/data/UserPreferences.kt | 24 +++ .../com/kaz229/fittrack/ui/FitTrackApp.kt | 12 ++ .../kaz229/fittrack/ui/FitTrackViewModel.kt | 37 +++++ .../kaz229/fittrack/ui/screens/KazisScreen.kt | 155 ++++++++++++++++++ .../fittrack/ui/screens/ProfileScreen.kt | 7 + .../main/res/xml/network_security_config.xml | 17 ++ .../java/com/kaz229/fittrack/KazisTest.kt | 103 ++++++++++++ 12 files changed, 675 insertions(+) create mode 100644 app/src/androidTest/java/com/kaz229/fittrack/KazisFetchTest.kt create mode 100644 app/src/main/java/com/kaz229/fittrack/data/Kazis.kt create mode 100644 app/src/main/java/com/kaz229/fittrack/ui/screens/KazisScreen.kt create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/test/java/com/kaz229/fittrack/KazisTest.kt diff --git a/README.md b/README.md index df33ffe..bece298 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ 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é. - **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 +58,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 +98,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/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/FitTrackRepository.kt b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt index 289445a..08004e2 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,63 @@ 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 + } + // --- Health Connect ------------------------------------------------------ val lastHealthSync: Flow = userPreferences.lastHealthSync @@ -284,6 +342,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..d1efbdd --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/data/Kazis.kt @@ -0,0 +1,130 @@ +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 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) + } + + /** 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 + } +} + +/** 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..997eedb 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt @@ -6,6 +6,7 @@ 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.map @@ -29,6 +30,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 +43,8 @@ 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") val profile: Flow = context.dataStore.data.map { prefs -> UserProfile( @@ -58,6 +66,22 @@ 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() + } + } + /** 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..14ccec9 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,13 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { onDelete = viewModel::deleteMeal, ) } + composable(KAZIS) { + KazisScreen( + state = kazisState, + onSave = viewModel::saveKazisSettings, + onFetch = viewModel::fetchKazisMeals, + ) + } composable(HEALTH) { HealthConnectScreen( state = healthState, @@ -280,6 +291,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..bd90863 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,8 @@ 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.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 +45,13 @@ 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, +) + /** Ce que l'écran Health Connect doit afficher. */ data class HealthState( val availability: HealthConnectAvailability = HealthConnectAvailability.UNSUPPORTED, @@ -213,6 +222,34 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application _scan.value = ScanState() } + // --- Repas dictés à Kazis ------------------------------------------------ + + private val kazisFetching = MutableStateFlow(false) + private val kazisResult = MutableStateFlow(null) + + val kazis: StateFlow = combine( + repository.kazisSettings, + kazisFetching, + kazisResult, + ) { settings, fetching, result -> KazisState(settings, fetching, result) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), KazisState()) + + init { + // Au lancement : ce qui a été dicté pendant qu'on n'avait pas l'app sous la main. + viewModelScope.launch { repository.fetchKazisMeals(today) } + } + + 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..8ecd1e6 --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/KazisScreen.kt @@ -0,0 +1,155 @@ +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.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, + 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) CircularProgressIndicator() + state.lastResult?.let { ResultCard(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 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) + } +} From 36fea8ff2827770ede59774e6c963c796566a68a Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Mon, 2 Nov 2026 14:35:52 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Sauvegarde=20les=20s=C3=A9ances,=20les=20s?= =?UTF-8?q?=C3=A9ries=20et=20le=20journal=20sur=20Kazis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tout vivait à un seul endroit : désinstaller l'app ou changer de téléphone perdait l'historique. Health Connect ne rattrape ni les séries ni les charges, c'est-à-dire ce qui a le plus de valeur. L'app envoie maintenant tout son contenu au serveur Kazis, au lancement et sur demande depuis les réglages. Elle réutilise l'adresse et le jeton déjà saisis pour les repas dictés. Rien n'est filtré sur la date : l'historique complet est petit, le serveur remplace ce qu'il a déjà, et une sauvegarde partielle laisserait des trous après une restauration. Un identifiant d'installation accompagne l'envoi. Les identifiants des séances repartent à 1 après une réinstallation : sans lui, la nouvelle sauvegarde écraserait la précédente côté serveur. Vérifié sur appareil contre un vrai serveur : une séance, deux séries (10 × 60 kg, 8 × 65 kg) et un repas arrivent avec leurs valeurs, et une seconde sauvegarde ne duplique rien. --- README.md | 3 +- .../com/kaz229/fittrack/KazisBackupTest.kt | 118 ++++++++++++++++++ .../java/com/kaz229/fittrack/data/Daos.kt | 12 ++ .../fittrack/data/FitTrackRepository.kt | 29 +++++ .../java/com/kaz229/fittrack/data/Kazis.kt | 60 +++++++++ .../kaz229/fittrack/data/UserPreferences.kt | 24 ++++ .../com/kaz229/fittrack/ui/FitTrackApp.kt | 1 + .../kaz229/fittrack/ui/FitTrackViewModel.kt | 35 +++++- .../kaz229/fittrack/ui/screens/KazisScreen.kt | 66 +++++++++- 9 files changed, 342 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/java/com/kaz229/fittrack/KazisBackupTest.kt diff --git a/README.md b/README.md index bece298..814a6d2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ Deux exceptions au « tout local » : [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é. + 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. 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/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 08004e2..7e55d3a 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -286,6 +286,35 @@ class FitTrackRepository( 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 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 d1efbdd..9e6d4ce 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,14 @@ data class KazisMeal( 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 @@ -54,6 +62,30 @@ class KazisClient { 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 @@ -123,6 +155,34 @@ class KazisClient { } } +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") 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 997eedb..7daf541 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt @@ -9,6 +9,7 @@ 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( @@ -45,6 +46,8 @@ class UserPreferences(private val context: Context) { 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( @@ -82,6 +85,27 @@ class UserPreferences(private val context: Context) { } } + /** + * 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 14ccec9..09b78bc 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -242,6 +242,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { state = kazisState, onSave = viewModel::saveKazisSettings, onFetch = viewModel::fetchKazisMeals, + onBackup = viewModel::backupToKazis, ) } 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 bd90863..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,7 @@ 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 @@ -50,6 +51,9 @@ 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. */ @@ -227,16 +231,39 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application 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, - ) { settings, fetching, result -> KazisState(settings, fetching, result) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), KazisState()) + 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 { - // Au lancement : ce qui a été dicté pendant qu'on n'avait pas l'app sous la main. - viewModelScope.launch { repository.fetchKazisMeals(today) } + 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 { 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 8ecd1e6..4bcbd0b 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 @@ -25,6 +25,7 @@ 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 @@ -40,6 +41,7 @@ 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) } @@ -95,9 +97,31 @@ fun KazisScreen( modifier = Modifier.fillMaxWidth(), ) { Text(if (state.fetching) "Récupération…" else "Récupérer maintenant") } } - if (state.fetching) CircularProgressIndicator() + 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", @@ -119,6 +143,46 @@ fun KazisScreen( } } +@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) {