Compare commits

..

3 Commits

Author SHA1 Message Date
mac
f1379e7cc3 test 2026-03-09 21:29:55 -04:00
2bec3bc681 Merge pull request 'changes' (#8) from dev into master
Reviewed-on: #8
2026-03-09 20:57:17 -04:00
9b87930e9a changes 2026-03-09 20:55:48 -04:00
16 changed files with 370 additions and 124 deletions

View File

@@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-02-20T21:41:35.128842100Z"> <DropdownSelection timestamp="2026-03-10T00:31:28.813056900Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\marca\.android\avd\Medium_Phone.avd" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=58051FDCG0038S" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

3
.idea/misc.xml generated
View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

2
.idea/vcs.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>

View File

@@ -18,6 +18,7 @@ android {
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
// Incrémentation automatique du versionCode basé sur le temps
versionCode = (System.currentTimeMillis() / 60000).toInt() versionCode = (System.currentTimeMillis() / 60000).toInt()
versionName = "1.0." + (System.currentTimeMillis() / 3600000).toString().takeLast(3) versionName = "1.0." + (System.currentTimeMillis() / 3600000).toString().takeLast(3)
@@ -25,6 +26,7 @@ android {
} }
signingConfigs { signingConfigs {
// Chargement des propriétés depuis local.properties
val keystoreProperties = Properties() val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("local.properties") val keystorePropertiesFile = rootProject.file("local.properties")
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
@@ -33,13 +35,14 @@ android {
getByName("debug") { getByName("debug") {
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore") storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
storePassword = "android" getByName("debug").storePassword = "android"
keyAlias = "androiddebugkey" getByName("debug").keyAlias = "androiddebugkey"
keyPassword = "android" getByName("debug").keyPassword = "android"
} }
create("release") { create("release") {
storeFile = file("C:\\Users\\mac\\keys\\keys") // Utilisation d'un chemin relatif au dossier Utilisateur pour fonctionner sur tous les PC
storeFile = file("${System.getProperty("user.home")}/keys/keys")
storePassword = keystoreProperties.getProperty("RELEASE_STORE_PASSWORD") storePassword = keystoreProperties.getProperty("RELEASE_STORE_PASSWORD")
keyAlias = keystoreProperties.getProperty("RELEASE_KEY_ALIAS") ?: "key0" keyAlias = keystoreProperties.getProperty("RELEASE_KEY_ALIAS") ?: "key0"
keyPassword = keystoreProperties.getProperty("RELEASE_KEY_PASSWORD") keyPassword = keystoreProperties.getProperty("RELEASE_KEY_PASSWORD")
@@ -51,7 +54,7 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true // Activer l'offuscation
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -109,7 +112,9 @@ dependencies {
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
// SDK Firebase App Distribution COMPLET (API + Implémentation)
implementation(libs.firebase.appdistribution) implementation(libs.firebase.appdistribution)
implementation(libs.google.generativeai) implementation(libs.google.generativeai)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.androidx.exifinterface) implementation(libs.androidx.exifinterface)
@@ -129,13 +134,21 @@ dependencies {
implementation(libs.firebase.firestore) implementation(libs.firebase.firestore)
implementation(libs.firebase.appcheck.playintegrity) implementation(libs.firebase.appcheck.playintegrity)
// On met le debug provider en implementation pour qu'il soit disponible à la compilation en Release
// (le code MainActivity utilise un check BuildConfig.DEBUG pour ne pas l'utiliser en prod)
implementation(libs.firebase.appcheck.debug)
// Barcode Scanning & Camera
implementation(libs.mlkit.barcode.scanning) implementation(libs.mlkit.barcode.scanning)
implementation(libs.androidx.camera.core) implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view) implementation(libs.androidx.camera.view)
// PDF generation
implementation(libs.itext7.core) implementation(libs.itext7.core)
// Security
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
testImplementation(libs.junit) testImplementation(libs.junit)
@@ -145,5 +158,4 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.firebase.appcheck.debug)
} }

View File

@@ -29,6 +29,14 @@
"certificate_hash": "ebcc060f9a1fdeb1186536d3828574b42cefa03c" "certificate_hash": "ebcc060f9a1fdeb1186536d3828574b42cefa03c"
} }
}, },
{
"client_id": "652626507041-i928hstoseh72dta5d0lokm9c55tma2p.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.example.scanwich",
"certificate_hash": "6f363d957ca44b3ca607c29f58f575d0ae71571d"
}
},
{ {
"client_id": "652626507041-5n42q37adh1guuv9gibfcf5uvekgunbe.apps.googleusercontent.com", "client_id": "652626507041-5n42q37adh1guuv9gibfcf5uvekgunbe.apps.googleusercontent.com",
"client_type": 3 "client_type": 3

View File

@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -14,6 +17,17 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Coloricam"> android:theme="@style/Theme.Coloricam">
<receiver
android:name=".MealReminderReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View File

@@ -60,6 +60,9 @@ fun AuthWrapper(dao: AppDao) {
var isAuthorized by remember { mutableStateOf<Boolean?>(null) } var isAuthorized by remember { mutableStateOf<Boolean?>(null) }
// État pour savoir si la synchro initiale est terminée
var syncCompleted by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try { try {
@@ -77,8 +80,8 @@ fun AuthWrapper(dao: AppDao) {
} catch (e: ApiException) { } catch (e: ApiException) {
Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}") Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}")
val msg = when (e.statusCode) { val msg = when (e.statusCode) {
10 -> "Erreur 10 : SHA-1 non reconnu. Assurez-vous d'avoir ajouté le SHA-1 de VOS clés." 10 -> "Erreur 10 : SHA-1 non reconnu."
7 -> "Erreur 7 : Problème de réseau." 7 -> "Erreur 7 : Réseau."
else -> "Erreur Google (Code ${e.statusCode})." else -> "Erreur Google (Code ${e.statusCode})."
} }
Toast.makeText(context, msg, Toast.LENGTH_LONG).show() Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
@@ -90,6 +93,7 @@ fun AuthWrapper(dao: AppDao) {
googleSignInClient.signOut().addOnCompleteListener { googleSignInClient.signOut().addOnCompleteListener {
firebaseUser = null firebaseUser = null
isAuthorized = null isAuthorized = null
syncCompleted = false
} }
} }
@@ -98,23 +102,20 @@ fun AuthWrapper(dao: AppDao) {
val email = firebaseUser?.email?.trim()?.lowercase() val email = firebaseUser?.email?.trim()?.lowercase()
if (email != null && email.isNotEmpty()) { if (email != null && email.isNotEmpty()) {
Log.d("Auth", "Vérification de l'autorisation pour l'email: '$email'")
try { try {
val db = FirebaseFirestore.getInstance("scan-wich") val db = FirebaseFirestore.getInstance("scan-wich")
val docRef = db.collection("authorized_users").document(email) val docRef = db.collection("authorized_users").document(email)
val document = docRef.get().await() val document = docRef.get().await()
if (document.exists()) { if (document.exists()) {
Log.d("Auth", "Accès AUTORISÉ pour '$email'.") Log.d("Auth", "Accès autorisé. Lancement de la synchronisation...")
// --- NOUVEAU : Rapatriement des données ---
coroutineScope.launch(Dispatchers.IO) {
FirebaseUtils.fetchAllDataFromFirestore(dao)
}
val prefs = ApiClient.getEncryptedPrefs(context)
// On attend que la synchro soit finie pour afficher l'app
FirebaseUtils.fetchAllDataFromFirestore(dao, prefs)
syncCompleted = true
isAuthorized = true isAuthorized = true
} else { } else {
Log.w("Auth", "Accès REFUSÉ pour '$email'.")
isAuthorized = false isAuthorized = false
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -130,15 +131,26 @@ fun AuthWrapper(dao: AppDao) {
LoginScreen { launcher.launch(googleSignInClient.signInIntent) } LoginScreen { launcher.launch(googleSignInClient.signInIntent) }
} else { } else {
when (isAuthorized) { when (isAuthorized) {
true -> MainApp(dao = dao, onLogout = onLogout, userId = firebaseUser!!.uid) true -> {
if (syncCompleted) {
MainApp(dao = dao, onLogout = onLogout, userId = firebaseUser!!.uid)
} else {
LoadingBox("Synchronisation de votre profil...")
}
}
false -> AccessDeniedScreen(onLogout) false -> AccessDeniedScreen(onLogout)
null -> { null -> LoadingBox("Vérification de l'accès...")
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { }
CircularProgressIndicator() }
Spacer(modifier = Modifier.height(8.dp)) }
Text("Vérification et synchronisation...")
} @Composable
} fun LoadingBox(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(text)
} }
} }
} }

View File

@@ -8,50 +8,49 @@ import kotlinx.coroutines.flow.Flow
@Entity(tableName = "meals") @Entity(tableName = "meals")
data class Meal( data class Meal(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey val date: Long = 0, // La date devient la clé unique pour éviter les doublons
val date: Long,
val name: String = "Repas", val name: String = "Repas",
val analysisText: String, val analysisText: String = "",
val totalCalories: Int, val totalCalories: Int = 0,
val carbs: Int = 0, val carbs: Int = 0,
val protein: Int = 0, val protein: Int = 0,
val fat: Int = 0, val fat: Int = 0,
val type: String = "Collation" val type: String = "Collation",
val id: Int = 0 // On garde le champ id pour la compatibilité mais il n'est plus PrimaryKey
) )
@Entity(tableName = "glycemia") @Entity(tableName = "glycemia")
data class Glycemia( data class Glycemia(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey val date: Long = 0, // La date devient la clé unique
val date: Long, val value: Double = 0.0,
val value: Double, val moment: String = "",
val moment: String val id: Int = 0
) )
@Entity(tableName = "sports") @Entity(tableName = "sports")
data class SportActivity( data class SportActivity(
@PrimaryKey val id: Long, @PrimaryKey val id: Long = 0, // Strava fournit déjà un ID unique
val name: String, val name: String = "",
val type: String, val type: String = "",
val distance: Float, val distance: Float = 0f,
val movingTime: Int, val movingTime: Int = 0,
val calories: Float?, val calories: Float? = null,
val date: Long // timestamp val date: Long = 0
) )
@Entity(tableName = "favorite_meals") @Entity(tableName = "favorite_meals")
data class FavoriteMeal( data class FavoriteMeal(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String, val name: String = "",
val analysisText: String, val analysisText: String = "",
val calories: Int, val calories: Int = 0,
val carbs: Int, val carbs: Int = 0,
val protein: Int, val protein: Int = 0,
val fat: Int val fat: Int = 0
) )
@Dao @Dao
interface AppDao { interface AppDao {
// On ajoute OnConflictStrategy.REPLACE pour la synchronisation
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMeal(meal: Meal): Long suspend fun insertMeal(meal: Meal): Long
@@ -61,7 +60,6 @@ interface AppDao {
@Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC") @Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC")
suspend fun getMealsInRangeSync(start: Long, end: Long): List<Meal> suspend fun getMealsInRangeSync(start: Long, end: Long): List<Meal>
// On ajoute OnConflictStrategy.REPLACE pour la synchronisation
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGlycemia(glycemia: Glycemia): Long suspend fun insertGlycemia(glycemia: Glycemia): Long
@@ -87,23 +85,16 @@ interface AppDao {
fun getAllDatesWithData(): Flow<List<Long>> fun getAllDatesWithData(): Flow<List<Long>>
} }
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 7) @Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 8) // Version incrémentée
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun appDao(): AppDao abstract fun appDao(): AppDao
companion object { companion object {
@Volatile private var INSTANCE: AppDatabase? = null @Volatile private var INSTANCE: AppDatabase? = null
private val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `favorite_meals` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `analysisText` TEXT NOT NULL, `calories` INTEGER NOT NULL, `carbs` INTEGER NOT NULL, `protein` INTEGER NOT NULL, `fat` INTEGER NOT NULL)")
}
}
fun getDatabase(context: Context): AppDatabase = fun getDatabase(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db") Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
.addMigrations(MIGRATION_6_7) .fallbackToDestructiveMigration() // Ceci va vider la base locale une seule fois pour appliquer le changement
.fallbackToDestructiveMigration(dropAllTables = false)
.build().also { INSTANCE = it } .build().also { INSTANCE = it }
} }
} }

