Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,16 @@ Rien d'autre ne sort du téléphone, et les deux restent facultatifs.
- **Progression** : chaque fiche d'exercice affiche ton **record personnel** et tout l'historique
de tes séries sur cet exercice.
- **Cardio / sport** : tapis, rameur, foot… une activité et une durée suffisent.
- **Séance oubliée** : au bout de 10 minutes sans nouvelle série, l'app demande si tu es
toujours là — une séance laissée ouverte fausse sa durée et ses calories.
- **Séances venues d'ailleurs** : celles enregistrées dans une autre application (Lyfta, une
montre) sont reprises depuis Health Connect, sans doublon.
- **Repas au code-barres** : on scanne le produit, l'app récupère ses valeurs nutritionnelles chez
Open Food Facts, on saisit la quantité en grammes et l'apport est calculé au prorata —
calories, protéines, glucides, lipides.
- **Recherche par nom** : « sky » retrouve le skyr mangé il y a deux jours, à réinscrire en un
tap, puis propose les produits d'Open Food Facts. L'historique passe devant : on remange
surtout les mêmes choses, et il répond sans réseau.
- **Saisie libre** : pour la cuisine maison ou un produit absent de la base, avec macros optionnelles.
- **Accueil** : bilan calorique du jour (ingéré / brûlé / net), macros face aux objectifs, volume
soulevé, progression vers l'objectif, historique des 7 derniers jours.
Expand Down Expand Up @@ -58,7 +65,8 @@ corde à sauter 11,0 · rameur 7,0…). C'est une estimation, pas une mesure mé
| Catalogue | JSON embarqué dans les assets, chargé en mémoire au lancement |
| Scan | CameraX + ML Kit (barcode-scanning groupé, fonctionne sans Play Services) |
| Nutrition | API Open Food Facts v2 en lecture, `HttpURLConnection`, cache Room |
| Santé | Health Connect (écriture séances, calories et nutrition ; lecture du poids) |
| Santé | Health Connect (écriture séances, calories et nutrition ; lecture du poids et des séances) |
| Rappels | WorkManager |
| Repas dictés | file récupérée sur le serveur Kazis (réseau privé, jeton partagé) |
| Build | Gradle 8.11 + AGP 8.7, `minSdk 26`, `targetSdk 35` |

Expand Down Expand Up @@ -99,7 +107,8 @@ app/src/main/
│ ├── Nutrition.kt # FoodProduct, portions, macros et objectifs
│ ├── OpenFoodFacts.kt # client de lecture de l'API + parsing
│ ├── HealthConnect.kt # export des séances et repas, lecture du poids
│ ├── Kazis.kt # récupération des repas dictés à l'assistant
│ ├── Kazis.kt # repas dictés, sauvegarde et restauration
│ ├── SessionReminder.kt # rappel quand une séance reste ouverte
│ ├── Exercise.kt # exercice du catalogue + libellés FR
│ ├── ExerciseCatalog.kt # chargement, recherche, filtres
│ ├── Activities.kt # activités cardio (MET) et calcul des calories
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ dependencies {
implementation(libs.androidx.datastore.preferences)

implementation(libs.androidx.health.connect.client)
implementation(libs.androidx.work.runtime.ktx)

implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
Expand Down
106 changes: 106 additions & 0 deletions app/src/androidTest/java/com/kaz229/fittrack/FoodSearchTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package com.kaz229.fittrack

import android.content.Context
import android.util.Log
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kaz229.fittrack.data.ExerciseCatalog
import com.kaz229.fittrack.data.FitTrackDatabase
import com.kaz229.fittrack.data.FitTrackRepository
import com.kaz229.fittrack.data.MealType
import com.kaz229.fittrack.data.UserPreferences
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.LocalDate

/** Recherche d'aliments : historique local d'abord, Open Food Facts ensuite. */
@RunWith(AndroidJUnit4::class)
class FoodSearchTest {

private lateinit var db: FitTrackDatabase
private lateinit var repository: FitTrackRepository
private val today: LocalDate = LocalDate.now()

@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, FitTrackDatabase::class.java).build()
repository = FitTrackRepository(
sessionDao = db.sessionDao(),
exerciseSetDao = db.exerciseSetDao(),
mealDao = db.mealDao(),
foodProductDao = db.foodProductDao(),
userPreferences = UserPreferences(context),
exercises = ExerciseCatalog.get(context),
)
}

@After
fun tearDown() = db.close()

