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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Deux exceptions au « tout local » :
- **Open Food Facts** : quand tu scannes un code-barres, ce code-barres est envoyé à
[Open Food Facts](https://world.openfoodfacts.org) pour récupérer les valeurs nutritionnelles du
produit. Le produit reçu est mis en cache localement pour rester consultable hors ligne.
- **Kazis**, uniquement si tu le configures : les repas dictés à ton assistant personnel sont
récupérés depuis ton serveur, sur ton réseau privé, et tes séances, séries et repas y sont
sauvegardés. C'est la seule copie de tes charges hors du téléphone.
- **Health Connect**, uniquement si tu l'actives : tes séances et ton journal alimentaire sont
partagés avec le magasin de santé d'Android, qui les garde sur l'appareil et ne les transmet
qu'aux applications que tu autorises explicitement.
Expand Down Expand Up @@ -56,6 +59,7 @@ corde à sauter 11,0 · rameur 7,0…). C'est une estimation, pas une mesure mé
| Scan | CameraX + ML Kit (barcode-scanning groupé, fonctionne sans Play Services) |
| Nutrition | API Open Food Facts v2 en lecture, `HttpURLConnection`, cache Room |
| Santé | Health Connect (écriture séances, calories et nutrition ; lecture du poids) |
| Repas dictés | file récupérée sur le serveur Kazis (réseau privé, jeton partagé) |
| Build | Gradle 8.11 + AGP 8.7, `minSdk 26`, `targetSdk 35` |

