From 75300292ec34c00486d179b1b1170d97a2208f21 Mon Sep 17 00:00:00 2001 From: mac Date: Mon, 23 Feb 2026 16:47:22 -0500 Subject: [PATCH 1/2] test --- app/proguard-rules.pro | 77 ++++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d6ae0ca..9a979df 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,26 +1,61 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html +# --- SCAN-WICH SECURITY RULES --- -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +# Optimisations agressives +-optimizationpasses 5 +-allowaccessmodification +-mergeinterfacesaggressively -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +# Supprimer les informations de débogage +-renamesourcefileattribute SourceFile +-keepattributes SourceFile,LineNumberTable -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile +# Offusquer plus profondément +-repackageclasses '' -# Obfuscation pour protéger les clés d'API --keep class com.example.scanwich.BuildConfig { *; } +# --- FIREBASE / GOOGLE --- +# Les bibliothèques Google fournissent leurs propres règles optimisées. +-dontwarn com.google.firebase.** +-dontwarn com.google.android.gms.** +-dontwarn com.google.errorprone.annotations.** + +# --- ITEXT --- +# On cible plus précisément les packages iText utilisés pour le rapport PDF -dontwarn com.itextpdf.** --keep class com.itextpdf.** { *; } +-keep class com.itextpdf.kernel.** { public protected *; } +-keep class com.itextpdf.layout.** { public protected *; } +-keep class com.itextpdf.io.** { public protected *; } + +# --- RETROFIT / OKHTTP --- +-keepattributes Signature, InnerClasses, EnclosingMethod +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations + +# Garder uniquement les annotations Retrofit +-keep @interface retrofit2.http.* +-dontwarn retrofit2.** +# Note: On laisse Retrofit gérer ses propres règles internes (incluses dans l'AAR) + +# --- ROOM --- +# On ne garde que les classes liées à la base de données +-keep class * extends androidx.room.RoomDatabase +-dontwarn androidx.room.paging.** + +# --- DATA MODELS --- +# Crucial : On garde tout ce qui est nécessaire au parsing JSON et à Room +-keepclassmembers class com.example.scanwich.** { + @com.google.gson.annotations.SerializedName ; + @androidx.room.PrimaryKey ; +} + +-keep @androidx.room.Entity class com.example.scanwich.** { *; } + +# On liste explicitement les modèles pour plus de précision +-keep class com.example.scanwich.Meal { *; } +-keep class com.example.scanwich.SportActivity { *; } +-keep class com.example.scanwich.Glycemia { *; } +-keep class com.example.scanwich.FavoriteMeal { *; } +-keep class com.example.scanwich.N8nMealRequest { *; } +-keep class com.example.scanwich.StravaActivity { *; } +-keep class com.example.scanwich.StravaTokenResponse { *; } + +# --- CONFIGURATION --- +-keep class com.example.scanwich.BuildConfig { *; } From e9c586adcda11639c6cdafc4da52e1b7a161e84f Mon Sep 17 00:00:00 2001 From: mac Date: Mon, 23 Feb 2026 21:33:21 -0500 Subject: [PATCH 2/2] test --- .idea/misc.xml | 1 + app/build.gradle.kts | 8 +- .../java/com/example/scanwich/Database.kt | 8 +- .../java/com/example/scanwich/MainActivity.kt | 511 ++++++++++++------ gradle/libs.versions.toml | 20 +- release-notes.txt | 41 +- 6 files changed, 402 insertions(+), 187 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index b2c751a..74dd639 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,3 +1,4 @@ + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f204654..0e1548d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,4 @@ import java.util.Properties -import java.util.Date plugins { alias(libs.plugins.android.application) @@ -133,6 +132,13 @@ dependencies { implementation(libs.firebase.auth) implementation(libs.firebase.appcheck.playintegrity) + // Barcode Scanning & Camera + implementation(libs.mlkit.barcode.scanning) + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + // PDF generation implementation(libs.itext7.core) diff --git a/app/src/main/java/com/example/scanwich/Database.kt b/app/src/main/java/com/example/scanwich/Database.kt index c10db09..22ef1db 100644 --- a/app/src/main/java/com/example/scanwich/Database.kt +++ b/app/src/main/java/com/example/scanwich/Database.kt @@ -53,7 +53,6 @@ data class FavoriteMeal( interface AppDao { @Insert suspend fun insertMeal(meal: Meal): Long @Delete suspend fun deleteMeal(meal: Meal) - @Query("SELECT * FROM meals ORDER BY date DESC") fun getAllMeals(): Flow> @Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow> @Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC") @@ -61,7 +60,6 @@ interface AppDao { @Insert suspend fun insertGlycemia(glycemia: Glycemia): Long @Delete suspend fun deleteGlycemia(glycemia: Glycemia) - @Query("SELECT * FROM glycemia ORDER BY date DESC") fun getAllGlycemia(): Flow> @Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow> @Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC") @@ -75,8 +73,10 @@ interface AppDao { suspend fun getSportsInRangeSync(start: Long, end: Long): List @Insert suspend fun insertFavorite(meal: FavoriteMeal) - @Delete suspend fun deleteFavorite(meal: FavoriteMeal) @Query("SELECT * FROM favorite_meals ORDER BY name ASC") fun getAllFavorites(): Flow> + + @Query("SELECT date FROM meals UNION SELECT date FROM sports UNION SELECT date FROM glycemia") + fun getAllDatesWithData(): Flow> } @Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 7) @@ -95,7 +95,7 @@ abstract class AppDatabase : RoomDatabase() { INSTANCE ?: synchronized(this) { Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db") .addMigrations(MIGRATION_6_7) - .fallbackToDestructiveMigration() + .fallbackToDestructiveMigration(dropAllTables = false) .build().also { INSTANCE = it } } } diff --git a/app/src/main/java/com/example/scanwich/MainActivity.kt b/app/src/main/java/com/example/scanwich/MainActivity.kt index 544832e..2b54017 100644 --- a/app/src/main/java/com/example/scanwich/MainActivity.kt +++ b/app/src/main/java/com/example/scanwich/MainActivity.kt @@ -18,17 +18,27 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.* +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.* @@ -41,11 +51,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.core.content.edit +import androidx.core.graphics.scale +import androidx.core.net.toUri import androidx.exifinterface.media.ExifInterface import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -63,22 +78,19 @@ import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.GoogleAuthProvider -import com.google.firebase.appdistribution.FirebaseAppDistribution import com.google.firebase.functions.functions import com.google.firebase.initialize import com.google.gson.annotations.SerializedName +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.common.InputImage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.ResponseBody -import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import java.text.SimpleDateFormat import java.util.* -import java.util.concurrent.TimeUnit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.* import java.io.ByteArrayOutputStream @@ -91,23 +103,30 @@ import com.itextpdf.kernel.pdf.PdfWriter import com.itextpdf.layout.Document import com.itextpdf.layout.element.Paragraph import com.itextpdf.layout.element.Table -import com.itextpdf.layout.element.Cell import com.itextpdf.layout.properties.TextAlignment import com.itextpdf.layout.properties.UnitValue -// --- API MODELS --- -data class N8nMealRequest( - val imageBase64: String?, - val mealName: String?, - val prompt: String +// --- OPEN FOOD FACTS API --- +data class OffProductResponse( + val status: Int, + val product: OffProduct? ) -interface N8nApi { - @POST("webhook/v1/gemini-proxy") - suspend fun analyzeMeal( - @Header("X-API-KEY") apiKey: String, - @Body request: N8nMealRequest - ): ResponseBody +data class OffProduct( + @SerializedName("product_name") val productName: String?, + val nutriments: OffNutriments? +) + +data class OffNutriments( + @SerializedName("energy-kcal_100g") val energyKcal: Float?, + @SerializedName("carbohydrates_100g") val carbs: Float?, + @SerializedName("proteins_100g") val proteins: Float?, + @SerializedName("fat_100g") val fat: Float? +) + +interface OffApi { + @GET("product/{barcode}.json") + suspend fun getProduct(@Path("barcode") barcode: String): OffProductResponse } // --- STRAVA API --- @@ -158,42 +177,18 @@ interface StravaApi { // Helpers object ApiClient { - private val loggingInterceptor = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - } - - private val okHttpClient = OkHttpClient.Builder() - .addInterceptor(loggingInterceptor) - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() - private val retrofitStrava = Retrofit.Builder() .baseUrl("https://www.strava.com/api/v3/") .addConverterFactory(GsonConverterFactory.create()) .build() - private val retrofitN8n = Retrofit.Builder() - .baseUrl("https://n8n.marquis1987.com/") - .client(okHttpClient) + private val retrofitOff = Retrofit.Builder() + .baseUrl("https://world.openfoodfacts.org/api/v2/") .addConverterFactory(GsonConverterFactory.create()) .build() val stravaApi: StravaApi = retrofitStrava.create(StravaApi::class.java) - val n8nApi: N8nApi = retrofitN8n.create(N8nApi::class.java) - - // Protection par simple obfuscation (Base64) pour éviter les scanners de texte simples dans l'APK - fun getN8nKey(): String { - val raw = BuildConfig.N8N_API_KEY - return try { - // On tente de décoder. Si ça échoue (pas du base64 valide), on renvoie tel quel. - val decoded = Base64.decode(raw, Base64.DEFAULT) - String(decoded) - } catch (e: Exception) { - raw - } - } + val offApi: OffApi = retrofitOff.create(OffApi::class.java) fun getEncryptedPrefs(context: Context): SharedPreferences { val masterKey = MasterKey.Builder(context) @@ -289,16 +284,6 @@ class MainActivity : ComponentActivity() { dao = AppDatabase.getDatabase(this).appDao() handleStravaCallback(intent) - - // Vérification automatique des mises à jour Firebase App Distribution - try { - FirebaseAppDistribution.getInstance().updateIfNewReleaseAvailable() - .addOnFailureListener { e -> - Log.e("AppDistribution", "Initial update check failed: ${e.message}") - } - } catch (e: Exception) { - Log.e("AppDistribution", "SDK not implemented in onCreate: ${e.message}") - } setContent { ScanwichTheme { @@ -376,7 +361,7 @@ fun AuthWrapper(dao: AppDao) { } catch (e: ApiException) { Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}") val msg = when(e.statusCode) { - 10 -> "Erreur 10 : SHA-1 non reconnu dans Firebase. Assurez-vous d\u0027avoir ajouté le SHA-1 de TOUTES vos clés de signature." + 10 -> "Erreur 10 : SHA-1 non reconnu dans Firebase. Assurez-vous d'avoir ajouté le SHA-1 de TOUTES vos clés de signature." 7 -> "Erreur 7 : Problème de réseau." 12500 -> "Erreur 12500 : Problème de configuration Google Play Services." else -> "Erreur Google (Code ${e.statusCode})." @@ -406,7 +391,6 @@ fun AuthWrapper(dao: AppDao) { @Composable fun LoginScreen(onLoginClick: () -> Unit) { - val context = LocalContext.current Column( modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -433,7 +417,7 @@ fun AccessDeniedScreen(onLogout: () -> Unit) { Spacer(Modifier.height(16.dp)) Text("Accès Refusé", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.error) Spacer(Modifier.height(8.dp)) - Text("Votre compte n\u0027est pas autorisé à utiliser cette application.", style = MaterialTheme.typography.bodyLarge) + Text("Votre compte n'est pas autorisé à utiliser cette application.", style = MaterialTheme.typography.bodyLarge) Spacer(Modifier.height(32.dp)) Button(onClick = onLogout) { Text("Changer de compte") } } @@ -467,7 +451,7 @@ fun MainApp(dao: AppDao, onLogout: () -> Unit) { } ) { innerPadding -> NavHost(navController, "capture", Modifier.padding(innerPadding)) { - composable("capture") { CaptureScreen(dao, prefs, isDiabetic) } + composable("capture") { CaptureScreen(dao) } composable("history") { HistoryScreen(dao, prefs) } composable("sport") { SportScreen(dao, prefs) } composable("glycemia") { GlycemiaScreen(dao) } @@ -571,7 +555,7 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) { Spacer(Modifier.height(16.dp)) - Text("Niveau d\u0027activité :", style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Start)) + Text("Niveau d'activité :", style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Start)) activityLevels.forEach { level -> Row( Modifier @@ -609,19 +593,13 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) { } val multiplier = activityMultipliers[activityLevel] ?: 1.2 - var targetCals = (bmr * multiplier).toInt() + val targetCals = (bmr * multiplier).toInt().let { if (goal == "Perdre du poids") it - 500 else it } - if (goal == "Perdre du poids") targetCals -= 500 - - val targetCarbs = (targetCals * 0.5 / 4).toInt() - val targetProtein = (targetCals * 0.2 / 4).toInt() - val targetFat = (targetCals * 0.3 / 9).toInt() - prefs.edit { putString("target_calories", targetCals.toString()) - putString("target_carbs", targetCarbs.toString()) - putString("target_protein", targetProtein.toString()) - putString("target_fat", targetFat.toString()) + putString("target_carbs", (targetCals * 0.5 / 4).toInt().toString()) + putString("target_protein", (targetCals * 0.2 / 4).toInt().toString()) + putString("target_fat", (targetCals * 0.3 / 9).toInt().toString()) putString("weight_kg", weightKg.toString()) putString("weight_display", weightDisplay) putBoolean("is_lbs", isLbs) @@ -644,7 +622,7 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { +fun CaptureScreen(dao: AppDao) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current @@ -654,21 +632,21 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { var mealDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var showBottomSheet by remember { mutableStateOf(false) } + var showBarcodeScanner by remember { mutableStateOf(false) } var manualMealName by remember { mutableStateOf("") } - val favoriteMeals by dao.getAllFavorites().collectAsState(initial = emptyList()) var showFavoritesSheet by remember { mutableStateOf(false) } val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap -> if (bitmap != null) { capturedBitmap = bitmap mealDateTime = System.currentTimeMillis() - analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error -> + analyzeImage(bitmap, null, { isAnalyzing = it }, { data: Triple>?, _ -> if (data != null) { currentMealData = data showBottomSheet = true } else { - Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show() + Toast.makeText(context, "Analyse échouée", Toast.LENGTH_LONG).show() } }, coroutineScope) } @@ -684,6 +662,16 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { } } + val barcodePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted: Boolean -> + if (isGranted) { + showBarcodeScanner = true + } else { + Toast.makeText(context, "Permission caméra requise pour le scan", Toast.LENGTH_SHORT).show() + } + } + val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> uri?.let { try { @@ -704,12 +692,12 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { exifStream.close() } - analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error -> + analyzeImage(bitmap, null, { isAnalyzing = it }, { data: Triple>?, _ -> if (data != null) { currentMealData = data showBottomSheet = true } else { - Toast.makeText(context, "L\u0027IA n\u0027a pas pu identifier le repas.", Toast.LENGTH_LONG).show() + Toast.makeText(context, "L'IA n'a pas pu identifier le repas.", Toast.LENGTH_LONG).show() } }, coroutineScope) } catch (e: Exception) { @@ -718,21 +706,59 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { } } + if (showBarcodeScanner) { + BarcodeScannerDialog( + onBarcodeScanned = { barcode -> + showBarcodeScanner = false + isAnalyzing = true + coroutineScope.launch { + try { + val response = ApiClient.offApi.getProduct(barcode) + if (response.status == 1 && response.product != null) { + val p = response.product + val nut = p.nutriments + currentMealData = Triple( + p.productName ?: "Produit inconnu", + "Scanné via OpenFoodFacts", + listOf( + nut?.energyKcal?.toInt() ?: 0, + nut?.carbs?.toInt() ?: 0, + nut?.proteins?.toInt() ?: 0, + nut?.fat?.toInt() ?: 0 + ) + ) + mealDateTime = System.currentTimeMillis() + showBottomSheet = true + } else { + Toast.makeText(context, "Produit non trouvé", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(context, "Erreur API: ${e.message}", Toast.LENGTH_SHORT).show() + } finally { + isAnalyzing = false + } + } + }, + onDismiss = { showBarcodeScanner = false } + ) + } + if (showFavoritesSheet) { ModalBottomSheet( onDismissRequest = { showFavoritesSheet = false }, containerColor = MaterialTheme.colorScheme.surface ) { + val favMeals by dao.getAllFavorites().collectAsState(initial = emptyList()) Column(modifier = Modifier.padding(16.dp).fillMaxHeight(0.6f)) { Text("Mes Favoris", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Spacer(Modifier.height(16.dp)) - if (favoriteMeals.isEmpty()) { + if (favMeals.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Aucun favori enregistré", color = Color.Gray) } } else { LazyColumn { - items(favoriteMeals) { fav -> + items(favMeals) { fav -> ListItem( headlineContent = { Text(fav.name) }, supportingContent = { Text("${fav.calories} kcal - G:${fav.carbs} P:${fav.protein} L:${fav.fat}") }, @@ -771,10 +797,11 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { var editableName by remember { mutableStateOf(currentMealData!!.first) } var editableDesc by remember { mutableStateOf(currentMealData!!.second) } - val editableCalories = currentMealData!!.third[0].toString() - val editableCarbs = currentMealData!!.third[1].toString() - val editableProtein = currentMealData!!.third[2].toString() - val editableFat = currentMealData!!.third[3].toString() + val mealValues = currentMealData!!.third + val editableCalories = mealValues[0].toString() + val editableCarbs = mealValues[1].toString() + val editableProtein = mealValues[2].toString() + val editableFat = mealValues[3].toString() // Update local state if currentMealData changes (e.g. after resubmission) LaunchedEffect(currentMealData) { @@ -807,7 +834,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { OutlinedTextField( value = editableDesc, onValueChange = { editableDesc = it }, - label = { Text("Description / Précisions pour l\u0027IA") }, + label = { Text("Description / Précisions pour l'IA") }, modifier = Modifier.fillMaxWidth(), minLines = 3 ) @@ -815,11 +842,11 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( onClick = { - analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, error -> + analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data: Triple>?, _ -> if (data != null) { currentMealData = data } else { - Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show() + Toast.makeText(context, "Analyse échouée", Toast.LENGTH_LONG).show() } }, coroutineScope) }, @@ -876,7 +903,8 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) { Icon(Icons.Default.DateRange, null) Spacer(Modifier.width(8.dp)) - Text("Date/Heure: " + SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(mealDateTime))) + val formattedDate = SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(mealDateTime)) + Text("Date/Heure: $formattedDate") } Spacer(Modifier.height(24.dp)) @@ -912,15 +940,24 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) { Text("Scan-Wich Analysis", style = MaterialTheme.typography.headlineMedium) Spacer(Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { cameraLauncher.launch(null) } else { permissionLauncher.launch(Manifest.permission.CAMERA) } - }) { Icon(Icons.Default.Add, null); Text(" Caméra") } - Button(onClick = { galleryLauncher.launch("image/*") }) { Icon(Icons.Default.Share, null); Text(" Galerie") } + }, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Add, null); Text(" Caméra") } + + Button(onClick = { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + showBarcodeScanner = true + } else { + barcodePermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, modifier = Modifier.weight(1f)) { Icon(Icons.Default.QrCodeScanner, null); Text(" Barcode") } + + Button(onClick = { galleryLauncher.launch("image/*") }, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Share, null); Text(" Galerie") } } capturedBitmap?.let { @@ -937,7 +974,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { Spacer(Modifier.height(32.dp)) Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { CircularProgressIndicator() - Text("Analyse par l\u0027IA en cours...", modifier = Modifier.padding(top = 8.dp)) + Text("Analyse en cours...", modifier = Modifier.padding(top = 8.dp)) } } @@ -949,7 +986,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { ) { Icon(Icons.Default.Favorite, null) Spacer(Modifier.width(8.dp)) - Text("Utiliser un Favori (${favoriteMeals.size})") + Text("Utiliser un Favori") } Spacer(Modifier.height(32.dp)) @@ -960,7 +997,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { OutlinedTextField( value = manualMealName, onValueChange = { manualMealName = it }, - label = { Text("Qu\u0027avez-vous mangé ?") }, + label = { Text("Qu'avez-vous mangé ?") }, placeholder = { Text("ex: Un sandwich au poulet et une pomme") }, modifier = Modifier.fillMaxWidth() ) @@ -968,12 +1005,12 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( onClick = { - analyzeImage(null, manualMealName, { isAnalyzing = it }, { data, error -> + analyzeImage(null, manualMealName, { isAnalyzing = it }, { data, _ -> if (data != null) { currentMealData = data showBottomSheet = true } else { - Toast.makeText(context, "Erreur AI: $error", Toast.LENGTH_LONG).show() + Toast.makeText(context, "Erreur AI", Toast.LENGTH_LONG).show() } }, coroutineScope) }, @@ -1019,8 +1056,9 @@ fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) { strokeWidth = 6.dp, strokeCap = StrokeCap.Round, ) + val percent = (progress * 100).toInt() Text( - text = "${(progress * 100).toInt()}%", + text = "$percent%", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold ) @@ -1035,6 +1073,21 @@ fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) { @Composable fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) { var selectedDate by remember { mutableStateOf(Calendar.getInstance()) } + val datesWithData by dao.getAllDatesWithData().collectAsState(initial = emptyList()) + var showMonthPicker by remember { mutableStateOf(false) } + + // Normalize datesWithData to a set of "days since epoch" for fast lookup + val normalizedDatesWithData = remember(datesWithData) { + datesWithData.map { timestamp -> + val cal = Calendar.getInstance().apply { timeInMillis = timestamp } + cal.set(Calendar.HOUR_OF_DAY, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + cal.timeInMillis + }.toSet() + } + val startOfDay = selectedDate.clone() as Calendar startOfDay.set(Calendar.HOUR_OF_DAY, 0); startOfDay.set(Calendar.MINUTE, 0); startOfDay.set(Calendar.SECOND, 0); startOfDay.set(Calendar.MILLISECOND, 0) val endOfDay = startOfDay.clone() as Calendar @@ -1095,37 +1148,124 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) { } } + if (showMonthPicker) { + AlertDialog( + onDismissRequest = { showMonthPicker = false }, + title = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + IconButton(onClick = { + val newDate = selectedDate.clone() as Calendar + newDate.add(Calendar.MONTH, -1) + selectedDate = newDate + }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null) } + val monthLabel = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(selectedDate.time) + Text(monthLabel) + IconButton(onClick = { + val newDate = selectedDate.clone() as Calendar + newDate.add(Calendar.MONTH, 1) + selectedDate = newDate + }) { Icon(Icons.AutoMirrored.Filled.ArrowForward, null) } + } + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + val daysOfWeek = listOf("L", "M", "M", "J", "V", "S", "D") + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + daysOfWeek.forEach { day -> + Text(day, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(32.dp), textAlign = TextAlign.Center) + } + } + + val cal = selectedDate.clone() as Calendar + cal.set(Calendar.DAY_OF_MONTH, 1) + val firstDayIdx = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7 + val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH) + + val gridItems = mutableListOf() + repeat(firstDayIdx) { gridItems.add(null) } + for (i in 1..daysInMonth) { gridItems.add(i) } + + LazyVerticalGrid( + columns = GridCells.Fixed(7), + modifier = Modifier.height(250.dp).padding(top = 8.dp) + ) { + items(gridItems) { day -> + if (day != null) { + val dayCal = selectedDate.clone() as Calendar + dayCal.set(Calendar.DAY_OF_MONTH, day) + dayCal.set(Calendar.HOUR_OF_DAY, 0); dayCal.set(Calendar.MINUTE, 0); dayCal.set(Calendar.SECOND, 0); dayCal.set(Calendar.MILLISECOND, 0) + val hasData = normalizedDatesWithData.contains(dayCal.timeInMillis) + val isSelected = day == selectedDate.get(Calendar.DAY_OF_MONTH) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent) + .clickable { + selectedDate = dayCal + showMonthPicker = false + } + .padding(4.dp) + ) { + Text( + text = day.toString(), + color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium + ) + if (hasData) { + Box(Modifier.size(4.dp).clip(CircleShape).background(if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary)) + } + } + } else { + Spacer(Modifier.size(40.dp)) + } + } + } + } + }, + confirmButton = { TextButton(onClick = { showMonthPicker = false }) { Text("Fermer") } } + ) + } + if (showExportDialog) { AlertDialog( onDismissRequest = { showExportDialog = false }, - title = { Text("Exporter l\u0027historique") }, + title = { Text("Exporter l'historique") }, text = { Column { Text("Sélectionnez la plage de dates :") Spacer(Modifier.height(16.dp)) Button(onClick = { DatePickerDialog(context, { _, y, m, d -> - exportStartDate.set(y, m, d) - exportStartDate = exportStartDate.clone() as Calendar + val newDate = Calendar.getInstance() + newDate.set(y, m, d) + exportStartDate = newDate }, exportStartDate.get(Calendar.YEAR), exportStartDate.get(Calendar.MONTH), exportStartDate.get(Calendar.DAY_OF_MONTH)).show() }, modifier = Modifier.fillMaxWidth()) { - Text("Du: " + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportStartDate.time)) + val dateLabel = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportStartDate.time) + Text("Du: $dateLabel") } Spacer(Modifier.height(8.dp)) Button(onClick = { DatePickerDialog(context, { _, y, m, d -> - exportEndDate.set(y, m, d) - exportEndDate = exportEndDate.clone() as Calendar + val newDate = Calendar.getInstance() + newDate.set(y, m, d) + exportEndDate = newDate }, exportEndDate.get(Calendar.YEAR), exportEndDate.get(Calendar.MONTH), exportEndDate.get(Calendar.DAY_OF_MONTH)).show() }, modifier = Modifier.fillMaxWidth()) { - Text("Au: " + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportEndDate.time)) + val dateLabel = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportEndDate.time) + Text("Au: $dateLabel") } } }, confirmButton = { TextButton(onClick = { showExportDialog = false - val fileName = "ScanWich_Rapport_${SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportStartDate.time)}_au_${SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportEndDate.time)}.pdf" + val startLabel = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportStartDate.time) + val endLabel = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportEndDate.time) + val fileName = "ScanWich_Rapport_${startLabel}_au_${endLabel}.pdf" createPdfLauncher.launch(fileName) }) { Text("Exporter") } }, @@ -1136,22 +1276,24 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) { } if (selectedMealForDetail != null) { + val meal = selectedMealForDetail!! AlertDialog( onDismissRequest = { selectedMealForDetail = null }, - title = { Text(selectedMealForDetail!!.name) }, + title = { Text(meal.name) }, text = { Column(modifier = Modifier .fillMaxWidth() .heightIn(max = 450.dp) .verticalScroll(rememberScrollState()) ) { - Text("Type: ${selectedMealForDetail!!.type}", fontWeight = FontWeight.Bold) - Text("Calories: ${selectedMealForDetail!!.totalCalories} kcal") - Text("Macro: G ${selectedMealForDetail!!.carbs}g | P ${selectedMealForDetail!!.protein}g | L ${selectedMealForDetail!!.fat}g") - Text("Heure: ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(selectedMealForDetail!!.date))}") + Text("Type: ${meal.type}", fontWeight = FontWeight.Bold) + Text("Calories: ${meal.totalCalories} kcal") + Text("Macro: G ${meal.carbs}g | P ${meal.protein}g | L ${meal.fat}g") + val timeLabel = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(meal.date)) + Text("Heure: $timeLabel") Spacer(Modifier.height(8.dp)) Text("Analyse complète :", style = MaterialTheme.typography.labelMedium) - Text(selectedMealForDetail!!.analysisText) + Text(meal.analysisText) } }, confirmButton = { @@ -1168,17 +1310,18 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) { selectedDate = newDate }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, null) } - Text( - text = SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()).format(selectedDate.time), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f).clickable { - DatePickerDialog(context, { _, y, m, d -> - val newDate = Calendar.getInstance() - newDate.set(y, m, d) - selectedDate = newDate - }, selectedDate.get(Calendar.YEAR), selectedDate.get(Calendar.MONTH), selectedDate.get(Calendar.DAY_OF_MONTH)).show() - } - ) + Column(modifier = Modifier.weight(1f).clickable { showMonthPicker = true }) { + Text( + text = SimpleDateFormat("EEEE d MMMM", Locale.getDefault()).format(selectedDate.time).replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = "Cliquer pour voir le calendrier", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } IconButton(onClick = { showExportDialog = true }) { Icon(Icons.Default.PictureAsPdf, contentDescription = "Export PDF", tint = MaterialTheme.colorScheme.primary) @@ -1315,7 +1458,7 @@ private fun generatePdfReport( val tf = SimpleDateFormat("HH:mm", Locale.getDefault()) // Header - document.add(Paragraph("Rapport d\u0027Historique Scan-Wich") + document.add(Paragraph("Rapport d'Historique Scan-Wich") .setTextAlignment(TextAlignment.CENTER) .setFontSize(20f) .setBold()) @@ -1339,7 +1482,8 @@ private fun generatePdfReport( table.addHeaderCell("Lip") meals.forEach { meal -> - table.addCell(df.format(Date(meal.date)) + " " + tf.format(Date(meal.date))) + val dateLabel = df.format(Date(meal.date)) + " " + tf.format(Date(meal.date)) + table.addCell(dateLabel) table.addCell(meal.name) table.addCell(meal.type) table.addCell(meal.totalCalories.toString()) @@ -1360,7 +1504,8 @@ private fun generatePdfReport( table.addHeaderCell("Valeur (mmol/L)") glycemia.forEach { gly -> - table.addCell(df.format(Date(gly.date)) + " " + tf.format(Date(gly.date))) + val dateLabel = df.format(Date(gly.date)) + " " + tf.format(Date(gly.date)) + table.addCell(dateLabel) table.addCell(gly.moment) table.addCell(gly.value.toString()) } @@ -1406,7 +1551,7 @@ private fun getOptimizedImageBase64(bitmap: Bitmap): String { } } - val resized = Bitmap.createScaledBitmap(bitmap, width, height, true) + val resized = bitmap.scale(width, height, true) val outputStream = ByteArrayOutputStream() resized.compress(Bitmap.CompressFormat.JPEG, 70, outputStream) return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) @@ -1548,10 +1693,12 @@ fun SportScreen(dao: AppDao, prefs: SharedPreferences) { Column(modifier = Modifier.padding(16.dp)) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { 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) + val dateLabel = SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)) + Text(dateLabel, style = MaterialTheme.typography.bodySmall) } Text("${activity.type} - ${(activity.distance / 1000).format(2)} km") - Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + val calLabel = "${activity.calories?.toInt() ?: 0} kcal brûlées" + Text(calLabel, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) } } } @@ -1565,21 +1712,11 @@ fun Float.format(digits: Int) = "%.${digits}f".format(this) fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) { var isEditing by remember { mutableStateOf(false) } val context = LocalContext.current - var updateErrorInfo by remember { mutableStateOf(null) } var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") } var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") } val isStravaConnected = prefs.contains("strava_token") - if (updateErrorInfo != null) { - AlertDialog( - onDismissRequest = { updateErrorInfo = null }, - title = { Text("Détail de l'erreur mise à jour") }, - text = { Text(updateErrorInfo!!) }, - confirmButton = { TextButton(onClick = { updateErrorInfo = null }) { Text("OK") } } - ) - } - if (isEditing) { SetupScreen(prefs) { isEditing = false @@ -1625,9 +1762,7 @@ fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpda 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, Uri.parse( - "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" - )) + 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) } }, @@ -1650,33 +1785,6 @@ fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpda } } - Spacer(Modifier.height(32.dp)) - Text("Mises à jour", style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(8.dp)) - Button( - onClick = { - try { - FirebaseAppDistribution.getInstance().updateIfNewReleaseAvailable() - .addOnSuccessListener { - Log.d("AppDistribution", "Check success") - Toast.makeText(context, "Vérification en cours...", Toast.LENGTH_SHORT).show() - } - .addOnFailureListener { e -> - Log.e("AppDistribution", "Error: ${e.message}") - updateErrorInfo = e.message ?: "Erreur inconnue lors de la vérification." - } - } catch (e: Exception) { - updateErrorInfo = "Le SDK Firebase App Distribution n'est pas disponible sur ce type de build (Local/USB)." - } - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary) - ) { - Icon(Icons.Default.Refresh, null) - Spacer(Modifier.width(8.dp)) - Text("Vérifier les mises à jour") - } - Spacer(Modifier.height(32.dp)) Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { Text("Déconnexion") } } @@ -1737,7 +1845,8 @@ fun GlycemiaScreen(dao: AppDao) { }, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true).show() }, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show() }, modifier = Modifier.fillMaxWidth()) { - Text("Date/Heure: " + SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(selectedDateTime))) + val dateLabel = SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(selectedDateTime)) + Text("Date/Heure: $dateLabel") } Spacer(Modifier.height(24.dp)) @@ -1759,3 +1868,73 @@ fun GlycemiaScreen(dao: AppDao) { } } } + +@OptIn(ExperimentalGetImage::class) +@Composable +fun BarcodeScannerDialog(onBarcodeScanned: (String) -> Unit, onDismiss: () -> Unit) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } }, + text = { + Box(modifier = Modifier.size(300.dp).clip(MaterialTheme.shapes.medium)) { + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx) + val executor = ContextCompat.getMainExecutor(ctx) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val barcodeScanner = BarcodeScanning.getClient() + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer(executor) { imageProxy -> + val mediaImage = imageProxy.image + if (mediaImage != null) { + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + barcodeScanner.process(image) + .addOnSuccessListener { barcodes -> + if (barcodes.isNotEmpty()) { + barcodes[0].rawValue?.let { barcode -> + onBarcodeScanned(barcode) + cameraProvider.unbindAll() + } + } + } + .addOnCompleteListener { imageProxy.close() } + } else { + imageProxy.close() + } + } + } + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis) + } catch (e: Exception) { + Log.e("BarcodeScanner", "Camera binding failed", e) + } + }, executor) + previewView + }, + modifier = Modifier.fillMaxSize() + ) + // Overlay to guide the user + Box( + modifier = Modifier + .fillMaxSize() + .border(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), MaterialTheme.shapes.medium) + ) + } + } + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32105f0..aecd7a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.4" -kotlin = "2.0.21" +kotlinComposePlugin = "2.3.10" composeBom = "2026.02.00" generativeai = "0.9.0" coil = "2.7.0" @@ -22,9 +22,11 @@ playServicesAuth = "21.5.1" googleServices = "4.4.4" firebaseBom = "34.9.0" firebaseAppDistribution = "5.2.1" -firebaseAppDistributionSdk = "16.0.0-beta15" +firebaseAppDistributionSdk = "16.0.0-beta17" securityCrypto = "1.1.0" -kotlinxCoroutinesPlayServices = "1.9.0" +mlkitBarcodeScanning = "17.3.0" +camerax = "1.5.3" +itext = "7.2.6" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -59,17 +61,21 @@ firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics firebase-appdistribution = { group = "com.google.firebase", name = "firebase-appdistribution", version.ref = "firebaseAppDistributionSdk" } firebase-functions = { group = "com.google.firebase", name = "firebase-functions" } firebase-auth = { group = "com.google.firebase", name = "firebase-auth" } -firebase-appcheck = { group = "com.google.firebase", name = "firebase-appcheck" } firebase-appcheck-playintegrity = { group = "com.google.firebase", name = "firebase-appcheck-playintegrity" } firebase-appcheck-debug = { group = "com.google.firebase", name = "firebase-appcheck-debug" } -kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" } +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version = "17.3.0" } +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } + # iText Core is needed for PDF generation -itext7-core = { group = "com.itextpdf", name = "itext7-core", version = "7.2.5" } +itext7-core = { group = "com.itextpdf", name = "itext7-core", version.ref = "itext" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinComposePlugin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } diff --git a/release-notes.txt b/release-notes.txt index ed45974..5d1abdf 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -1,9 +1,32 @@ -- Renforcement de la sécurité : Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les utilisations non autorisées et sécuriser les appels vers l'IA. -- Protection des données sensibles : Migration de la clé API vers Google Cloud Secret Manager, garantissant qu'aucune information confidentielle n'est stockée en dur dans le code. -- Optimisation majeure de l'analyse : Réduction drastique de la latence réseau grâce à un nouveau moteur de compression d'image (passage de 2.2 Mo à ~150 Ko par scan). -- Sécurisation de l'IA : Migration de la logique des instructions (prompts) côté serveur (Cloud Functions) pour prévenir les manipulations et garantir des résultats fiables. -- Amélioration de l'authentification : Liaison directe entre Google Sign-In et Firebase Auth pour une session utilisateur plus robuste et sécurisée. -- Correction du parsing : Nouveau système de traitement des réponses Cloud Functions pour une meilleure fiabilité de l'affichage des macros. -- Export PDF de l'historique : nouvelle fonctionnalité permettant d'exporter vos repas, suivis de glycémie et activités sportives sous forme de rapport PDF professionnel. -- Refonte de l'accès aux favoris : intégration d'un bouton dédié et d'une liste modale pour un écran d'accueil plus clair. -- Mise à jour système : Optimisation pour Android 36 et amélioration de la stabilité globale. \ No newline at end of file +📝 Notes de version - Scan-Wich + +Nouveautés et Améliorations : + +🛡️ Sécurité renforcée : +- Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les accès non autorisés. +- Migration de la clé API vers Google Cloud Secret Manager, supprimant toute information sensible du code source. + +⚡ Analyse Ultra-Rapide : +- Nouveau moteur de compression d'image intelligent (réduction de ~2.2 Mo à 150 Ko par scan), accélérant drastiquement l'analyse IA. + +🤖 IA Sécurisée : +- Migration de la logique d'analyse (prompts) vers des Cloud Functions pour garantir des résultats plus fiables et protégés. + +📄 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 : +- Possibilité d'enregistrer et de visualiser sa glycémie avant/après chaque catégorie de repas directement dans l'historique. + +🚴 Synchronisation Strava : +- Connexion directe à Strava pour importer vos activités et calculer précisément les calories brûlées. + +⭐ 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. +- Correction de bugs majeurs sur le stockage sécurisé des préférences (MasterKey) et la synchronisation Firebase (collectAsState). \ No newline at end of file