@Test
fun un_aliment_deja_saisi_se_retrouve_par_son_debut() = runTest {
repository.addMeal("Skyr nature Lidl", 130, MealType.BREAKFAST, 20.0, 8.0, 0.4, today.minusDays(2))

val found = repository.searchFoods("sky")

assertEquals(listOf("Skyr nature Lidl"), found.history.map { it.name })
assertEquals(130, found.history.single().calories)
}

@Test
fun seule_la_derniere_version_d_un_aliment_est_proposee() = runTest {
repository.addMeal("Skyr nature", 120, MealType.BREAKFAST, 19.0, 7.0, 0.3, today.minusDays(3))
repository.addMeal("Skyr nature", 180, MealType.BREAKFAST, 28.0, 11.0, 0.5, today.minusDays(1))

val found = repository.searchFoods("skyr")

// Un même aliment revient souvent : la dernière version est la plus à jour
assertEquals(1, found.history.size)
assertEquals(180, found.history.single().calories)
}

@Test
fun une_recherche_trop_courte_ne_cherche_rien() = runTest {
repository.addMeal("Skyr", 130, MealType.BREAKFAST, date = today)

assertTrue(repository.searchFoods("s").isEmpty)
}

@Test
fun un_aliment_repete_revient_au_jour_et_au_repas_choisis() = runTest {
repository.addMeal("Skyr nature", 130, MealType.BREAKFAST, 20.0, 8.0, 0.4, today.minusDays(2))
val ancien = repository.searchFoods("skyr").history.single()

repository.repeatMeal(ancien, MealType.SNACK, today)

val aujourdhui = repository.mealsForDay(today).first()
assertEquals(1, aujourdhui.size)
assertEquals(MealType.SNACK.id, aujourdhui.single().mealType)
assertEquals(130, aujourdhui.single().calories)
assertEquals(20.0, aujourdhui.single().proteinG, 0.001)
}

@Test
fun open_food_facts_complete_la_recherche() = runTest {
val found = repository.searchFoods("skyr")

if (found.products.isEmpty()) {
Log.w(TAG, "IGNORÉ : Open Food Facts injoignable ou sans résultat")
return@runTest
}
Log.i(TAG, "Trouvés : ${found.products.take(3).map { it.displayName }}")
assertTrue("Les produits doivent porter leurs calories", found.products.all { it.kcalPer100g > 0 })
}

