diff --git a/README.md b/README.md index fc7110c..df33ffe 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,16 @@ Application Android pour **suivre ses séances de salle** et **tracker ses calories et ses macros**. Pas de compte, pas de serveur, pas de publicité : tout est stocké sur le téléphone. -Une seule exception au « tout local » : quand tu scannes un code-barres, ce code-barres est envoyé à -[Open Food Facts](https://world.openfoodfacts.org) pour récupérer les valeurs nutritionnelles du -produit. Rien d'autre ne sort du téléphone — ni séances, ni repas, ni poids — et le produit reçu est -mis en cache localement pour rester consultable hors ligne. +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. +- **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. + +Rien d'autre ne sort du téléphone, et les deux restent facultatifs. ## Fonctionnalités @@ -49,6 +55,7 @@ corde à sauter 11,0 · rameur 7,0…). C'est une estimation, pas une mesure mé | Catalogue | JSON embarqué dans les assets, chargé en mémoire au lancement | | Scan | CameraX + ML Kit (barcode-scanning groupé, fonctionne sans Play Services) | | Nutrition | API Open Food Facts v2 en lecture, `HttpURLConnection`, cache Room | +| Santé | Health Connect (écriture séances, calories et nutrition ; lecture du poids) | | Build | Gradle 8.11 + AGP 8.7, `minSdk 26`, `targetSdk 35` | Architecture : `data` (entités Room, DAO, catalogue, repository) → `ui` (ViewModel exposant des @@ -87,6 +94,7 @@ app/src/main/ │ ├── Model.kt # Session, ExerciseSet, Meal, bilans │ ├── 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 │ ├── Exercise.kt # exercice du catalogue + libellés FR │ ├── ExerciseCatalog.kt # chargement, recherche, filtres │ ├── Activities.kt # activités cardio (MET) et calcul des calories @@ -133,4 +141,4 @@ hors ligne. - Graphiques de progression par exercice et courbe de poids. - Réutiliser un produit déjà scanné sans rescanner (liste des aliments récents). - Typage des séries (échauffement, échec, drop-set) pour un volume plus juste. -- Export / import CSV et intégration Health Connect. +- Export / import CSV. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c158f31..694aff0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,7 +7,7 @@ plugins { android { namespace = "com.kaz229.fittrack" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.kaz229.fittrack" @@ -64,11 +64,16 @@ dependencies { implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.health.connect.client) + implementation(libs.androidx.camera.core) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.view) implementation(libs.mlkit.barcode.scanning) + // Fournit ListenableFuture.await() et, au passage, le vrai artefact guava : + // sans lui, Health Connect tire la version « vide » qui masque ListenableFuture. + implementation(libs.androidx.concurrent.futures.ktx) testImplementation(libs.junit) // org.json n'est qu'un stub dans android.jar : la vraie implémentation permet de diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 00e4c0b..ad7055b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,21 @@ android:name="android.hardware.camera.any" android:required="false" /> + + + + + + + + + + + + + + + + + + + + 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 5bbe8c6..4cfe9dd 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Daos.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Daos.kt @@ -28,6 +28,10 @@ interface SessionDao { @Query("SELECT * FROM sessions ORDER BY epochDay DESC, id DESC") fun observeAll(): Flow> + /** Instantané des séances d'une période, pour l'export vers Health Connect. */ + @Query("SELECT * FROM sessions WHERE epochDay >= :fromDay ORDER BY epochDay, id") + suspend fun since(fromDay: Long): List + @Query("SELECT COALESCE(SUM(caloriesBurned), 0) FROM sessions WHERE epochDay = :epochDay") fun observeCaloriesForDay(epochDay: Long): Flow @@ -82,6 +86,10 @@ interface MealDao { @Query("SELECT * FROM meals WHERE epochDay = :epochDay ORDER BY id DESC") fun observeForDay(epochDay: Long): Flow> + /** Instantané des repas d'une période, pour l'export vers Health Connect. */ + @Query("SELECT * FROM meals WHERE epochDay >= :fromDay ORDER BY epochDay, id") + suspend fun since(fromDay: Long): 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 fc1ce9e..289445a 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -19,6 +19,7 @@ class FitTrackRepository( private val userPreferences: UserPreferences, val exercises: ExerciseCatalog, private val openFoodFacts: OpenFoodFactsClient = OpenFoodFactsClient(), + private val healthConnect: HealthConnectManager? = null, ) { val profile: Flow = userPreferences.profile @@ -227,6 +228,36 @@ class FitTrackRepository( } } + // --- Health Connect ------------------------------------------------------ + + val lastHealthSync: Flow = userPreferences.lastHealthSync + + fun healthConnectAvailability(): HealthConnectAvailability = + healthConnect?.availability() ?: HealthConnectAvailability.UNSUPPORTED + + suspend fun healthConnectPermissionsGranted(): Boolean = + healthConnect?.hasAllPermissions() ?: false + + /** + * Exporte les séances et repas du dernier mois, et relit le poids le plus récent. + * Le poids n'est pas appliqué d'office : il est renvoyé pour que l'utilisateur + * décide de remplacer, ou non, celui de son profil. + */ + suspend fun syncHealthConnect(today: LocalDate = LocalDate.now()): SyncResult { + val manager = healthConnect ?: return SyncResult.Unavailable + val fromDay = today.minusDays((SYNC_DAYS - 1).toLong()).toEpochDay() + val result = manager.sync( + sessions = sessionDao.since(fromDay), + meals = mealDao.since(fromDay), + today = today, + days = SYNC_DAYS, + ) + if (result is SyncResult.Success) { + userPreferences.setLastHealthSync(System.currentTimeMillis() / 1000) + } + return result + } + // --- Profil -------------------------------------------------------------- suspend fun setWeight(weightKg: Double) = userPreferences.setWeight(weightKg) @@ -236,6 +267,9 @@ class FitTrackRepository( suspend fun setMacroGoals(goals: MacroGoals) = userPreferences.setMacroGoals(goals) companion object { + /** Fenêtre d'export : réécrire tout l'historique à chaque fois serait inutile. */ + const val SYNC_DAYS = 30 + @Volatile private var instance: FitTrackRepository? = null @@ -249,6 +283,7 @@ class FitTrackRepository( foodProductDao = db.foodProductDao(), userPreferences = UserPreferences(context.applicationContext), exercises = ExerciseCatalog.get(context), + healthConnect = HealthConnectManager(context.applicationContext), ).also { instance = it } } } diff --git a/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt b/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt new file mode 100644 index 0000000..a24cd07 --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt @@ -0,0 +1,329 @@ +package com.kaz229.fittrack.data + +import android.content.Context +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.MealType as HcMealType +import androidx.health.connect.client.records.NutritionRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.request.ReadRecordsRequest +import androidx.health.connect.client.time.TimeRangeFilter +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Mass +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset + +/** Ce que le téléphone peut faire avec Health Connect. */ +enum class HealthConnectAvailability { + /** Disponible et utilisable. */ + AVAILABLE, + + /** Health Connect est absent ou demande une mise à jour : on peut proposer l'installation. */ + UPDATE_REQUIRED, + + /** Version d'Android trop ancienne : rien à proposer. */ + UNSUPPORTED, +} + +/** Résultat d'une synchronisation, pour l'afficher sans interpréter une exception. */ +sealed interface SyncResult { + data class Success(val sessions: Int, val meals: Int, val weightKg: Double?) : SyncResult + data object PermissionsMissing : SyncResult + data object Unavailable : SyncResult + data class Failed(val message: String) : SyncResult +} + +/** + * Pont avec Health Connect. + * + * L'app reste entièrement fonctionnelle sans : chaque appel vérifie la disponibilité + * et les autorisations, et renvoie un [SyncResult] explicite plutôt que de lever. + * + * L'idempotence repose sur `clientRecordId` : Health Connect remplace un enregistrement + * portant le même identifiant au lieu d'en créer un second. Une synchronisation relancée + * dix fois ne duplique donc rien, et corriger une séance met à jour l'enregistrement + * existant — sans qu'on ait à retenir localement ce qui a déjà été exporté. + */ +class HealthConnectManager(private val context: Context) { + + private val client: HealthConnectClient? by lazy { + if (availability() == HealthConnectAvailability.AVAILABLE) { + HealthConnectClient.getOrCreate(context) + } else { + null + } + } + + fun availability(): HealthConnectAvailability = + when (HealthConnectClient.getSdkStatus(context)) { + HealthConnectClient.SDK_AVAILABLE -> HealthConnectAvailability.AVAILABLE + HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> + HealthConnectAvailability.UPDATE_REQUIRED + else -> HealthConnectAvailability.UNSUPPORTED + } + + suspend fun hasAllPermissions(): Boolean { + val granted = client?.permissionController?.getGrantedPermissions() ?: return false + return granted.containsAll(PERMISSIONS) + } + + /** + * Écrit les séances et les repas de la période, puis relit le dernier poids connu. + * + * La fenêtre est volontairement courte (les [days] derniers jours) : réécrire tout + * l'historique à chaque synchronisation serait coûteux et inutile, puisque les + * enregistrements déjà envoyés restent en place. + */ + suspend fun sync( + sessions: List, + meals: List, + today: LocalDate = LocalDate.now(), + days: Int = 30, + ): SyncResult = withContext(Dispatchers.IO) { + val client = client ?: return@withContext SyncResult.Unavailable + if (!hasAllPermissions()) return@withContext SyncResult.PermissionsMissing + + val from = today.minusDays((days - 1).toLong()) + val recentSessions = sessions.filter { it.finished && it.epochDay >= from.toEpochDay() } + val recentMeals = meals.filter { it.epochDay >= from.toEpochDay() } + + try { + val now = Instant.now() + val records = buildList { + addAll(exerciseRecords(recentSessions, now = now)) + addAll(recentMeals.map { nutritionRecord(it, now = now) }) + } + if (records.isNotEmpty()) client.insertRecords(records) + + SyncResult.Success( + sessions = recentSessions.size, + meals = recentMeals.size, + weightKg = latestWeightKg(), + ) + } catch (e: Exception) { + SyncResult.Failed(e.message ?: "Échec de la synchronisation") + } + } + + /** Dernier poids enregistré par n'importe quelle application, en kilos. */ + suspend fun latestWeightKg(): Double? = withContext(Dispatchers.IO) { + val client = client ?: return@withContext null + try { + client.readRecords( + ReadRecordsRequest( + recordType = WeightRecord::class, + timeRangeFilter = TimeRangeFilter.before(Instant.now()), + ascendingOrder = false, + pageSize = 1, + ), + ).records.firstOrNull()?.weight?.inKilograms + } catch (_: Exception) { + null + } + } + + companion object { + /** Lien Play Store vers Health Connect, pour la proposition d'installation. */ + const val PLAY_STORE_URI = + "market://details?id=com.google.android.apps.healthdata" + + val PERMISSIONS: Set = setOf( + HealthPermission.getWritePermission(ExerciseSessionRecord::class), + HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class), + HealthPermission.getWritePermission(NutritionRecord::class), + HealthPermission.getReadPermission(WeightRecord::class), + ) + + /** + * Heure conventionnelle de début d'une séance. + * + * FitTrack ne retient que le jour d'une séance, jamais l'heure ; Health Connect + * exige un créneau. Les séances d'une même journée sont donc posées à la suite + * à partir de 18 h, dans l'ordre de leur identifiant : sans ce décalage, deux + * séances du même jour se chevaucheraient et Health Connect les refuserait. + */ + private val DEFAULT_SESSION_START: LocalTime = LocalTime.of(18, 0) + + /** Heures conventionnelles des repas, pour la même raison. */ + private fun mealTime(type: MealType): LocalTime = when (type) { + MealType.BREAKFAST -> LocalTime.of(8, 0) + MealType.LUNCH -> LocalTime.of(12, 30) + MealType.DINNER -> LocalTime.of(19, 30) + MealType.SNACK -> LocalTime.of(16, 0) + } + + private fun hcMealType(type: MealType): Int = when (type) { + MealType.BREAKFAST -> HcMealType.MEAL_TYPE_BREAKFAST + MealType.LUNCH -> HcMealType.MEAL_TYPE_LUNCH + MealType.DINNER -> HcMealType.MEAL_TYPE_DINNER + MealType.SNACK -> HcMealType.MEAL_TYPE_SNACK + } + + /** + * Une séance donne deux enregistrements : la séance elle-même et les calories + * qu'elle a brûlées, que Health Connect range dans des types distincts. + */ + fun exerciseRecords( + sessions: List, + zone: ZoneId = ZoneId.systemDefault(), + now: Instant = Instant.now(), + ): List = sessions + .groupBy { it.epochDay } + .flatMap { (epochDay, daySessions) -> + val ordered = daySessions.sortedBy { it.id } + val day = LocalDate.ofEpochDay(epochDay) + val totalMinutes = ordered.sumOf { it.durationMinutes.coerceAtLeast(1).toLong() } + + // Health Connect refuse tout enregistrement qui déborde dans le futur. + // Le créneau conventionnel de 18 h est donc ramené en arrière quand la + // journée en cours n'y est pas encore arrivée — une synchronisation + // lancée le matin échouerait sinon systématiquement. + var start = day.atTime(DEFAULT_SESSION_START) + val lastEnd = start.plusMinutes(totalMinutes) + val overflow = Duration.between( + now, + lastEnd.toInstant(zone.rules.getOffset(lastEnd)), + ) + if (!overflow.isNegative && !overflow.isZero) { + start = start.minusMinutes(overflow.toMinutes() + 1) + } + + ordered.flatMap { session -> + val duration = session.durationMinutes.coerceAtLeast(1).toLong() + val end = start.plusMinutes(duration) + val records = exerciseRecords(session, start, end, zone) + start = end + records + } + } + + private fun exerciseRecords( + session: Session, + start: LocalDateTime, + end: LocalDateTime, + zone: ZoneId, + ): List { + val offset = zone.rules.getOffset(start) + val startInstant = start.toInstant(offset) + val endInstant = end.toInstant(offset) + val activity = session.activityId?.let { Activities.byId(it) } + + val exercise = ExerciseSessionRecord( + startTime = startInstant, + startZoneOffset = offset, + endTime = endInstant, + endZoneOffset = offset, + exerciseType = exerciseType(session), + title = activity?.label ?: "Séance de musculation", + notes = session.note.takeIf { it.isNotBlank() }, + metadata = clientMetadata("fittrack-session-${session.id}"), + ) + if (session.caloriesBurned <= 0) return listOf(exercise) + + return listOf( + exercise, + ActiveCaloriesBurnedRecord( + startTime = startInstant, + startZoneOffset = offset, + endTime = endInstant, + endZoneOffset = offset, + energy = Energy.kilocalories(session.caloriesBurned.toDouble()), + metadata = clientMetadata("fittrack-session-calories-${session.id}"), + ), + ) + } + + /** Une ligne du journal alimentaire, avec ses macros quand elles sont connues. */ + fun nutritionRecord( + meal: Meal, + zone: ZoneId = ZoneId.systemDefault(), + now: Instant = Instant.now(), + ): NutritionRecord { + val type = MealType.byId(meal.mealType) + val conventional = LocalDate.ofEpochDay(meal.epochDay).atTime(mealTime(type)) + // Même règle que pour les séances : un dîner noté le jour même ne peut pas + // être enregistré à 19 h 30 s'il n'est que 16 h. + val start = notInFuture(conventional, zone, now, durationMinutes = 30) + val offset = zone.rules.getOffset(start) + return NutritionRecord( + startTime = start.toInstant(offset), + startZoneOffset = offset, + endTime = start.plusMinutes(30).toInstant(offset), + endZoneOffset = offset, + name = meal.name, + mealType = hcMealType(type), + energy = Energy.kilocalories(meal.calories.toDouble()), + protein = meal.proteinG.takeIf { it > 0 }?.let(Mass::grams), + totalCarbohydrate = meal.carbsG.takeIf { it > 0 }?.let(Mass::grams), + totalFat = meal.fatG.takeIf { it > 0 }?.let(Mass::grams), + metadata = clientMetadata("fittrack-meal-${meal.id}"), + ) + } + + /** + * Correspondance avec les types d'exercice de Health Connect. Le catalogue cardio + * de FitTrack est plus fin que ce que couvrent les constantes : tout ce qui n'a + * pas d'équivalent direct retombe sur « autre entraînement », qui reste exact. + */ + private fun exerciseType(session: Session): Int = + if (SessionKind.byId(session.kind) == SessionKind.GYM) { + ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING + } else { + when (session.activityId) { + "tapis" -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL + "course" -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING + "velo" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING + "velo_appart" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY + "rameur" -> ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE + "elliptique" -> ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL + "hiit" -> ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING + "natation" -> ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL + "marche" -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING + "football" -> ExerciseSessionRecord.EXERCISE_TYPE_SOCCER + "basket" -> ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL + "etirements" -> ExerciseSessionRecord.EXERCISE_TYPE_STRETCHING + "muscu" -> ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING + // Corde à sauter : aucune constante dédiée côté Health Connect. + else -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT + } + } + + /** Métadonnée portant l'identifiant stable qui évite les doublons. */ + private fun clientMetadata(clientRecordId: String): Metadata = + Metadata.manualEntry(clientRecordId = clientRecordId) + + /** + * Recule [dateTime] juste ce qu'il faut pour que le créneau se termine avant + * [now], et le laisse intact quand il est déjà passé. + */ + private fun notInFuture( + dateTime: LocalDateTime, + zone: ZoneId, + now: Instant, + durationMinutes: Long, + ): LocalDateTime { + val end = dateTime.plusMinutes(durationMinutes) + val overflow = Duration.between(now, end.toInstant(zone.rules.getOffset(end))) + return if (overflow.isNegative || overflow.isZero) { + dateTime + } else { + dateTime.minusMinutes(overflow.toMinutes() + 1) + } + } + + /** Décalage horaire d'un instant local, exposé pour les tests. */ + fun offsetFor(dateTime: LocalDateTime, zone: ZoneId): ZoneOffset = + zone.rules.getOffset(dateTime) + } +} 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 a511bea..74ba085 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/UserPreferences.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences 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.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -35,6 +36,7 @@ class UserPreferences(private val context: Context) { private val proteinGoalKey = intPreferencesKey("protein_goal_g") private val carbsGoalKey = intPreferencesKey("carbs_goal_g") private val fatGoalKey = intPreferencesKey("fat_goal_g") + private val lastHealthSyncKey = longPreferencesKey("last_health_sync_epoch_second") val profile: Flow = context.dataStore.data.map { prefs -> UserProfile( @@ -56,6 +58,13 @@ class UserPreferences(private val context: Context) { context.dataStore.edit { it[goalKey] = goal } } + /** Horodatage de la dernière synchronisation Health Connect réussie, en secondes. */ + val lastHealthSync: Flow = context.dataStore.data.map { it[lastHealthSyncKey] } + + suspend fun setLastHealthSync(epochSecond: Long) { + context.dataStore.edit { it[lastHealthSyncKey] = epochSecond } + } + suspend fun setMacroGoals(goals: MacroGoals) { context.dataStore.edit { it[proteinGoalKey] = goals.proteinG 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 18ab6f9..e0c550a 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackApp.kt @@ -46,6 +46,7 @@ import com.kaz229.fittrack.ui.screens.AddCardioDialog import com.kaz229.fittrack.ui.screens.AddMealDialog import com.kaz229.fittrack.ui.screens.ExerciseDetailScreen import com.kaz229.fittrack.ui.screens.ExercisesScreen +import com.kaz229.fittrack.ui.screens.HealthConnectScreen import com.kaz229.fittrack.ui.screens.HomeScreen import com.kaz229.fittrack.ui.screens.MealsScreen import com.kaz229.fittrack.ui.screens.ProfileScreen @@ -69,6 +70,7 @@ private const val SESSION_DETAIL = "session" private const val EXERCISE_DETAIL = "exercise" private const val PROFILE = "profile" private const val SCAN = "scan" +private const val HEALTH = "health" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -92,6 +94,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { val meals by viewModel.todayMeals.collectAsStateWithLifecycle() val macros by viewModel.todayMacros.collectAsStateWithLifecycle() val scanState by viewModel.scan.collectAsStateWithLifecycle() + val healthState by viewModel.health.collectAsStateWithLifecycle() val catalog by viewModel.catalog.collectAsStateWithLifecycle() val exercisesById by viewModel.exercisesById.collectAsStateWithLifecycle() val openSession by viewModel.openSession.collectAsStateWithLifecycle() @@ -104,6 +107,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { EXERCISE_DETAIL -> "Exercice" PROFILE -> "Profil" SCAN -> "Scanner un produit" + HEALTH -> "Health Connect" else -> currentTab?.label ?: "FitTrack" } @@ -229,6 +233,14 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { onDelete = viewModel::deleteMeal, ) } + composable(HEALTH) { + HealthConnectScreen( + state = healthState, + onSync = viewModel::syncHealthConnect, + onPermissionsChanged = viewModel::refreshHealthPermissions, + onUseWeight = viewModel::applyWeightFromHealth, + ) + } composable(SCAN) { ScanScreen( state = scanState, @@ -267,6 +279,7 @@ fun FitTrackApp(viewModel: FitTrackViewModel = viewModel()) { composable(PROFILE) { ProfileScreen( profile = profile, + onOpenHealthConnect = { navController.navigate(HEALTH) }, 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 13890c0..58aa3a8 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/FitTrackViewModel.kt @@ -10,6 +10,7 @@ import com.kaz229.fittrack.data.ExerciseCatalog import com.kaz229.fittrack.data.ExerciseSet import com.kaz229.fittrack.data.FitTrackRepository import com.kaz229.fittrack.data.FoodLookup +import com.kaz229.fittrack.data.HealthConnectAvailability import com.kaz229.fittrack.data.FoodProduct import com.kaz229.fittrack.data.Labels import com.kaz229.fittrack.data.MacroGoals @@ -17,6 +18,7 @@ import com.kaz229.fittrack.data.MacroTotals import com.kaz229.fittrack.data.Meal import com.kaz229.fittrack.data.MealType import com.kaz229.fittrack.data.Session +import com.kaz229.fittrack.data.SyncResult import com.kaz229.fittrack.data.UserProfile import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -41,6 +43,15 @@ data class CatalogState( val loading: Boolean = true, ) +/** Ce que l'écran Health Connect doit afficher. */ +data class HealthState( + val availability: HealthConnectAvailability = HealthConnectAvailability.UNSUPPORTED, + val permissionsGranted: Boolean = false, + val syncing: Boolean = false, + val lastSyncEpochSecond: Long? = null, + val lastResult: SyncResult? = null, +) + /** Pourquoi un code-barres scanné n'a rien donné. */ enum class ScanError { NOT_FOUND, NO_NUTRITION, NETWORK } @@ -202,6 +213,45 @@ class FitTrackViewModel(application: Application) : AndroidViewModel(application _scan.value = ScanState() } + // --- Health Connect ------------------------------------------------------ + + private val healthSyncing = MutableStateFlow(false) + private val healthPermissions = MutableStateFlow(false) + private val healthResult = MutableStateFlow(null) + + val health: StateFlow = combine( + healthSyncing, + healthPermissions, + healthResult, + repository.lastHealthSync, + ) { syncing, granted, result, lastSync -> + HealthState( + availability = repository.healthConnectAvailability(), + permissionsGranted = granted, + syncing = syncing, + lastSyncEpochSecond = lastSync, + lastResult = result, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HealthState()) + + /** À rappeler au retour de l'écran d'autorisations, qui ne renvoie pas de résultat fiable. */ + fun refreshHealthPermissions() = viewModelScope.launch { + healthPermissions.value = repository.healthConnectPermissionsGranted() + } + + fun syncHealthConnect() = viewModelScope.launch { + healthSyncing.value = true + healthResult.value = repository.syncHealthConnect(today) + healthPermissions.value = repository.healthConnectPermissionsGranted() + healthSyncing.value = false + } + + /** Reprend dans le profil le poids relu depuis Health Connect. */ + fun applyWeightFromHealth(weightKg: Double) = viewModelScope.launch { + repository.setWeight(weightKg) + healthResult.value = null + } + // --- Séances, repas, bilans --------------------------------------------- val profile: StateFlow = repository.profile diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt new file mode 100644 index 0000000..2614d0b --- /dev/null +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt @@ -0,0 +1,223 @@ +package com.kaz229.fittrack.ui.screens + +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +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.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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.health.connect.client.PermissionController +import com.kaz229.fittrack.data.HealthConnectAvailability +import com.kaz229.fittrack.data.HealthConnectManager +import com.kaz229.fittrack.data.SyncResult +import com.kaz229.fittrack.ui.HealthState +import com.kaz229.fittrack.ui.formatWeight +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Réglages de la synchronisation Health Connect. + * + * Tout y est explicite et déclenché à la main : rien ne part vers d'autres applications + * sans que l'utilisateur appuie sur le bouton. + */ +@Composable +fun HealthConnectScreen( + state: HealthState, + onSync: () -> Unit, + onPermissionsChanged: () -> Unit, + onUseWeight: (Double) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val permissionLauncher = rememberLauncherForActivityResult( + PermissionController.createRequestPermissionResultContract(), + ) { onPermissionsChanged() } + + // Les autorisations peuvent avoir changé dans les réglages de Health Connect, + // hors de l'app : on les revérifie à chaque affichage de l'écran. + LaunchedEffect(Unit) { onPermissionsChanged() } + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Health Connect", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Partage tes séances et ton journal alimentaire avec les autres " + + "applications santé du téléphone, et récupère ton poids si une autre " + + "application le suit déjà.", + style = MaterialTheme.typography.bodyMedium, + ) + + when (state.availability) { + HealthConnectAvailability.UNSUPPORTED -> InfoCard( + title = "Indisponible sur cet appareil", + body = "Health Connect demande une version d'Android plus récente. " + + "FitTrack fonctionne normalement sans.", + ) + + HealthConnectAvailability.UPDATE_REQUIRED -> Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + InfoCard( + title = "Health Connect à installer", + body = "L'application Health Connect est absente ou doit être mise à jour.", + ) + Button( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(HealthConnectManager.PLAY_STORE_URI)), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Ouvrir le Play Store") } + } + + HealthConnectAvailability.AVAILABLE -> { + if (!state.permissionsGranted) { + InfoCard( + title = "Autorisations à accorder", + body = "FitTrack écrit les séances, les calories brûlées et la " + + "nutrition, et lit uniquement le poids. Chaque autorisation " + + "reste révocable depuis Health Connect.", + ) + Button( + onClick = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Accorder les autorisations") } + } else { + Button( + onClick = onSync, + enabled = !state.syncing, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (state.syncing) "Synchronisation…" else "Synchroniser maintenant") } + OutlinedButton( + onClick = { permissionLauncher.launch(HealthConnectManager.PERMISSIONS) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Revoir les autorisations") } + } + + if (state.syncing) CircularProgressIndicator() + state.lastSyncEpochSecond?.let { LastSyncLine(it) } + state.lastResult?.let { ResultCard(result = it, onUseWeight = onUseWeight) } + } + } + + HorizontalDivider() + Text( + text = "Ce qui sort du téléphone", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "Les 30 derniers jours de séances et de repas sont envoyés à Health " + + "Connect, qui les garde sur l'appareil et ne les communique qu'aux " + + "applications que tu autorises. Chaque enregistrement porte un identifiant " + + "stable : relancer la synchronisation met à jour l'existant au lieu de créer " + + "des doublons.", + style = MaterialTheme.typography.bodySmall, + ) + Text( + text = "FitTrack ne connaît que le jour d'une séance, jamais son heure. Les " + + "séances sont donc placées en fin d'après-midi et les repas à l'heure " + + "habituelle de leur catégorie — en reculant ce qu'il faut pour qu'un " + + "enregistrement du jour ne tombe jamais dans le futur, ce que Health " + + "Connect refuse.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun LastSyncLine(epochSecond: Long) { + val formatted = remember(epochSecond) { + Instant.ofEpochSecond(epochSecond) + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("dd/MM/yyyy 'à' HH:mm")) + } + Text( + text = "Dernière synchronisation : $formatted", + style = MaterialTheme.typography.bodySmall, + ) +} + +@Composable +private fun ResultCard(result: SyncResult, onUseWeight: (Double) -> Unit) { + when (result) { + is SyncResult.Success -> Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "${result.sessions} séance(s) et ${result.meals} ligne(s) " + + "de repas envoyées.", + style = MaterialTheme.typography.bodyMedium, + ) + result.weightKg?.let { weight -> + Text( + text = "Poids trouvé dans Health Connect : ${formatWeight(weight)}.", + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton(onClick = { onUseWeight(weight) }) { + Text("Utiliser ce poids dans mon profil") + } + } + } + } + + SyncResult.PermissionsMissing -> InfoCard( + title = "Autorisations manquantes", + body = "Health Connect a refusé l'accès. Vérifie les autorisations accordées à FitTrack.", + ) + + SyncResult.Unavailable -> InfoCard( + title = "Health Connect indisponible", + body = "L'application n'a pas pu être jointe.", + ) + + is SyncResult.Failed -> InfoCard( + title = "Échec de la synchronisation", + body = result.message, + ) + } +} + +@Composable +private fun InfoCard(title: String, body: String) { + 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 e32c2be..3d5ab9f 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card 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 @@ -28,6 +29,7 @@ import com.kaz229.fittrack.data.UserProfile fun ProfileScreen( profile: UserProfile, onSave: (weightKg: Double, goal: Int, macroGoals: MacroGoals) -> Unit, + onOpenHealthConnect: () -> Unit, modifier: Modifier = Modifier, ) { var weight by remember(profile.weightKg) { @@ -130,6 +132,12 @@ fun ProfileScreen( ) { Text("Enregistrer") } + OutlinedButton( + onClick = onOpenHealthConnect, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Synchronisation Health Connect") + } Card(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.padding(16.dp), diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt index 7574363..dd3137c 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/ScanScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import java.util.concurrent.Executors +import androidx.concurrent.futures.await import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.mlkit.vision.barcode.BarcodeScanner @@ -200,29 +201,26 @@ private fun CameraPreview(onBarcode: (String) -> Unit, modifier: Modifier = Modi var provider by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - val future = ProcessCameraProvider.getInstance(context) - future.addListener({ - val cameraProvider = future.get() - val preview = Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - val analysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { imageAnalysis -> - imageAnalysis.setAnalyzer(analysisExecutor) { imageProxy -> - imageProxy.scanBarcode(scanner, onBarcode) - } + val cameraProvider = ProcessCameraProvider.getInstance(context).await() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { imageAnalysis -> + imageAnalysis.setAnalyzer(analysisExecutor) { imageProxy -> + imageProxy.scanBarcode(scanner, onBarcode) } - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - analysis, - ) - provider = cameraProvider - }, ContextCompat.getMainExecutor(context)) + } + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + provider = cameraProvider } DisposableEffect(Unit) { diff --git a/app/src/test/java/com/kaz229/fittrack/HealthConnectMappingTest.kt b/app/src/test/java/com/kaz229/fittrack/HealthConnectMappingTest.kt new file mode 100644 index 0000000..918e088 --- /dev/null +++ b/app/src/test/java/com/kaz229/fittrack/HealthConnectMappingTest.kt @@ -0,0 +1,276 @@ +package com.kaz229.fittrack + +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.MealType as HcMealType +import com.kaz229.fittrack.data.HealthConnectManager +import com.kaz229.fittrack.data.Meal +import com.kaz229.fittrack.data.MealType +import com.kaz229.fittrack.data.Session +import com.kaz229.fittrack.data.SessionKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Conversion des séances et repas de FitTrack vers les enregistrements Health Connect. + * + * Le point sensible est l'heure : l'app ne retient que le jour, Health Connect exige un + * créneau. Deux séances du même jour ne doivent donc jamais se chevaucher. + */ +class HealthConnectMappingTest { + + private val zone: ZoneId = ZoneId.of("Europe/Paris") + private val date = LocalDate.of(2026, 3, 14) + private val day = date.toEpochDay() + + /** Un « maintenant » postérieur aux créneaux conventionnels : cas d'une journée passée. */ + private val lateEnough: Instant = date.atTime(23, 0).toInstant(zone.rules.getOffset(date.atTime(23, 0))) + + private fun gymSession(id: Long, minutes: Int = 60, calories: Int = 300) = Session( + id = id, + kind = SessionKind.GYM.id, + epochDay = day, + durationMinutes = minutes, + caloriesBurned = calories, + finished = true, + ) + + @Test + fun `une seance salle donne la seance et ses calories`() { + val records = HealthConnectManager.exerciseRecords(listOf(gymSession(1)), zone, lateEnough) + + assertEquals(2, records.size) + val exercise = records.filterIsInstance().single() + val calories = records.filterIsInstance().single() + assertEquals( + ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, + exercise.exerciseType, + ) + assertEquals(300.0, calories.energy.inKilocalories, 0.001) + assertEquals(60, Duration.between(exercise.startTime, exercise.endTime).toMinutes()) + } + + @Test + fun `une seance sans calories ne produit pas d'enregistrement de depense`() { + val records = HealthConnectManager.exerciseRecords( + listOf(gymSession(1, calories = 0)), + zone, + lateEnough, + ) + + assertEquals(1, records.size) + assertTrue(records.single() is ExerciseSessionRecord) + } + + @Test + fun `deux seances du meme jour ne se chevauchent pas`() { + val records = HealthConnectManager.exerciseRecords( + listOf(gymSession(1, minutes = 45), gymSession(2, minutes = 30)), + zone, + lateEnough, + ).filterIsInstance().sortedBy { it.startTime } + + assertEquals(2, records.size) + // La seconde démarre exactement où la première s'arrête : jamais de recouvrement, + // que Health Connect refuserait. + assertTrue(records[0].endTime <= records[1].startTime) + assertEquals(45, Duration.between(records[0].startTime, records[0].endTime).toMinutes()) + assertEquals(30, Duration.between(records[1].startTime, records[1].endTime).toMinutes()) + } + + @Test + fun `une seance de duree nulle dure au moins une minute`() { + // Health Connect rejette un créneau vide : une séance clôturée sans durée saisie + // doit quand même produire un enregistrement valide. + val records = HealthConnectManager.exerciseRecords( + listOf(gymSession(1, minutes = 0)), + zone, + lateEnough, + ).filterIsInstance() + + val record = records.single() + assertTrue(record.endTime > record.startTime) + } + + @Test + fun `les identifiants clients sont stables et distincts`() { + val first = HealthConnectManager.exerciseRecords(listOf(gymSession(7)), zone, lateEnough) + val second = HealthConnectManager.exerciseRecords(listOf(gymSession(7)), zone, lateEnough) + + val ids = first.map { it.metadata.clientRecordId } + // Rejouer la synchronisation doit produire les mêmes identifiants, sinon Health + // Connect créerait des doublons au lieu de mettre à jour. + assertEquals(ids, second.map { it.metadata.clientRecordId }) + assertEquals(ids.toSet().size, ids.size) + assertTrue(ids.all { it != null && it.startsWith("fittrack-session-") }) + } + + @Test + fun `une activite cardio est traduite dans le bon type`() { + val cardio = Session( + id = 1, + kind = SessionKind.CARDIO.id, + epochDay = day, + activityId = "course", + durationMinutes = 40, + caloriesBurned = 400, + finished = true, + ) + + val record = HealthConnectManager.exerciseRecords(listOf(cardio), zone, lateEnough) + .filterIsInstance() + .single() + + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, record.exerciseType) + assertEquals("Course à pied (extérieur)", record.title) + } + + @Test + fun `une activite sans equivalent retombe sur autre entrainement`() { + val cordeASauter = Session( + id = 1, + kind = SessionKind.CARDIO.id, + epochDay = day, + activityId = "corde", + durationMinutes = 15, + finished = true, + ) + + val record = HealthConnectManager.exerciseRecords(listOf(cordeASauter), zone, lateEnough) + .filterIsInstance() + .single() + + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT, record.exerciseType) + } + + @Test + fun `un repas porte ses calories et ses macros`() { + val meal = Meal( + id = 3, + name = "Riz blanc cuit", + calories = 350, + mealType = MealType.LUNCH.id, + epochDay = day, + proteinG = 7.0, + carbsG = 77.0, + fatG = 0.6, + ) + + val record = HealthConnectManager.nutritionRecord(meal, zone, lateEnough) + + assertEquals("Riz blanc cuit", record.name) + assertEquals(HcMealType.MEAL_TYPE_LUNCH, record.mealType) + assertEquals(350.0, record.energy!!.inKilocalories, 0.001) + assertEquals(7.0, record.protein!!.inGrams, 0.001) + assertEquals(77.0, record.totalCarbohydrate!!.inGrams, 0.001) + assertEquals(0.6, record.totalFat!!.inGrams, 0.001) + assertEquals("fittrack-meal-3", record.metadata.clientRecordId) + } + + @Test + fun `un repas sans macros n'envoie pas de zeros`() { + val meal = Meal( + id = 4, + name = "Poulet basquaise maison", + calories = 620, + mealType = MealType.DINNER.id, + epochDay = day, + ) + + val record = HealthConnectManager.nutritionRecord(meal, zone, lateEnough) + + // Envoyer 0 g de protéines serait une affirmation fausse : l'information est + // absente, pas nulle. + assertNull(record.protein) + assertNull(record.totalCarbohydrate) + assertNull(record.totalFat) + assertEquals(620.0, record.energy!!.inKilocalories, 0.001) + } + + @Test + fun `une seance du jour ne part jamais dans le futur`() { + // Synchronisation lancée à 16 h : le créneau conventionnel de 18 h déborderait, + // et Health Connect rejetterait tout l'envoi. + val maintenant = date.atTime(16, 0).let { it.toInstant(zone.rules.getOffset(it)) } + + val record = HealthConnectManager.exerciseRecords( + listOf(gymSession(1, minutes = 75)), + zone, + maintenant, + ).filterIsInstance().single() + + assertTrue("Début dans le futur : ${record.startTime}", record.startTime < maintenant) + assertTrue("Fin dans le futur : ${record.endTime}", record.endTime <= maintenant) + assertEquals(75, Duration.between(record.startTime, record.endTime).toMinutes()) + } + + @Test + fun `plusieurs seances ramenees en arriere ne se chevauchent pas`() { + val maintenant = date.atTime(16, 0).let { it.toInstant(zone.rules.getOffset(it)) } + + val records = HealthConnectManager.exerciseRecords( + listOf(gymSession(1, minutes = 45), gymSession(2, minutes = 30)), + zone, + maintenant, + ).filterIsInstance().sortedBy { it.startTime } + + assertTrue(records[0].endTime <= records[1].startTime) + assertTrue(records[1].endTime <= maintenant) + } + + @Test + fun `un repas du jour ne part jamais dans le futur`() { + // Un dîner saisi à l'avance, ou une synchronisation en milieu d'après-midi. + val maintenant = date.atTime(16, 0).let { it.toInstant(zone.rules.getOffset(it)) } + val diner = Meal( + id = 1, + name = "Poulet", + calories = 500, + mealType = MealType.DINNER.id, + epochDay = day, + ) + + val record = HealthConnectManager.nutritionRecord(diner, zone, maintenant) + + assertTrue(record.startTime < maintenant) + assertTrue(record.endTime <= maintenant) + } + + @Test + fun `une journee passee garde ses heures conventionnelles`() { + val hier = Session( + id = 1, + kind = SessionKind.GYM.id, + epochDay = day - 1, + durationMinutes = 60, + caloriesBurned = 300, + finished = true, + ) + + val record = HealthConnectManager.exerciseRecords(listOf(hier), zone, lateEnough) + .filterIsInstance() + .single() + + assertEquals(18, record.startTime.atZone(zone).hour) + } + + @Test + fun `chaque type de repas tombe a son heure habituelle`() { + fun heure(type: MealType): Int { + val meal = Meal(id = 1, name = "x", calories = 1, mealType = type.id, epochDay = day) + return HealthConnectManager.nutritionRecord(meal, zone, lateEnough) + .startTime.atZone(zone).hour + } + + assertEquals(8, heure(MealType.BREAKFAST)) + assertEquals(12, heure(MealType.LUNCH)) + assertEquals(19, heure(MealType.DINNER)) + assertEquals(16, heure(MealType.SNACK)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0b74bc..b7e8b8e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.3" +agp = "8.13.2" kotlin = "2.0.21" ksp = "2.0.21-1.0.28" coreKtx = "1.15.0" @@ -9,7 +9,9 @@ composeBom = "2024.12.01" navigation = "2.8.5" room = "2.6.1" datastore = "1.1.1" -camerax = "1.4.1" +healthConnect = "1.1.0" +concurrentFutures = "1.3.0" +camerax = "1.6.2" mlkitBarcode = "17.3.0" junit = "4.13.2" orgJson = "20240303" @@ -34,6 +36,8 @@ androidx-room-runtime = { group = "androidx.room", name = "room-runtime", versio androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +androidx-health-connect-client = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" } +androidx-concurrent-futures-ktx = { group = "androidx.concurrent", name = "concurrent-futures-ktx", version.ref = "concurrentFutures" } androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55..a4b76b9 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c8..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 23d15a9..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -86,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +115,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -205,7 +206,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -213,7 +214,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee..9b42019 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell