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>
<SelectionState runConfigName="app">
<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">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\marca\.android\avd\Medium_Phone.avd" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=58051FDCG0038S" />
</handle>
</Target>
</DropdownSelection>

3
.idea/misc.xml generated
View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<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" />
</component>
<component name="ProjectType">

2
.idea/vcs.xml generated
View File

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

View File

@@ -18,6 +18,7 @@ android {
minSdk = 24
targetSdk = 36
// Incrémentation automatique du versionCode basé sur le temps
versionCode = (System.currentTimeMillis() / 60000).toInt()
versionName = "1.0." + (System.currentTimeMillis() / 3600000).toString().takeLast(3)
@@ -25,6 +26,7 @@ android {
}
signingConfigs {
// Chargement des propriétés depuis local.properties
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("local.properties")
if (keystorePropertiesFile.exists()) {
@@ -33,13 +35,14 @@ android {
getByName("debug") {
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
getByName("debug").storePassword = "android"
getByName("debug").keyAlias = "androiddebugkey"
getByName("debug").keyPassword = "android"
}
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")
keyAlias = keystoreProperties.getProperty("RELEASE_KEY_ALIAS") ?: "key0"
keyPassword = keystoreProperties.getProperty("RELEASE_KEY_PASSWORD")
@@ -51,7 +54,7 @@ android {
buildTypes {
release {
isMinifyEnabled = true
isMinifyEnabled = true // Activer l'offuscation
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -109,7 +112,9 @@ dependencies {
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
// SDK Firebase App Distribution COMPLET (API + Implémentation)
implementation(libs.firebase.appdistribution)
implementation(libs.google.generativeai)
implementation(libs.coil.compose)
implementation(libs.androidx.exifinterface)
@@ -129,13 +134,21 @@ dependencies {
implementation(libs.firebase.firestore)
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.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
// PDF generation
implementation(libs.itext7.core)
// Security
implementation(libs.androidx.security.crypto)
testImplementation(libs.junit)
@@ -145,5 +158,4 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.firebase.appcheck.debug)
}

View File

@@ -29,6 +29,14 @@
"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_type": 3

View File

@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<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
android:allowBackup="true"
@@ -14,6 +17,17 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
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
android:name=".MainActivity"
android:exported="true"

View File

@@ -60,6 +60,9 @@ fun AuthWrapper(dao: AppDao) {
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 task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try {
@@ -77,8 +80,8 @@ fun AuthWrapper(dao: AppDao) {
} catch (e: ApiException) {
Log.e("Auth", "Erreur Google Sign-In : ${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."
7 -> "Erreur 7 : Problème de réseau."
10 -> "Erreur 10 : SHA-1 non reconnu."
7 -> "Erreur 7 : Réseau."
else -> "Erreur Google (Code ${e.statusCode})."
}
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
@@ -90,6 +93,7 @@ fun AuthWrapper(dao: AppDao) {
googleSignInClient.signOut().addOnCompleteListener {
firebaseUser = null
isAuthorized = null
syncCompleted = false
}
}
@@ -98,23 +102,20 @@ fun AuthWrapper(dao: AppDao) {
val email = firebaseUser?.email?.trim()?.lowercase()
if (email != null && email.isNotEmpty()) {
Log.d("Auth", "Vérification de l'autorisation pour l'email: '$email'")
try {
val db = FirebaseFirestore.getInstance("scan-wich")
val docRef = db.collection("authorized_users").document(email)
val document = docRef.get().await()
if (document.exists()) {
Log.d("Auth", "Accès AUTORISÉ pour '$email'.")
// --- NOUVEAU : Rapatriement des données ---
coroutineScope.launch(Dispatchers.IO) {
FirebaseUtils.fetchAllDataFromFirestore(dao)
}
Log.d("Auth", "Accès autorisé. Lancement de la synchronisation...")
val prefs = ApiClient.getEncryptedPrefs(context)
// On attend que la synchro soit finie pour afficher l'app
FirebaseUtils.fetchAllDataFromFirestore(dao, prefs)
syncCompleted = true
isAuthorized = true
} else {
Log.w("Auth", "Accès REFUSÉ pour '$email'.")
isAuthorized = false
}
} catch (e: Exception) {
@@ -130,15 +131,26 @@ fun AuthWrapper(dao: AppDao) {
LoginScreen { launcher.launch(googleSignInClient.signInIntent) }
} else {
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)
null -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
Text("Vérification et synchronisation...")
}
}
null -> LoadingBox("Vérification de l'accès...")
}
}
}
@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")
data class Meal(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val date: Long,
@PrimaryKey val date: Long = 0, // La date devient la clé unique pour éviter les doublons
val name: String = "Repas",
val analysisText: String,
val totalCalories: Int,
val analysisText: String = "",
val totalCalories: Int = 0,
val carbs: Int = 0,
val protein: 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")
data class Glycemia(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val date: Long,
val value: Double,
val moment: String
@PrimaryKey val date: Long = 0, // La date devient la clé unique
val value: Double = 0.0,
val moment: String = "",
val id: Int = 0
)
@Entity(tableName = "sports")
data class SportActivity(
@PrimaryKey val id: Long,
val name: String,
val type: String,
val distance: Float,
val movingTime: Int,
val calories: Float?,
val date: Long // timestamp
@PrimaryKey val id: Long = 0, // Strava fournit déjà un ID unique
val name: String = "",
val type: String = "",
val distance: Float = 0f,
val movingTime: Int = 0,
val calories: Float? = null,
val date: Long = 0
)
@Entity(tableName = "favorite_meals")
data class FavoriteMeal(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String,
val analysisText: String,
val calories: Int,
val carbs: Int,
val protein: Int,
val fat: Int
val name: String = "",
val analysisText: String = "",
val calories: Int = 0,
val carbs: Int = 0,
val protein: Int = 0,
val fat: Int = 0
)
@Dao
interface AppDao {
// On ajoute OnConflictStrategy.REPLACE pour la synchronisation
@Insert(onConflict = OnConflictStrategy.REPLACE)
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")
suspend fun getMealsInRangeSync(start: Long, end: Long): List<Meal>
// On ajoute OnConflictStrategy.REPLACE pour la synchronisation
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGlycemia(glycemia: Glycemia): Long
@@ -87,23 +85,16 @@ interface AppDao {
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 fun appDao(): AppDao
companion object {
@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 =
INSTANCE ?: synchronized(this) {
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
.addMigrations(MIGRATION_6_7)
.fallbackToDestructiveMigration(dropAllTables = false)
.fallbackToDestructiveMigration() // Ceci va vider la base locale une seule fois pour appliquer le changement
.build().also { INSTANCE = it }
}
}

View File

@@ -1,6 +1,8 @@
package com.example.scanwich
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
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) {
suspend fun fetchAllDataFromFirestore(dao: AppDao, prefs: SharedPreferences) {
val user = FirebaseAuth.getInstance().currentUser ?: return
val db = getDb()
val userDoc = db.collection("users").document(user.uid)
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()
if (!mealsSnapshot.isEmpty) {
val meals = mealsSnapshot.toObjects(Meal::class.java)
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()
if (!glycemiaSnapshot.isEmpty) {
val glycemia = glycemiaSnapshot.toObjects(Glycemia::class.java)
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()
if (!sportsSnapshot.isEmpty) {
val sports = sportsSnapshot.toObjects(SportActivity::class.java)
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) {
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.scanwich.ui.theme.ReadableAmber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -329,13 +330,38 @@ fun HistoryScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
val totalIn = meals.sumOf { it.totalCalories }
val totalOut = sports.sumOf { it.calories?.toInt() ?: 0 }
val netTotal = totalIn - totalOut
val totalCarbs = meals.sumOf { it.carbs }
val totalProt = meals.sumOf { it.protein }
val totalFat = meals.sumOf { it.fat }
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
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))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
DailyGoalChart("Calories", totalIn, tCal, calorieColor)
@@ -343,13 +369,6 @@ fun HistoryScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
DailyGoalChart("Protéines", totalProt, tProt, proteinColor)
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
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.example.scanwich.ui.theme.ScanwichTheme
import com.google.firebase.Firebase
import com.google.firebase.appcheck.appCheck
@@ -20,11 +25,26 @@ import kotlinx.coroutines.launch
import androidx.core.content.edit
class MainActivity : ComponentActivity() {
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
NotificationHelper.scheduleReminders(this)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkNotificationPermission()
NotificationHelper.scheduleReminders(this)
try {
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()
} else {
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) {
super.onNewIntent(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.concurrent.TimeUnit
import kotlinx.coroutines.tasks.await
import java.io.File
// --- OPEN FOOD FACTS API ---
data class OffProductResponse(val status: Int, val product: OffProduct?)
@@ -71,7 +72,6 @@ object ApiClient {
.create(StravaApi::class.java)
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/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
@@ -79,12 +79,31 @@ object ApiClient {
.create(OffApi::class.java)
private const val STRAVA_CLIENT_ID = "203805"
private const val PREFS_FILENAME = "secure_user_prefs"
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()
return EncryptedSharedPreferences.create(
context,
"secure_user_prefs",
PREFS_FILENAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
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) }
}
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")
.getHttpsCallable("analyzeMealProxy")

View File

@@ -1,50 +1,42 @@
📝 Notes de version - Scan-Wich
**Changements majeurs de la version actuelle :**
**Nouveautés de la version actuelle :**
🇫🇷 **Expérience 100% en Français :**
- **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.
- **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.
☁️ **Synchronisation Cloud Totale (Zéro Re-saisie) :**
- **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 !
- **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 :**
- **Correctif d'analyse :** Résolution du bug "Erreur IA" lié au formatage des réponses du modèle.
- **Fiabilité accrue :** Nettoyage automatique des données nutritionnelles côté serveur (Cloud Functions).
🍲 **Focus Nourriture Pur (IA) :**
- **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.
🚀 **Connexion Strava 100% Automatique :**
- **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques.
- **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.
🔔 **Rappels de Repas :**
- **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).
---
**Mises à jour précédentes :**
**Changements majeurs précédents :**
🛠️ **Correctifs et Améliorations Strava :**
- Résolution de bugs de compilation et amélioration du parsing des dates.
- Nouvel algorithme d'estimation des calories basé sur les MET.
🇫🇷 **Expérience 100% en Français :**
- **IA Francophone :** Les analyses de repas (caméra et manuel) sont désormais exclusivement en français.
- **Scanner Localisé :** Utilisation de la base de données française d'Open Food Facts.
🛡️ **Sécurité renforcée :**
- Intégration de Firebase App Check (Play Integrity).
- Migration des clés vers Secret Manager.
🤖 **Analyse IA plus Robuste :**
- **Fiabilité accrue :** Nettoyage automatique des données nutritionnelles côté serveur.
**Analyse Ultra-Rapide :**
- Nouveau moteur de compression d'image intelligent.
🚀 **Connexion Strava 100% Automatique :**
- **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques.
📄 **Export PDF Professionnel :**
- Exportation de l'historique complet (repas, sport, glycémie).
🛡️ **Architecture Cloud & Sécurité :**
- **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 :**
- Nouvelle interface d'ajout rapide.
🔧 **Stabilité :**
- Optimisation pour Android 36 et corrections diverses.
🛠️ **Historique technique :**
- Export PDF Professionnel (Repas, sport, glycémie).
- Suivi Diabétique complet.
- Intégration Firebase App Check (Play Integrity).
- Algorithme d'estimation MET pour le sport.
- Optimisation pour Android 36.