Compare commits
5 Commits
7699ebcf2e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1379e7cc3 | ||
| 2bec3bc681 | |||
| 9b87930e9a | |||
| c9d05be4b1 | |||
|
|
73a7f46509 |
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -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
3
.idea/misc.xml
generated
@@ -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
2
.idea/vcs.xml
generated
@@ -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>
|
||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoadingBox(text: String) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text("Vérification de l'accès...")
|
Text(text)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,19 +89,33 @@ 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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
app/src/main/java/com/example/scanwich/NotificationHelper.kt
Normal file
48
app/src/main/java/com/example/scanwich/NotificationHelper.kt
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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écurité :**
|
||||||
- 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).
|
|
||||||
|
|||||||
Reference in New Issue
Block a user