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