View File

@@ -1,6 +1,8 @@
package com.example.scanwich package com.example.scanwich
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.core.content.edit
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
@@ -48,33 +50,60 @@ object FirebaseUtils {
} }
} }
// NOUVELLE FONCTION : Rapatrie toutes les données depuis le Cloud suspend fun fetchAllDataFromFirestore(dao: AppDao, prefs: SharedPreferences) {
suspend fun fetchAllDataFromFirestore(dao: AppDao) {
val user = FirebaseAuth.getInstance().currentUser ?: return val user = FirebaseAuth.getInstance().currentUser ?: return
val db = getDb() val db = getDb()
val userDoc = db.collection("users").document(user.uid) val userDoc = db.collection("users").document(user.uid)
try { try {
// 1. Récupérer les repas Log.d("FirestoreSync", "Début de la récupération pour l'UID: ${user.uid}")
// 1. Profil
val profileSnapshot = userDoc.get().await()
if (profileSnapshot.exists()) {
prefs.edit {
profileSnapshot.get("target_calories")?.let { putString("target_calories", it.toString()) }
profileSnapshot.get("target_carbs")?.let { putString("target_carbs", it.toString()) }
profileSnapshot.get("target_protein")?.let { putString("target_protein", it.toString()) }
profileSnapshot.get("target_fat")?.let { putString("target_fat", it.toString()) }
profileSnapshot.get("weight_kg")?.let { putString("weight_kg", it.toString()) }
profileSnapshot.getBoolean("is_lbs")?.let { putBoolean("is_lbs", it) }
profileSnapshot.get("height_cm")?.let { putString("height_cm", it.toString()) }
profileSnapshot.getBoolean("is_diabetic")?.let { putBoolean("is_diabetic", it) }
(profileSnapshot.get("age") as? Number)?.let { putInt("age", it.toInt()) }
profileSnapshot.getString("gender")?.let { putString("gender", it) }
profileSnapshot.getString("activity_level")?.let { putString("activity_level", it) }
profileSnapshot.getString("goal")?.let { putString("goal", it) }
}
Log.d("FirestoreSync", "Profil récupéré avec succès")
}
// 2. Repas
val mealsSnapshot = userDoc.collection("meals").get().await() val mealsSnapshot = userDoc.collection("meals").get().await()
if (!mealsSnapshot.isEmpty) {
val meals = mealsSnapshot.toObjects(Meal::class.java) val meals = mealsSnapshot.toObjects(Meal::class.java)
meals.forEach { dao.insertMeal(it) } meals.forEach { dao.insertMeal(it) }
Log.d("FirestoreSync", "${meals.size} repas récupérés") Log.d("FirestoreSync", "${meals.size} repas insérés dans la base locale")
}
// 2. Récupérer la glycémie // 3. Glycémie
val glycemiaSnapshot = userDoc.collection("glycemia").get().await() val glycemiaSnapshot = userDoc.collection("glycemia").get().await()
if (!glycemiaSnapshot.isEmpty) {
val glycemia = glycemiaSnapshot.toObjects(Glycemia::class.java) val glycemia = glycemiaSnapshot.toObjects(Glycemia::class.java)
glycemia.forEach { dao.insertGlycemia(it) } glycemia.forEach { dao.insertGlycemia(it) }
Log.d("FirestoreSync", "${glycemia.size} glycémies récupérées") Log.d("FirestoreSync", "${glycemia.size} relevés de glycémie insérés")
}
// 3. Récupérer le sport // 4. Sport
val sportsSnapshot = userDoc.collection("sports").get().await() val sportsSnapshot = userDoc.collection("sports").get().await()
if (!sportsSnapshot.isEmpty) {
val sports = sportsSnapshot.toObjects(SportActivity::class.java) val sports = sportsSnapshot.toObjects(SportActivity::class.java)
dao.insertSports(sports) dao.insertSports(sports)
Log.d("FirestoreSync", "${sports.size} activités sportives récupérées") Log.d("FirestoreSync", "${sports.size} activités sportives insérées")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("FirestoreSync", "Erreur lors du rapatriement des données: ${e.message}") Log.e("FirestoreSync", "ERREUR CRITIQUE lors du rapatriement: ${e.message}", e)
} }
} }
} }

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.scanwich.ui.theme.ReadableAmber import com.example.scanwich.ui.theme.ReadableAmber
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -329,13 +330,38 @@ fun HistoryScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
val totalIn = meals.sumOf { it.totalCalories } val totalIn = meals.sumOf { it.totalCalories }
val totalOut = sports.sumOf { it.calories?.toInt() ?: 0 } val totalOut = sports.sumOf { it.calories?.toInt() ?: 0 }
val netTotal = totalIn - totalOut
val totalCarbs = meals.sumOf { it.carbs } val totalCarbs = meals.sumOf { it.carbs }
val totalProt = meals.sumOf { it.protein } val totalProt = meals.sumOf { it.protein }
val totalFat = meals.sumOf { it.fat } val totalFat = meals.sumOf { it.fat }
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text("Objectifs Quotidiens", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Résumé Calorique", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Mangé", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
Text("$totalIn", style = MaterialTheme.typography.titleLarge, color = calorieColor, fontWeight = FontWeight.Bold)
}
Text("-", style = MaterialTheme.typography.headlineMedium, color = Color.Gray)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Dépensé", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
Text("$totalOut", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
}
Text("=", style = MaterialTheme.typography.headlineMedium, color = Color.Gray)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Total Net", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
Text("$netTotal", style = MaterialTheme.typography.titleLarge, color = if(netTotal <= tCal) calorieColor else Color.Red, fontWeight = FontWeight.ExtraBold)
}
}
Text("kcal", style = MaterialTheme.typography.labelSmall, modifier = Modifier.align(Alignment.End), color = Color.Gray)
Spacer(Modifier.height(12.dp))
HorizontalDivider()
Spacer(Modifier.height(12.dp))
Text("Objectifs Quotidiens", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
DailyGoalChart("Calories", totalIn, tCal, calorieColor) DailyGoalChart("Calories", totalIn, tCal, calorieColor)
@@ -343,13 +369,6 @@ fun HistoryScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
DailyGoalChart("Protéines", totalProt, tProt, proteinColor) DailyGoalChart("Protéines", totalProt, tProt, proteinColor)
DailyGoalChart("Lipides", totalFat, tFat, fatColor) DailyGoalChart("Lipides", totalFat, tFat, fatColor)
} }
Spacer(Modifier.height(12.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Sport (Brûlées):")
Text("$totalOut kcal", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
}
} }
} }

View File

@@ -1,12 +1,17 @@
package com.example.scanwich package com.example.scanwich
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.example.scanwich.ui.theme.ScanwichTheme import com.example.scanwich.ui.theme.ScanwichTheme
import com.google.firebase.Firebase import com.google.firebase.Firebase
import com.google.firebase.appcheck.appCheck import com.google.firebase.appcheck.appCheck
@@ -20,11 +25,26 @@ import kotlinx.coroutines.launch
import androidx.core.content.edit import androidx.core.content.edit
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
NotificationHelper.scheduleReminders(this)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
checkNotificationPermission()
NotificationHelper.scheduleReminders(this)
try { try {
Firebase.initialize(this) Firebase.initialize(this)
val appCheckFactory = if (BuildConfig.DEBUG) {
// On utilise explicitement le package name pour BuildConfig car l'import peut échouer
val appCheckFactory = if (com.example.scanwich.BuildConfig.DEBUG) {
DebugAppCheckProviderFactory.getInstance() DebugAppCheckProviderFactory.getInstance()
} else { } else {
PlayIntegrityAppCheckProviderFactory.getInstance() PlayIntegrityAppCheckProviderFactory.getInstance()
@@ -48,6 +68,16 @@ class MainActivity : ComponentActivity() {
} }
} }
private fun checkNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleStravaCallback(intent) handleStravaCallback(intent)

View File

@@ -0,0 +1,63 @@
package com.example.scanwich
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
class MealReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
NotificationHelper.scheduleReminders(context)
return
}
val mealType = intent.getStringExtra("meal_type") ?: "repas"
showNotification(context, mealType)
}
private fun showNotification(context: Context, mealType: String) {
val channelId = "meal_reminders"
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"Rappels de repas",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Notifications pour ne pas oublier d'entrer vos repas"
}
notificationManager.createNotificationChannel(channel)
}
val activityIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context, 0, activityIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info) // À remplacer par l'icône de l'app si dispo
.setContentTitle("N'oubliez pas votre $mealType !")
.setContentText("Prenez un moment pour enregistrer ce que vous avez mangé.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
val notificationId = when(mealType.lowercase()) {
"déjeuner" -> 1
"dîner" -> 2
"souper" -> 3
else -> 0
}
notificationManager.notify(notificationId, notification)
}
}