private companion object {
const val TAG = "FoodSearchTest"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.kaz229.fittrack

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.kaz229.fittrack.data.SessionReminder
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/** Programmation du rappel de séance oubliée. */
@RunWith(AndroidJUnit4::class)
class SessionReminderTest {

private val context: Context = ApplicationProvider.getApplicationContext()
private val work = WorkManager.getInstance(context)

private fun infos(sessionId: Long): List<WorkInfo> =
work.getWorkInfosForUniqueWork("seance-inactive-$sessionId").get()

@Test
fun demarrer_une_seance_programme_un_rappel() = runTest {
SessionReminder.schedule(context, 42)

val info = infos(42).single()
assertEquals(WorkInfo.State.ENQUEUED, info.state)
}

@Test
fun une_nouvelle_serie_repousse_le_rappel_au_lieu_d_en_empiler_un_second() = runTest {
SessionReminder.schedule(context, 43)
val premier = infos(43).single().id

SessionReminder.schedule(context, 43)

val apres = infos(43).filter { it.state == WorkInfo.State.ENQUEUED }
assertEquals(1, apres.size)
assertTrue("Le rappel doit être remplacé, pas doublé", apres.single().id != premier)
}

@Test
fun terminer_la_seance_annule_le_rappel() = runTest {
SessionReminder.schedule(context, 44)

SessionReminder.cancel(context, 44)

assertTrue(infos(44).all { it.state == WorkInfo.State.CANCELLED })
}

@Test
fun le_rappel_affiche_bien_une_notification() = runTest {
val manager = context.getSystemService(android.app.NotificationManager::class.java)
manager.cancelAll()

SessionReminder.notifyIdle(context, 99)

val active = manager.activeNotifications.firstOrNull { it.id == 99 }
if (active == null) {
// Sans l'autorisation POST_NOTIFICATIONS, l'app n'insiste pas : c'est voulu.
android.util.Log.w("SessionReminderTest", "IGNORÉ : notifications non autorisées")
return@runTest
}
val extras = active.notification.extras
assertEquals("Toujours à la salle ?", extras.getCharSequence("android.title"))
assertTrue(
extras.getCharSequence("android.text").toString().contains("10 minutes"),
)
manager.cancelAll()
}

@Test
fun deux_seances_ont_chacune_leur_rappel() = runTest {
SessionReminder.schedule(context, 45)
SessionReminder.schedule(context, 46)

SessionReminder.cancel(context, 45)

assertTrue(infos(45).all { it.state == WorkInfo.State.CANCELLED })
assertEquals(WorkInfo.State.ENQUEUED, infos(46).single().state)
}
}
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.CAMERA" />
<!-- Interrogation d'Open Food Facts : seul le code-barres scanné sort du téléphone. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Rappel quand une séance reste ouverte sans qu'on y ajoute rien. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<uses-feature
android:name="android.hardware.camera.any"
Expand All @@ -19,6 +21,9 @@
<uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.WRITE_NUTRITION" />
<uses-permission android:name="android.permission.health.READ_WEIGHT" />
<!-- Récupérer les séances enregistrées par une autre application (Lyfta, montre…). -->
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />

<!-- Nécessaire pour détecter si Health Connect est installé (Android 11+). -->
<queries>
Expand Down
33 changes: 33 additions & 0 deletions app/src/main/java/com/kaz229/fittrack/data/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ interface SessionDao {
@Query("SELECT * FROM sessions ORDER BY id")
suspend fun all(): List<Session>

/** Les identifiants Health Connect déjà importés, pour ne pas les reprendre. */
@Query("SELECT externalId FROM sessions WHERE externalId IS NOT NULL")
suspend fun importedExternalIds(): List<String>

/** Réécrit les séances d'une restauration, identifiants d'origine compris. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(sessions: List<Session>)

@Query("SELECT COALESCE(SUM(caloriesBurned), 0) FROM sessions WHERE epochDay = :epochDay")
fun observeCaloriesForDay(epochDay: Long): Flow<Int>

Expand Down Expand Up @@ -81,6 +89,9 @@ interface ExerciseSetDao {
/** Toutes les séries, pour la sauvegarde : c'est ce que rien d'autre ne conserve. */
@Query("SELECT * FROM exercise_sets ORDER BY id")
suspend fun all(): List<ExerciseSet>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(sets: List<ExerciseSet>)
}

@Dao
Expand All @@ -102,6 +113,21 @@ interface MealDao {
@Query("SELECT * FROM meals ORDER BY id")
suspend fun all(): List<Meal>

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(meals: List<Meal>)

/**
* Les aliments déjà saisis qui correspondent à la recherche, le plus récent d'abord.
*
* Un même aliment revenant souvent, on ne garde que sa dernière version : c'est
* celle dont la quantité et les macros sont les plus à jour.
*/
@Query(
"SELECT * FROM meals WHERE name LIKE '%' || :query || '%' " +
"GROUP BY LOWER(name) HAVING id = MAX(id) ORDER BY id DESC LIMIT :limit",
)
suspend fun searchHistory(query: String, limit: Int = 8): List<Meal>

@Query("SELECT COALESCE(SUM(calories), 0) FROM meals WHERE epochDay = :epochDay")
fun observeCaloriesForDay(epochDay: Long): Flow<Int>

Expand Down Expand Up @@ -129,4 +155,11 @@ interface FoodProductDao {

@Query("SELECT * FROM food_products WHERE barcode = :barcode")
suspend fun findByBarcode(barcode: String): FoodProduct?

/** Produits déjà scannés qui correspondent à la recherche : disponibles hors ligne. */
@Query(
"SELECT * FROM food_products WHERE name LIKE '%' || :query || '%' " +
"OR brands LIKE '%' || :query || '%' ORDER BY fetchedAtEpochDay DESC LIMIT :limit",
)
suspend fun search(query: String, limit: Int = 8): List<FoodProduct>
}
18 changes: 16 additions & 2 deletions app/src/main/java/com/kaz229/fittrack/data/FitTrackDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase

@Database(
entities = [Session::class, ExerciseSet::class, Meal::class, FoodProduct::class],
version = 2,
version = 3,
exportSchema = false,
)
abstract class FitTrackDatabase : RoomDatabase() {
Expand Down Expand Up @@ -50,12 +50,26 @@ abstract class FitTrackDatabase : RoomDatabase() {
}
}

/**
* v2 → v3 : les séances peuvent venir d'une autre application via Health Connect.
* L'index unique sur l'identifiant d'origine empêche les doublons à l'import.
*/
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE sessions ADD COLUMN externalId TEXT")
db.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS index_sessions_externalId" +
" ON sessions (externalId)",
)
}
}

fun get(context: Context): FitTrackDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
FitTrackDatabase::class.java,
"fittrack.db",
).addMigrations(MIGRATION_1_2).build().also { instance = it }
).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build().also { instance = it }
}
}
}
Loading
Loading