Compare commits

..

3 Commits

Author SHA1 Message Date
f8c702398f Merge pull request 'changes' (#5) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/5
2026-02-23 21:34:07 -05:00
mac
e9c586adcd test 2026-02-23 21:33:21 -05:00
mac
75300292ec test 2026-02-23 16:47:22 -05:00
7 changed files with 458 additions and 208 deletions

1
.idea/misc.xml generated
View File

@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View File

@@ -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)

View File

@@ -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 <fields>;
@androidx.room.PrimaryKey <fields>;
}
-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 { *; }

View File

@@ -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<List<Meal>>
@Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>>
@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<List<Glycemia>>
@Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>>
@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<SportActivity>
@Insert suspend fun insertFavorite(meal: FavoriteMeal)
@Delete suspend fun deleteFavorite(meal: FavoriteMeal)
@Query("SELECT * FROM favorite_meals ORDER BY name ASC") fun getAllFavorites(): Flow<List<FavoriteMeal>>
@Query("SELECT date FROM meals UNION SELECT date FROM sports UNION SELECT date FROM glycemia")
fun getAllDatesWithData(): Flow<List<Long>>
}
@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 }
}
}

View File

@@ -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<String, String, List<Int>>?, _ ->
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<String, String, List<Int>>?, _ ->
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<String, String, List<Int>>?, _ ->
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<Int?>()
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<String?>(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)
)
}
}
)
}

View File

@@ -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" }

View File

@@ -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.
📝 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).