Architecture : `data` (entités Room, DAO, catalogue, repository) → `ui` (ViewModel exposant des
Expand Down Expand Up @@ -95,6 +99,7 @@ app/src/main/
│ ├── Nutrition.kt # FoodProduct, portions, macros et objectifs
│ ├── OpenFoodFacts.kt # client de lecture de l'API + parsing
│ ├── HealthConnect.kt # export des séances et repas, lecture du poids
│ ├── Kazis.kt # récupération des repas dictés à l'assistant
│ ├── Exercise.kt # exercice du catalogue + libellés FR
│ ├── ExerciseCatalog.kt # chargement, recherche, filtres
│ ├── Activities.kt # activités cardio (MET) et calcul des calories
Expand Down
118 changes: 118 additions & 0 deletions app/src/androidTest/java/com/kaz229/fittrack/KazisBackupTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.kaz229.fittrack

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

/**
* Sauvegarde du contenu de l'app vers Kazis, contre un vrai serveur.
*
* Sans serveur joignable, le test vérifie seulement que l'échec est propre, et le dit
* dans logcat pour qu'un vert ne trompe personne.
*/
@RunWith(AndroidJUnit4::class)
class KazisBackupTest {

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

private val url = "http://10.0.2.2:8799"
private val token = "jeton-de-test"

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

@After
fun tearDown() {
db.close()
}

private suspend fun remplir() {
val id = repository.startGymSession(today)
repository.addSet(id, "0025", reps = 10, weightKg = 60.0)
repository.addSet(id, "0025", reps = 8, weightKg = 65.0)
repository.finishGymSession(id, durationMinutes = 75, note = "Push day")
repository.addMeal("ravioles fraiches", 420, MealType.LUNCH, 24.0, 36.0, 21.0, today)
}

@Test
fun sans_reglages_rien_n_est_envoye() = runTest {
preferences.setKazis(KazisSettings())

assertEquals(KazisBackup.NotConfigured, repository.backupToKazis())
}

@Test
fun un_jeton_refuse_est_signale() = runTest {
preferences.setKazis(KazisSettings(url, "mauvais-jeton"))

val result = repository.backupToKazis()

if (result is KazisBackup.Failed) {
Log.w(TAG, "IGNORÉ : serveur de test injoignable ($result)")
return@runTest
}
assertEquals(KazisBackup.Unauthorized, result)
}

@Test
fun tout_le_contenu_part_en_sauvegarde() = runTest {
preferences.setKazis(KazisSettings(url, token))
remplir()

val result = repository.backupToKazis()
if (result is KazisBackup.Failed) {
Log.w(TAG, "IGNORÉ : serveur de test injoignable ($result)")
return@runTest
}
Log.i(TAG, "Sauvegarde : $result")

assertTrue("Attendu un succès, obtenu $result", result is KazisBackup.Success)
val success = result as KazisBackup.Success
assertEquals(1, success.sessions)
// Les séries sont le cœur : ce que Health Connect ne conserve pas.
assertEquals(2, success.sets)
assertEquals(1, success.meals)

// Rejouée, la sauvegarde renvoie la même chose : côté serveur rien ne double.
val again = repository.backupToKazis()
assertEquals(result, again)
}

private companion object {
const val TAG = "KazisBackupTest"
}
}
126 changes: 126 additions & 0 deletions app/src/androidTest/java/com/kaz229/fittrack/KazisFetchTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.kaz229.fittrack

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

/**
* Récupération des repas dictés, contre un vrai serveur Kazis.
*
* Le serveur de test tourne sur la machine hôte, joignable depuis l'émulateur par
* 10.0.2.2. Sans lui, le test constate seulement que l'échec est propre et que rien
* n'est enregistré — et le dit dans logcat, pour qu'un vert ne trompe personne.
*/
@RunWith(AndroidJUnit4::class)
class KazisFetchTest {

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

private val url = "http://10.0.2.2:8799"
private val token = "jeton-de-test"

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

@After
fun tearDown() {
db.close()
}

@Test
fun sans_reglages_rien_n_est_tente() = runTest {
preferences.setKazis(KazisSettings())

assertEquals(KazisFetch.NotConfigured, repository.fetchKazisMeals(today))
assertTrue(repository.mealsForDay(today).first().isEmpty())
}

@Test
fun un_jeton_refuse_est_signale_sans_rien_enregistrer() = runTest {
preferences.setKazis(KazisSettings(url, "mauvais-jeton"))

val result = repository.fetchKazisMeals(today)

if (result is KazisFetch.Failed) {
Log.w(TAG, "IGNORÉ : serveur de test injoignable ($result)")
return@runTest
}
assertEquals(KazisFetch.Unauthorized, result)
assertTrue(repository.mealsForDay(today).first().isEmpty())
}

@Test
fun les_repas_dictes_arrivent_dans_le_journal() = runTest {
preferences.setKazis(KazisSettings(url, token))

val result = repository.fetchKazisMeals(today)
if (result is KazisFetch.Failed) {
Log.w(TAG, "IGNORÉ : serveur de test injoignable ($result)")
return@runTest
}
Log.i(TAG, "Récupération : $result")

assertTrue("Attendu un succès, obtenu $result", result is KazisFetch.Success)
val meals = repository.mealsForDay(today).first()
assertTrue("Aucun repas enregistré", meals.isNotEmpty())

val riz = meals.firstOrNull { it.name.contains("riz") }
if (riz != null) {
assertEquals(195, riz.calories)
assertEquals(4.0, riz.proteinG, 0.001)
assertEquals(MealType.LUNCH.id, riz.mealType)
}

// Le produit donné par code-barres doit avoir été résolu chez Open Food Facts.
val produit = meals.firstOrNull { it.barcode == "3017620422003" }
if (produit != null) {
Log.i(TAG, "Produit résolu : ${produit.name} ${produit.calories} kcal")
assertTrue("Le produit scanné doit porter ses calories", produit.calories > 0)
assertEquals(30.0, produit.grams!!, 0.001)
}

// Deuxième passage : la file a été vidée par l'accusé de réception.
val again = repository.fetchKazisMeals(today)
assertTrue(again is KazisFetch.Success)
assertEquals(0, (again as KazisFetch.Success).added)
}

private companion object {
const val TAG = "KazisFetchTest"
}
}
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FitTrack">
Expand Down
12 changes: 12 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 @@ -32,6 +32,10 @@ interface SessionDao {
@Query("SELECT * FROM sessions WHERE epochDay >= :fromDay ORDER BY epochDay, id")
suspend fun since(fromDay: Long): List<Session>

/** Tout l'historique, pour la sauvegarde. */
@Query("SELECT * FROM sessions ORDER BY id")
suspend fun all(): List<Session>

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

Expand Down Expand Up @@ -73,6 +77,10 @@ interface ExerciseSetDao {

@Query("SELECT COUNT(*) FROM exercise_sets WHERE epochDay = :epochDay")
fun observeSetCountForDay(epochDay: Long): Flow<Int>

/** 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>
}

@Dao
Expand All @@ -90,6 +98,10 @@ interface MealDao {
@Query("SELECT * FROM meals WHERE epochDay >= :fromDay ORDER BY epochDay, id")
suspend fun since(fromDay: Long): List<Meal>

/** Tout le journal, pour la sauvegarde. */
@Query("SELECT * FROM meals ORDER BY id")
suspend fun all(): List<Meal>

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

Expand Down
Loading
Loading