View File

@@ -20,6 +20,7 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import java.io.File
// --- OPEN FOOD FACTS API --- // --- OPEN FOOD FACTS API ---
data class OffProductResponse(val status: Int, val product: OffProduct?) data class OffProductResponse(val status: Int, val product: OffProduct?)
@@ -71,7 +72,6 @@ object ApiClient {
.create(StravaApi::class.java) .create(StravaApi::class.java)
val offApi: OffApi = Retrofit.Builder() val offApi: OffApi = Retrofit.Builder()
// On force l'utilisation de l'API française pour les codes-barres
.baseUrl("https://fr.openfoodfacts.org/api/v2/") .baseUrl("https://fr.openfoodfacts.org/api/v2/")
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
@@ -79,12 +79,31 @@ object ApiClient {
.create(OffApi::class.java) .create(OffApi::class.java)
private const val STRAVA_CLIENT_ID = "203805" private const val STRAVA_CLIENT_ID = "203805"
private const val PREFS_FILENAME = "secure_user_prefs"
fun getEncryptedPrefs(context: Context): SharedPreferences { fun getEncryptedPrefs(context: Context): SharedPreferences {
return try {
createPrefs(context)
} catch (e: Exception) {
Log.e("Security", "Erreur Keystore: ${e.message}. Réinitialisation...")
try {
// Version plus sûre pour effacer les prefs corrompues
val prefsFile = File(context.filesDir.parent, "shared_prefs/$PREFS_FILENAME.xml")
if (prefsFile.exists()) {
prefsFile.delete()
}
createPrefs(context)
} catch (e2: Exception) {
context.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE)
}
}
}
private fun createPrefs(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return EncryptedSharedPreferences.create( return EncryptedSharedPreferences.create(
context, context,
"secure_user_prefs", PREFS_FILENAME,
masterKey, masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM

View File

@@ -0,0 +1,48 @@
package com.example.scanwich
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import java.util.*
object NotificationHelper {
fun scheduleReminders(context: Context) {
scheduleMealAlarm(context, 8, 30, "Déjeuner", 101)
scheduleMealAlarm(context, 12, 30, "Dîner", 102)
scheduleMealAlarm(context, 19, 30, "Souper", 103)
}
private fun scheduleMealAlarm(context: Context, hour: Int, minute: Int, mealType: String, requestCode: Int) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, MealReminderReceiver::class.java).apply {
putExtra("meal_type", mealType)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val calendar = Calendar.getInstance().apply {
timeInMillis = System.currentTimeMillis()
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
// Si l'heure est déjà passée, on programme pour demain
if (before(Calendar.getInstance())) {
add(Calendar.DATE, 1)
}
}
alarmManager.setInexactRepeating(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
AlarmManager.INTERVAL_DAY,
pendingIntent
)
}
}

View File

@@ -72,7 +72,15 @@ fun analyzeImage(
bitmap?.let { getOptimizedImageBase64(it) } bitmap?.let { getOptimizedImageBase64(it) }
} }
val data = hashMapOf("imageBase64" to base64, "mealName" to textDescription) // Instruction pour que l'IA se concentre uniquement sur la nourriture (sans qualificatifs ni environnement)
val aiInstruction = "Focus seulement sur la nourriture, pas de qualificatif, pas son environnement, seulement la nourriture."
val mealDescriptionForAI = if (textDescription.isNullOrBlank()) {
aiInstruction
} else {
"$textDescription. $aiInstruction"
}
val data = hashMapOf("imageBase64" to base64, "mealName" to mealDescriptionForAI)
Firebase.functions("us-central1") Firebase.functions("us-central1")
.getHttpsCallable("analyzeMealProxy") .getHttpsCallable("analyzeMealProxy")

View File

@@ -1,50 +1,42 @@
📝 Notes de version - Scan-Wich 📝 Notes de version - Scan-Wich
**Changements majeurs de la version actuelle :** **Nouveautés de la version actuelle :**
🇫🇷 **Expérience 100% en Français :** ☁️ **Synchronisation Cloud Totale (Zéro Re-saisie) :**
- **IA Francophone :** Les analyses de repas (caméra et manuel) sont désormais exclusivement en français grâce à des instructions renforcées côté serveur. - **Mémoire Cloud :** Votre profil (poids, objectifs, calories cibles) est désormais entièrement sauvegardé dans le Cloud. Plus besoin de retaper vos informations lors d'une mise à jour ou d'une réinstallation !
- **Scanner Localisé :** Utilisation forcée de la base de données française d'Open Food Facts pour le scan de codes-barres, garantissant des noms de produits familiers. - **Rapatriement Automatique :** L'application récupère instantanément tout votre historique (repas, sport, glycémie) et vos paramètres dès la connexion.
- **Modèles de données optimisés :** Mise à jour des structures internes pour garantir une compatibilité parfaite avec Firebase lors de la récupération des données.
- **Résilience Keystore :** Ajout d'un système d'auto-réparation en cas de corruption des clés de sécurité locales, évitant ainsi les fermetures inattendues de l'application.
🤖 **Analyse IA plus Robuste :** 🍲 **Focus Nourriture Pur (IA) :**
- **Correctif d'analyse :** Résolution du bug "Erreur IA" lié au formatage des réponses du modèle. - **Analyse sélective :** L'intelligence artificielle se concentre désormais exclusivement sur la nourriture. Les qualificatifs, les descriptions de l'environnement ou les éléments de décor sont ignorés pour ne garder que l'essentiel nutritionnel.
- **Fiabilité accrue :** Nettoyage automatique des données nutritionnelles côté serveur (Cloud Functions).
🚀 **Connexion Strava 100% Automatique :** 🔔 **Rappels de Repas :**
- **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques. - **Notifications intelligentes :** Ne manquez plus un enregistrement ! Des rappels automatiques ont été ajoutés pour le déjeuner (08h30), le dîner (12h30) et le souper (19h30).
- **Sécurité Maximale :** Utilisation de Google Cloud Secret Manager pour la protection des clés API Strava.
🎨 **Améliorations UI/UX :**
- **Interface épurée :** Suppression des réglages superflus.
- **Correction du Thème :** Lisibilité parfaite sur l'écran de profil.
🛡️ **Architecture Cloud :**
- **Gestion des accès :** Contrôle dynamique des utilisateurs autorisés via Firestore.
- **Profils Cloud :** Synchronisation automatique de vos données personnelles.
--- ---
**Mises à jour précédentes :** **Changements majeurs précédents :**
🛠️ **Correctifs et Améliorations Strava :** 🇫🇷 **Expérience 100% en Français :**
- Résolution de bugs de compilation et amélioration du parsing des dates. - **IA Francophone :** Les analyses de repas (caméra et manuel) sont désormais exclusivement en français.
- Nouvel algorithme d'estimation des calories basé sur les MET. - **Scanner Localisé :** Utilisation de la base de données française d'Open Food Facts.
🛡️ **Sécurité renforcée :** 🤖 **Analyse IA plus Robuste :**
- Intégration de Firebase App Check (Play Integrity). - **Fiabilité accrue :** Nettoyage automatique des données nutritionnelles côté serveur.
- Migration des clés vers Secret Manager.
**Analyse Ultra-Rapide :** 🚀 **Connexion Strava 100% Automatique :**
- Nouveau moteur de compression d'image intelligent. - **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques.
📄 **Export PDF Professionnel :** 🛡️ **Architecture Cloud & Sécurité :**
- Exportation de l'historique complet (repas, sport, glycémie). - **Gestion des accès :** Contrôle dynamique des utilisateurs autorisés via Firestore.
- **Secret Manager :** Protection maximale des clés API via Google Cloud.
🩸 **Suivi Diabétique :** ---
- Visualisation de la glycémie directement dans l'historique.
**Gestion des Favoris :** 🛠️ **Historique technique :**
- Nouvelle interface d'ajout rapide. - Export PDF Professionnel (Repas, sport, glycémie).
- Suivi Diabétique complet.
🔧 **Stabilité :** - Intégration Firebase App Check (Play Integrity).
- Optimisation pour Android 36 et corrections diverses. - Algorithme d'estimation MET pour le sport.
- Optimisation pour Android 36.