Compare commits

...

5 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
c9d05be4b1 Merge pull request 'test' (#7) from changes into master
Reviewed-on: #7
2026-03-09 20:26:02 -04:00
mac
73a7f46509 test 2026-03-09 20:25:10 -04:00
18 changed files with 529 additions and 175 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

@@ -41,7 +41,8 @@ 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")
@@ -133,6 +134,10 @@ 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 // Barcode Scanning & Camera
implementation(libs.mlkit.barcode.scanning) implementation(libs.mlkit.barcode.scanning)
implementation(libs.androidx.camera.core) implementation(libs.androidx.camera.core)
@@ -153,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,26 +102,27 @@ 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 {
// On spécifie explicitement la base de données "scan-wich"
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'. Document trouvé.") 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 isAuthorized = true
} else { } else {
Log.w("Auth", "Accès REFUSÉ pour '$email'. Document NON trouvé.")
isAuthorized = false isAuthorized = false
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Auth", "Erreur critique Firestore. Vérifiez les règles de sécurité.", e) Log.e("Auth", "Erreur critique Firestore.", e)
isAuthorized = false isAuthorized = false
} }
} else if (firebaseUser != null) { } else if (firebaseUser != null) {
Log.w("Auth", "L'utilisateur est connecté mais son email est vide.")
isAuthorized = false isAuthorized = false
} }
} }
@@ -126,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 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,64 +8,70 @@ 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 {
@Insert suspend fun insertMeal(meal: Meal): Long @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMeal(meal: Meal): Long
@Delete suspend fun deleteMeal(meal: Meal) @Delete suspend fun deleteMeal(meal: Meal)
@Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") @Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>> fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>>
@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>
@Insert suspend fun insertGlycemia(glycemia: Glycemia): Long @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGlycemia(glycemia: Glycemia): Long
@Delete suspend fun deleteGlycemia(glycemia: Glycemia) @Delete suspend fun deleteGlycemia(glycemia: Glycemia)
@Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") @Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>> fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>>
@Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC") @Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC")
suspend fun getGlycemiaInRangeSync(start: Long, end: Long): List<Glycemia> suspend fun getGlycemiaInRangeSync(start: Long, end: Long): List<Glycemia>
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List<SportActivity>) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSports(sports: List<SportActivity>)
@Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow<List<SportActivity>> @Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow<List<SportActivity>>
@Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") @Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>> fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>>
@@ -79,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,17 +1,18 @@
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
import kotlinx.coroutines.tasks.await
object FirebaseUtils { object FirebaseUtils {
private fun getDb(): FirebaseFirestore { private fun getDb(): FirebaseFirestore {
return try { return try {
// Tente d'utiliser la base de données nommée "scan-wich"
FirebaseFirestore.getInstance("scan-wich") FirebaseFirestore.getInstance("scan-wich")
} catch (e: Exception) { } catch (e: Exception) {
// Repli sur la base par défaut si "scan-wich" n'est pas configurée comme base secondaire
FirebaseFirestore.getInstance() FirebaseFirestore.getInstance()
} }
} }
@@ -48,4 +49,61 @@ object FirebaseUtils {
.addOnFailureListener { e -> Log.e("Firestore", "Error sport: ${e.message}") } .addOnFailureListener { e -> Log.e("Firestore", "Error sport: ${e.message}") }
} }
} }
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 {
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 insérés dans la base locale")
}
// 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} relevés de glycémie insérés")
}
// 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 insérées")
}
} catch (e: Exception) {
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,17 +1,23 @@
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
import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory
import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory
import com.google.firebase.functions.functions
import com.google.firebase.initialize import com.google.firebase.initialize
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -19,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()
@@ -47,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)
@@ -58,21 +89,35 @@ class MainActivity : ComponentActivity() {
val code = data.getQueryParameter("code") val code = data.getQueryParameter("code")
if (code != null) { if (code != null) {
val prefs = ApiClient.getEncryptedPrefs(this) val prefs = ApiClient.getEncryptedPrefs(this)
val clientId = prefs.getString("strava_client_id", "") ?: "" val functions = Firebase.functions
val clientSecret = prefs.getString("strava_client_secret", "") ?: ""
if (clientId.isNotEmpty() && clientSecret.isNotEmpty()) { val requestData = hashMapOf(
CoroutineScope(Dispatchers.IO).launch { "code" to code
try { )
val response = ApiClient.stravaApi.exchangeToken(clientId, clientSecret, code)
functions.getHttpsCallable("exchangeStravaToken")
.call(requestData)
.addOnSuccessListener { result ->
val res = result.data as? Map<*, *>
if (res != null) {
val accessToken = res["access_token"] as? String
val refreshToken = res["refresh_token"] as? String
val expiresAt = (res["expires_at"] as? Number)?.toLong() ?: 0L
if (accessToken != null && refreshToken != null) {
prefs.edit { prefs.edit {
putString("strava_token", response.accessToken) putString("strava_token", accessToken)
putString("strava_refresh_token", response.refreshToken) putString("strava_refresh_token", refreshToken)
putLong("strava_expires_at", response.expiresAt) putLong("strava_expires_at", expiresAt)
} }
} catch (e: Exception) { Log.e("StravaAuth", "Exchange failed: ${e.message}") } Log.d("StravaAuth", "Token exchange successful")
} }
} }
} }
.addOnFailureListener { e ->
Log.e("StravaAuth", "Cloud Function exchange failed: ${e.message}")
}
}
} }
} }
} }

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

@@ -1,10 +1,16 @@
package com.example.scanwich package com.example.scanwich
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.core.content.edit import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.google.firebase.Firebase
import com.google.firebase.functions.functions
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -13,6 +19,8 @@ import retrofit2.http.*
import java.text.SimpleDateFormat 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 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?)
@@ -64,17 +72,38 @@ object ApiClient {
.create(StravaApi::class.java) .create(StravaApi::class.java)
val offApi: OffApi = Retrofit.Builder() val offApi: OffApi = Retrofit.Builder()
.baseUrl("https://world.openfoodfacts.org/api/v2/") .baseUrl("https://fr.openfoodfacts.org/api/v2/")
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
.create(OffApi::class.java) .create(OffApi::class.java)
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
@@ -84,19 +113,38 @@ object ApiClient {
suspend fun getValidStravaToken(prefs: SharedPreferences): String? { suspend fun getValidStravaToken(prefs: SharedPreferences): String? {
val stravaToken = prefs.getString("strava_token", null) ?: return null val stravaToken = prefs.getString("strava_token", null) ?: return null
val expiresAt = prefs.getLong("strava_expires_at", 0) val expiresAt = prefs.getLong("strava_expires_at", 0)
if (System.currentTimeMillis() / 1000 >= expiresAt) {
if (System.currentTimeMillis() / 1000 >= (expiresAt - 300)) {
val refreshToken = prefs.getString("strava_refresh_token", null) ?: return null val refreshToken = prefs.getString("strava_refresh_token", null) ?: return null
val clientId = prefs.getString("strava_client_id", "") ?: ""
val clientSecret = prefs.getString("strava_client_secret", "") ?: ""
return try { return try {
val res = stravaApi.refreshToken(clientId, clientSecret, refreshToken) val functions = Firebase.functions
val data = hashMapOf("refreshToken" to refreshToken)
val result = functions.getHttpsCallable("refreshStravaToken")
.call(data)
.await()
val res = result.data as? Map<*, *>
if (res != null) {
val newAccessToken = res["access_token"] as? String
val newRefreshToken = res["refresh_token"] as? String
val newExpiresAt = (res["expires_at"] as? Number)?.toLong() ?: 0L
if (newAccessToken != null && newRefreshToken != null) {
prefs.edit { prefs.edit {
putString("strava_token", res.accessToken) putString("strava_token", newAccessToken)
putString("strava_refresh_token", res.refreshToken) putString("strava_refresh_token", newRefreshToken)
putLong("strava_expires_at", res.expiresAt) putLong("strava_expires_at", newExpiresAt)
}
return newAccessToken
}
}
null
} catch (e: Exception) {
Log.e("StravaAuth", "Refresh failed: ${e.message}")
null
} }
res.accessToken
} catch (e: Exception) { null }
} }
return stravaToken return stravaToken
} }
@@ -125,4 +173,18 @@ object ApiClient {
val durationHours = activity.movingTime / 3600.0 val durationHours = activity.movingTime / 3600.0
return met * weightKg * durationHours return met * weightKg * durationHours
} }
fun launchStravaAuth(context: Context) {
val intentUri = Uri.parse("https://www.strava.com/oauth/mobile/authorize")
.buildUpon()
.appendQueryParameter("client_id", STRAVA_CLIENT_ID)
.appendQueryParameter("redirect_uri", "coloricam://localhost")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("approval_prompt", "auto")
.appendQueryParameter("scope", "activity:read_all")
.build()
val intent = Intent(Intent.ACTION_VIEW, intentUri)
context.startActivity(intent)
}
} }

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

@@ -1,8 +1,6 @@
package com.example.scanwich package com.example.scanwich
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -16,16 +14,27 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri
@Composable @Composable
fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) { fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) {
var isEditing by remember { mutableStateOf(false) } var isEditing by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") } // État réactif pour la connexion Strava
var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") } var isStravaConnected by remember { mutableStateOf(prefs.contains("strava_token")) }
val isStravaConnected = prefs.contains("strava_token")
// Écouteur pour mettre à jour l'état si le token change
DisposableEffect(prefs) {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { p, key ->
if (key == "strava_token") {
isStravaConnected = p.contains("strava_token")
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
onDispose {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
}
if (isEditing) { if (isEditing) {
SetupScreen(prefs) { SetupScreen(prefs) {
@@ -48,51 +57,25 @@ fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpda
Button(onClick = { isEditing = true }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Edit, null); Text(" Modifier le profil") } Button(onClick = { isEditing = true }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Edit, null); Text(" Modifier le profil") }
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Text("Configuration Strava", style = MaterialTheme.typography.titleMedium) Text("Intégrations", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedTextField( if (isStravaConnected) {
value = stravaClientId,
onValueChange = { stravaClientId = it; prefs.edit { putString("strava_client_id", it) } },
label = { Text("Strava Client ID") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = stravaClientSecret,
onValueChange = { stravaClientSecret = it; prefs.edit { putString("strava_client_secret", it) } },
label = { Text("Strava Client Secret") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
if (!isStravaConnected) {
Button(
onClick = {
if (stravaClientId.isBlank() || stravaClientSecret.isBlank()) {
Toast.makeText(context, "Entrez votre ID et Secret Client", Toast.LENGTH_SHORT).show()
} else {
val intent = Intent(Intent.ACTION_VIEW, "https://www.strava.com/oauth/mobile/authorize?client_id=$stravaClientId&redirect_uri=coloricam://localhost&response_type=code&approval_prompt=auto&scope=read,activity:read_all".toUri())
context.startActivity(intent)
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Connecter Strava")
}
} else {
OutlinedButton( OutlinedButton(
onClick = { onClick = {
prefs.edit { prefs.edit {
remove("strava_token") remove("strava_token")
remove("strava_refresh_token") remove("strava_refresh_token")
} }
// L'écouteur mettra `isStravaConnected` à jour automatiquement
Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show()
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Déconnecter Strava (Connecté)") Text("Déconnecter Strava (Connecté)")
} }
} else {
Text("Aucune intégration active. Allez dans l'onglet 'Sport' pour connecter Strava.", style = MaterialTheme.typography.bodyMedium)
} }
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))

View File

@@ -1,6 +1,7 @@
package com.example.scanwich package com.example.scanwich
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -8,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -23,21 +25,51 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
@Composable @Composable
fun SportScreen(dao: AppDao, prefs: android.content.SharedPreferences) { fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
val sports by dao.getAllSports().collectAsState(initial = emptyList()) val sports by dao.getAllSports().collectAsState(initial = emptyList())
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
// État réactif pour la connexion Strava
var isConnectedToStrava by remember {
mutableStateOf(prefs.getString("strava_token", null) != null)
}
// Écouteur de changements pour les SharedPreferences
DisposableEffect(prefs) {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { p, key ->
if (key == "strava_token") {
isConnectedToStrava = p.getString("strava_token", null) != null
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
onDispose {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium) Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium)
if (!isConnectedToStrava) {
Button(
onClick = { ApiClient.launchStravaAuth(context) },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFC6100)) // Strava orange
) {
Icon(Icons.Default.Sync, null)
Spacer(Modifier.width(8.dp))
Text("Se connecter à Strava")
}
} else {
Button( Button(
onClick = { syncStravaActivities(dao, prefs, coroutineScope, context) }, onClick = { syncStravaActivities(dao, prefs, coroutineScope, context) },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) { ) {
Icon(Icons.Default.Refresh, null) Icon(Icons.Default.Refresh, null)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Synchroniser Strava") Text("Synchroniser les activités")
}
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -50,7 +82,8 @@ fun SportScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text(SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)), style = MaterialTheme.typography.bodySmall) Text(SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)), style = MaterialTheme.typography.bodySmall)
} }
Text("${activity.type} - ${(activity.distance / 1000).format(2)} km") val distanceInKm = activity.distance / 1000
Text("${activity.type} - ${String.format(Locale.getDefault(), "%.2f", distanceInKm)} km")
Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
} }
} }
@@ -59,11 +92,11 @@ fun SportScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
} }
} }
private fun syncStravaActivities(dao: AppDao, prefs: android.content.SharedPreferences, scope: CoroutineScope, context: Context) { private fun syncStravaActivities(dao: AppDao, prefs: SharedPreferences, scope: CoroutineScope, context: Context) {
scope.launch { scope.launch {
val token = ApiClient.getValidStravaToken(prefs) val token = ApiClient.getValidStravaToken(prefs)
if (token == null) { if (token == null) {
Toast.makeText(context, "Veuillez connecter Strava dans les paramètres", Toast.LENGTH_LONG).show() Toast.makeText(context, "Erreur de connexion Strava", Toast.LENGTH_LONG).show()
return@launch return@launch
} }
try { try {
@@ -83,7 +116,7 @@ private fun syncStravaActivities(dao: AppDao, prefs: android.content.SharedPrefe
).toFloat(), ).toFloat(),
date = ApiClient.parseStravaDate(it.startDate) date = ApiClient.parseStravaDate(it.startDate)
) )
syncSportToFirestore(activity) // Firestore Sync syncSportToFirestore(activity)
activity activity
} }
dao.insertSports(sportActivities) dao.insertSports(sportActivities)

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,49 +1,42 @@
📝 Notes de version - Scan-Wich 📝 Notes de version - Scan-Wich
**Changements majeurs de la version actuelle :** **Nouveautés de la version actuelle :**
🎨 **Améliorations de l'Interface Utilisateur :** ☁️ **Synchronisation Cloud Totale (Zéro Re-saisie) :**
- **Correction du Thème :** Résolution d'un problème de contraste sur l'écran de configuration du profil qui rendait le texte illisible (texte jaune sur fond blanc). L'écran respecte désormais correctement le thème de l'application. - **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.
🛡️ **Refonte de l'Architecture de Sécurité et de Données :** 🍲 **Focus Nourriture Pur (IA) :**
- **Gestion des accès centralisée :** L'ancien système d'utilisateurs autorisés codé en dur dans l'application a été supprimé. L'accès est désormais contrôlé de manière sécurisée et dynamique via une liste d'autorisation sur le serveur Firebase Firestore. Cela ouvre la voie à la gestion d'abonnements. - **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.
- **Profils Utilisateurs dans le Cloud :** Les données de profil (poids, objectifs, etc.) sont maintenant synchronisées avec le compte Firebase de l'utilisateur, permettant une expérience cohérente sur plusieurs appareils.
- **Correctif Critique de Connexion :** Résolution d'un bug majeur qui empêchait l'application de se connecter correctement à la base de données Firestore, causant des accès refusés inattendus pour les utilisateurs légitimes. L'application est désormais compatible avec les bases de données Firestore nommées. 🔔 **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 :** 🇫🇷 **Expérience 100% en Français :**
- Résolution d'un problème de compilation bloquant sur l'écran des sports. - **IA Francophone :** Les analyses de repas (caméra et manuel) sont désormais exclusivement en français.
- Intégration d'un nouvel algorithme d'estimation des calories basé sur les MET (Metabolic Equivalent of Task) pour une précision accrue. - **Scanner Localisé :** Utilisation de la base de données française d'Open Food Facts.
- Amélioration de la fiabilité du parsing des dates d'activités Strava.
🛡️ **Sécurité renforcée :** 🤖 **Analyse IA plus Robuste :**
- Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les accès non autorisés. - **Fiabilité accrue :** Nettoyage automatique des données nutritionnelles côté serveur.
- Migration de la clé API vers Google Cloud Secret Manager, supprimant toute information sensible du code source.
**Analyse Ultra-Rapide :** 🚀 **Connexion Strava 100% Automatique :**
- Nouveau moteur de compression d'image intelligent (réduction de ~2.2 Mo à 150 Ko par scan), accélérant drastiquement l'analyse IA. - **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques.
🤖 **IA Sécurisée :** 🛡️ **Architecture Cloud & Sécuri :**
- Migration de la logique d'analyse (prompts) vers des Cloud Functions pour garantir des résultats plus fiables et protégés. - **Gestion des accès :** Contrôle dynamique des utilisateurs autorisés via Firestore.
- **Secret Manager :** Protection maximale des clés API via Google Cloud.
📄 **Export PDF Professionnel :** ---
- Nouvelle fonctionnalité d'exportation de l'historique. Générez un rapport PDF complet incluant vos repas, activités sportives et suivis de glycémie.
🩸 **Suivi Diabétique complet :** 🛠️ **Historique technique :**
- Possibilité d'enregistrer et de visualiser sa glycémie avant/après chaque catégorie de repas directement dans l'historique. - Export PDF Professionnel (Repas, sport, glycémie).
- Suivi Diabétique complet.
🚴 **Synchronisation Strava :** - Intégration Firebase App Check (Play Integrity).
- Connexion directe à Strava pour importer vos activités et calculer précisément les calories brûlées. - Algorithme d'estimation MET pour le sport.
⭐ **Gestion des Favoris :**
- Refonte de l'interface des favoris avec une nouvelle fenêtre modale pour un ajout rapide de vos repas récurrents.
🔍 **Scanner de Code-barres :**
- Intégration d'Open Food Facts pour identifier instantanément les produits industriels via leur code-barres.
🔧 **Stabilité et Modernisation :**
- Optimisation pour Android 36. - Optimisation pour Android 36.
- Correction de bugs majeurs sur le stockage sécurisé des préférences (MasterKey) et la synchronisation Firebase (collectAsState).