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"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View File

@@ -1,5 +1,4 @@
import java.util.Properties import java.util.Properties
import java.util.Date
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
@@ -133,6 +132,13 @@ dependencies {
implementation(libs.firebase.auth) implementation(libs.firebase.auth)
implementation(libs.firebase.appcheck.playintegrity) 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 // PDF generation
implementation(libs.itext7.core) implementation(libs.itext7.core)

View File

@@ -1,26 +1,61 @@
# Add project specific ProGuard rules here. # --- SCAN-WICH SECURITY RULES ---
# 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
# If your project uses WebView with JS, uncomment the following # Optimisations agressives
# and specify the fully qualified class name to the JavaScript interface -optimizationpasses 5
# class: -allowaccessmodification
#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -mergeinterfacesaggressively
# public *;
#}
# Uncomment this to preserve the line number information for # Supprimer les informations de débogage
# debugging stack traces. -renamesourcefileattribute SourceFile
#-keepattributes SourceFile,LineNumberTable -keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to # Offusquer plus profondément
# hide the original source file name. -repackageclasses ''
#-renamesourcefileattribute SourceFile
# Obfuscation pour protéger les clés d'API # --- FIREBASE / GOOGLE ---
-keep class com.example.scanwich.BuildConfig { *; } # 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.** -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 { interface AppDao {
@Insert suspend fun insertMeal(meal: Meal): Long @Insert suspend fun insertMeal(meal: Meal): Long
@Delete suspend fun deleteMeal(meal: Meal) @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") @Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>> fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>>
@Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC") @Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC")
@@ -61,7 +60,6 @@ interface AppDao {
@Insert suspend fun insertGlycemia(glycemia: Glycemia): Long @Insert suspend fun insertGlycemia(glycemia: Glycemia): Long
@Delete suspend fun deleteGlycemia(glycemia: Glycemia) @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") @Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>> fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>>
@Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC") @Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC")
@@ -75,8 +73,10 @@ interface AppDao {
suspend fun getSportsInRangeSync(start: Long, end: Long): List<SportActivity> suspend fun getSportsInRangeSync(start: Long, end: Long): List<SportActivity>
@Insert suspend fun insertFavorite(meal: FavoriteMeal) @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 * 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) @Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 7)
@@ -95,7 +95,7 @@ abstract class AppDatabase : RoomDatabase() {
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db") Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
.addMigrations(MIGRATION_6_7) .addMigrations(MIGRATION_6_7)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration(dropAllTables = false)
.build().also { INSTANCE = it } .build().also { INSTANCE = it }
} }
} }

View File

@@ -18,17 +18,27 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.* 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.StrokeCap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.graphics.scale
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable 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.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.GoogleAuthProvider import com.google.firebase.auth.GoogleAuthProvider
import com.google.firebase.appdistribution.FirebaseAppDistribution
import com.google.firebase.functions.functions import com.google.firebase.functions.functions
import com.google.firebase.initialize import com.google.firebase.initialize
import com.google.gson.annotations.SerializedName 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.* import retrofit2.http.*
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@@ -91,23 +103,30 @@ import com.itextpdf.kernel.pdf.PdfWriter
import com.itextpdf.layout.Document import com.itextpdf.layout.Document
import com.itextpdf.layout.element.Paragraph import com.itextpdf.layout.element.Paragraph
import com.itextpdf.layout.element.Table import com.itextpdf.layout.element.Table
import com.itextpdf.layout.element.Cell
import com.itextpdf.layout.properties.TextAlignment import com.itextpdf.layout.properties.TextAlignment
import com.itextpdf.layout.properties.UnitValue import com.itextpdf.layout.properties.UnitValue
// --- API MODELS --- // --- OPEN FOOD FACTS API ---
data class N8nMealRequest( data class OffProductResponse(
val imageBase64: String?, val status: Int,
val mealName: String?, val product: OffProduct?
val prompt: String
) )
interface N8nApi { data class OffProduct(
@POST("webhook/v1/gemini-proxy") @SerializedName("product_name") val productName: String?,
suspend fun analyzeMeal( val nutriments: OffNutriments?
@Header("X-API-KEY") apiKey: String, )
@Body request: N8nMealRequest
): ResponseBody 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 --- // --- STRAVA API ---
@@ -158,42 +177,18 @@ interface StravaApi {
// Helpers // Helpers
object ApiClient { 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() private val retrofitStrava = Retrofit.Builder()
.baseUrl("https://www.strava.com/api/v3/") .baseUrl("https://www.strava.com/api/v3/")
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
private val retrofitN8n = Retrofit.Builder() private val retrofitOff = Retrofit.Builder()
.baseUrl("https://n8n.marquis1987.com/") .baseUrl("https://world.openfoodfacts.org/api/v2/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
val stravaApi: StravaApi = retrofitStrava.create(StravaApi::class.java) val stravaApi: StravaApi = retrofitStrava.create(StravaApi::class.java)
val n8nApi: N8nApi = retrofitN8n.create(N8nApi::class.java) val offApi: OffApi = retrofitOff.create(OffApi::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
}
}
fun getEncryptedPrefs(context: Context): SharedPreferences { fun getEncryptedPrefs(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)
@@ -290,16 +285,6 @@ class MainActivity : ComponentActivity() {
dao = AppDatabase.getDatabase(this).appDao() dao = AppDatabase.getDatabase(this).appDao()
handleStravaCallback(intent) 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 { setContent {
ScanwichTheme { ScanwichTheme {
AuthWrapper(dao) AuthWrapper(dao)
@@ -376,7 +361,7 @@ fun AuthWrapper(dao: AppDao) {
} catch (e: ApiException) { } catch (e: ApiException) {
Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}") Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}")
val msg = when(e.statusCode) { val msg = when(e.statusCode) {
10 -> "Erreur 10 : SHA-1 non reconnu 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." 7 -> "Erreur 7 : Problème de réseau."
12500 -> "Erreur 12500 : Problème de configuration Google Play Services." 12500 -> "Erreur 12500 : Problème de configuration Google Play Services."
else -> "Erreur Google (Code ${e.statusCode})." else -> "Erreur Google (Code ${e.statusCode})."
@@ -406,7 +391,6 @@ fun AuthWrapper(dao: AppDao) {
@Composable @Composable
fun LoginScreen(onLoginClick: () -> Unit) { fun LoginScreen(onLoginClick: () -> Unit) {
val context = LocalContext.current
Column( Column(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -433,7 +417,7 @@ fun AccessDeniedScreen(onLogout: () -> Unit) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("Accès Refusé", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.error) Text("Accès Refusé", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp)) 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)) Spacer(Modifier.height(32.dp))
Button(onClick = onLogout) { Text("Changer de compte") } Button(onClick = onLogout) { Text("Changer de compte") }
} }
@@ -467,7 +451,7 @@ fun MainApp(dao: AppDao, onLogout: () -> Unit) {
} }
) { innerPadding -> ) { innerPadding ->
NavHost(navController, "capture", Modifier.padding(innerPadding)) { NavHost(navController, "capture", Modifier.padding(innerPadding)) {
composable("capture") { CaptureScreen(dao, prefs, isDiabetic) } composable("capture") { CaptureScreen(dao) }
composable("history") { HistoryScreen(dao, prefs) } composable("history") { HistoryScreen(dao, prefs) }
composable("sport") { SportScreen(dao, prefs) } composable("sport") { SportScreen(dao, prefs) }
composable("glycemia") { GlycemiaScreen(dao) } composable("glycemia") { GlycemiaScreen(dao) }
@@ -571,7 +555,7 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
Spacer(Modifier.height(16.dp)) 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 -> activityLevels.forEach { level ->
Row( Row(
Modifier Modifier
@@ -609,19 +593,13 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
} }
val multiplier = activityMultipliers[activityLevel] ?: 1.2 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 { prefs.edit {
putString("target_calories", targetCals.toString()) putString("target_calories", targetCals.toString())
putString("target_carbs", targetCarbs.toString()) putString("target_carbs", (targetCals * 0.5 / 4).toInt().toString())
putString("target_protein", targetProtein.toString()) putString("target_protein", (targetCals * 0.2 / 4).toInt().toString())
putString("target_fat", targetFat.toString()) putString("target_fat", (targetCals * 0.3 / 9).toInt().toString())
putString("weight_kg", weightKg.toString()) putString("weight_kg", weightKg.toString())
putString("weight_display", weightDisplay) putString("weight_display", weightDisplay)
putBoolean("is_lbs", isLbs) putBoolean("is_lbs", isLbs)
@@ -644,7 +622,7 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) { fun CaptureScreen(dao: AppDao) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
@@ -654,21 +632,21 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
var mealDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) } var mealDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var showBarcodeScanner by remember { mutableStateOf(false) }
var manualMealName by remember { mutableStateOf("") } var manualMealName by remember { mutableStateOf("") }
val favoriteMeals by dao.getAllFavorites().collectAsState(initial = emptyList())
var showFavoritesSheet by remember { mutableStateOf(false) } var showFavoritesSheet by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
if (bitmap != null) { if (bitmap != null) {
capturedBitmap = bitmap capturedBitmap = bitmap
mealDateTime = System.currentTimeMillis() mealDateTime = System.currentTimeMillis()
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error -> analyzeImage(bitmap, null, { isAnalyzing = it }, { data: Triple<String, String, List<Int>>?, _ ->
if (data != null) { if (data != null) {
currentMealData = data currentMealData = data
showBottomSheet = true showBottomSheet = true
} else { } else {
Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show() Toast.makeText(context, "Analyse échouée", Toast.LENGTH_LONG).show()
} }
}, coroutineScope) }, 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 -> val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { uri?.let {
try { try {
@@ -704,12 +692,12 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
exifStream.close() exifStream.close()
} }
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error -> analyzeImage(bitmap, null, { isAnalyzing = it }, { data: Triple<String, String, List<Int>>?, _ ->
if (data != null) { if (data != null) {
currentMealData = data currentMealData = data
showBottomSheet = true showBottomSheet = true
} else { } 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) }, coroutineScope)
} catch (e: Exception) { } 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) { if (showFavoritesSheet) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { showFavoritesSheet = false }, onDismissRequest = { showFavoritesSheet = false },
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
) { ) {
val favMeals by dao.getAllFavorites().collectAsState(initial = emptyList())
Column(modifier = Modifier.padding(16.dp).fillMaxHeight(0.6f)) { Column(modifier = Modifier.padding(16.dp).fillMaxHeight(0.6f)) {
Text("Mes Favoris", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Text("Mes Favoris", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
if (favoriteMeals.isEmpty()) { if (favMeals.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Aucun favori enregistré", color = Color.Gray) Text("Aucun favori enregistré", color = Color.Gray)
} }
} else { } else {
LazyColumn { LazyColumn {
items(favoriteMeals) { fav -> items(favMeals) { fav ->
ListItem( ListItem(
headlineContent = { Text(fav.name) }, headlineContent = { Text(fav.name) },
supportingContent = { Text("${fav.calories} kcal - G:${fav.carbs} P:${fav.protein} L:${fav.fat}") }, 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 editableName by remember { mutableStateOf(currentMealData!!.first) }
var editableDesc by remember { mutableStateOf(currentMealData!!.second) } var editableDesc by remember { mutableStateOf(currentMealData!!.second) }
val editableCalories = currentMealData!!.third[0].toString() val mealValues = currentMealData!!.third
val editableCarbs = currentMealData!!.third[1].toString() val editableCalories = mealValues[0].toString()
val editableProtein = currentMealData!!.third[2].toString() val editableCarbs = mealValues[1].toString()
val editableFat = currentMealData!!.third[3].toString() val editableProtein = mealValues[2].toString()
val editableFat = mealValues[3].toString()
// Update local state if currentMealData changes (e.g. after resubmission) // Update local state if currentMealData changes (e.g. after resubmission)
LaunchedEffect(currentMealData) { LaunchedEffect(currentMealData) {
@@ -807,7 +834,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
OutlinedTextField( OutlinedTextField(
value = editableDesc, value = editableDesc,
onValueChange = { editableDesc = it }, onValueChange = { editableDesc = it },
label = { Text("Description / Précisions pour l\u0027IA") }, label = { Text("Description / Précisions pour l'IA") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
minLines = 3 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)) { Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button( Button(
onClick = { onClick = {
analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, error -> analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data: Triple<String, String, List<Int>>?, _ ->
if (data != null) { if (data != null) {
currentMealData = data currentMealData = data
} else { } else {
Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show() Toast.makeText(context, "Analyse échouée", Toast.LENGTH_LONG).show()
} }
}, coroutineScope) }, 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)) { }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) {
Icon(Icons.Default.DateRange, null) Icon(Icons.Default.DateRange, null)
Spacer(Modifier.width(8.dp)) 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)) 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())) { Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
Text("Scan-Wich Analysis", style = MaterialTheme.typography.headlineMedium) Text("Scan-Wich Analysis", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { Button(onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(null) cameraLauncher.launch(null)
} else { } else {
permissionLauncher.launch(Manifest.permission.CAMERA) permissionLauncher.launch(Manifest.permission.CAMERA)
} }
}) { Icon(Icons.Default.Add, null); Text(" Caméra") } }, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Add, null); Text(" Caméra") }
Button(onClick = { galleryLauncher.launch("image/*") }) { Icon(Icons.Default.Share, null); Text(" Galerie") }
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 { capturedBitmap?.let {
@@ -937,7 +974,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
CircularProgressIndicator() 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) Icon(Icons.Default.Favorite, null)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("Utiliser un Favori (${favoriteMeals.size})") Text("Utiliser un Favori")
} }
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
@@ -960,7 +997,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
OutlinedTextField( OutlinedTextField(
value = manualMealName, value = manualMealName,
onValueChange = { manualMealName = it }, 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") }, placeholder = { Text("ex: Un sandwich au poulet et une pomme") },
modifier = Modifier.fillMaxWidth() 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)) { Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button( Button(
onClick = { onClick = {
analyzeImage(null, manualMealName, { isAnalyzing = it }, { data, error -> analyzeImage(null, manualMealName, { isAnalyzing = it }, { data, _ ->
if (data != null) { if (data != null) {
currentMealData = data currentMealData = data
showBottomSheet = true showBottomSheet = true
} else { } else {
Toast.makeText(context, "Erreur AI: $error", Toast.LENGTH_LONG).show() Toast.makeText(context, "Erreur AI", Toast.LENGTH_LONG).show()
} }
}, coroutineScope) }, coroutineScope)
}, },
@@ -1019,8 +1056,9 @@ fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) {
strokeWidth = 6.dp, strokeWidth = 6.dp,
strokeCap = StrokeCap.Round, strokeCap = StrokeCap.Round,
) )
val percent = (progress * 100).toInt()
Text( Text(
text = "${(progress * 100).toInt()}%", text = "$percent%",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -1035,6 +1073,21 @@ fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) {
@Composable @Composable
fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) { fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
var selectedDate by remember { mutableStateOf(Calendar.getInstance()) } 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 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) 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 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) { if (showExportDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showExportDialog = false }, onDismissRequest = { showExportDialog = false },
title = { Text("Exporter l\u0027historique") }, title = { Text("Exporter l'historique") },
text = { text = {
Column { Column {
Text("Sélectionnez la plage de dates :") Text("Sélectionnez la plage de dates :")
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Button(onClick = { Button(onClick = {
DatePickerDialog(context, { _, y, m, d -> DatePickerDialog(context, { _, y, m, d ->
exportStartDate.set(y, m, d) val newDate = Calendar.getInstance()
exportStartDate = exportStartDate.clone() as Calendar newDate.set(y, m, d)
exportStartDate = newDate
}, exportStartDate.get(Calendar.YEAR), exportStartDate.get(Calendar.MONTH), exportStartDate.get(Calendar.DAY_OF_MONTH)).show() }, exportStartDate.get(Calendar.YEAR), exportStartDate.get(Calendar.MONTH), exportStartDate.get(Calendar.DAY_OF_MONTH)).show()
}, modifier = Modifier.fillMaxWidth()) { }, 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)) Spacer(Modifier.height(8.dp))
Button(onClick = { Button(onClick = {
DatePickerDialog(context, { _, y, m, d -> DatePickerDialog(context, { _, y, m, d ->
exportEndDate.set(y, m, d) val newDate = Calendar.getInstance()
exportEndDate = exportEndDate.clone() as Calendar newDate.set(y, m, d)
exportEndDate = newDate
}, exportEndDate.get(Calendar.YEAR), exportEndDate.get(Calendar.MONTH), exportEndDate.get(Calendar.DAY_OF_MONTH)).show() }, exportEndDate.get(Calendar.YEAR), exportEndDate.get(Calendar.MONTH), exportEndDate.get(Calendar.DAY_OF_MONTH)).show()
}, modifier = Modifier.fillMaxWidth()) { }, 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 = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
showExportDialog = false 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) createPdfLauncher.launch(fileName)
}) { Text("Exporter") } }) { Text("Exporter") }
}, },
@@ -1136,22 +1276,24 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
} }
if (selectedMealForDetail != null) { if (selectedMealForDetail != null) {
val meal = selectedMealForDetail!!
AlertDialog( AlertDialog(
onDismissRequest = { selectedMealForDetail = null }, onDismissRequest = { selectedMealForDetail = null },
title = { Text(selectedMealForDetail!!.name) }, title = { Text(meal.name) },
text = { text = {
Column(modifier = Modifier Column(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = 450.dp) .heightIn(max = 450.dp)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Text("Type: ${selectedMealForDetail!!.type}", fontWeight = FontWeight.Bold) Text("Type: ${meal.type}", fontWeight = FontWeight.Bold)
Text("Calories: ${selectedMealForDetail!!.totalCalories} kcal") Text("Calories: ${meal.totalCalories} kcal")
Text("Macro: G ${selectedMealForDetail!!.carbs}g | P ${selectedMealForDetail!!.protein}g | L ${selectedMealForDetail!!.fat}g") Text("Macro: G ${meal.carbs}g | P ${meal.protein}g | L ${meal.fat}g")
Text("Heure: ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(selectedMealForDetail!!.date))}") val timeLabel = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(meal.date))
Text("Heure: $timeLabel")
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text("Analyse complète :", style = MaterialTheme.typography.labelMedium) Text("Analyse complète :", style = MaterialTheme.typography.labelMedium)
Text(selectedMealForDetail!!.analysisText) Text(meal.analysisText)
} }
}, },
confirmButton = { confirmButton = {
@@ -1168,17 +1310,18 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
selectedDate = newDate selectedDate = newDate
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, null) } }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, null) }
Text( Column(modifier = Modifier.weight(1f).clickable { showMonthPicker = true }) {
text = SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()).format(selectedDate.time), Text(
style = MaterialTheme.typography.titleLarge, text = SimpleDateFormat("EEEE d MMMM", Locale.getDefault()).format(selectedDate.time).replaceFirstChar { it.uppercase() },
modifier = Modifier.weight(1f).clickable { style = MaterialTheme.typography.titleLarge,
DatePickerDialog(context, { _, y, m, d -> fontWeight = FontWeight.Bold
val newDate = Calendar.getInstance() )
newDate.set(y, m, d) Text(
selectedDate = newDate text = "Cliquer pour voir le calendrier",
}, selectedDate.get(Calendar.YEAR), selectedDate.get(Calendar.MONTH), selectedDate.get(Calendar.DAY_OF_MONTH)).show() style = MaterialTheme.typography.labelSmall,
} color = MaterialTheme.colorScheme.primary
) )
}
IconButton(onClick = { showExportDialog = true }) { IconButton(onClick = { showExportDialog = true }) {
Icon(Icons.Default.PictureAsPdf, contentDescription = "Export PDF", tint = MaterialTheme.colorScheme.primary) 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()) val tf = SimpleDateFormat("HH:mm", Locale.getDefault())
// Header // Header
document.add(Paragraph("Rapport d\u0027Historique Scan-Wich") document.add(Paragraph("Rapport d'Historique Scan-Wich")
.setTextAlignment(TextAlignment.CENTER) .setTextAlignment(TextAlignment.CENTER)
.setFontSize(20f) .setFontSize(20f)
.setBold()) .setBold())
@@ -1339,7 +1482,8 @@ private fun generatePdfReport(
table.addHeaderCell("Lip") table.addHeaderCell("Lip")
meals.forEach { meal -> 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.name)
table.addCell(meal.type) table.addCell(meal.type)
table.addCell(meal.totalCalories.toString()) table.addCell(meal.totalCalories.toString())
@@ -1360,7 +1504,8 @@ private fun generatePdfReport(
table.addHeaderCell("Valeur (mmol/L)") table.addHeaderCell("Valeur (mmol/L)")
glycemia.forEach { gly -> 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.moment)
table.addCell(gly.value.toString()) 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() val outputStream = ByteArrayOutputStream()
resized.compress(Bitmap.CompressFormat.JPEG, 70, outputStream) resized.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
@@ -1548,10 +1693,12 @@ fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text(SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)), style = MaterialTheme.typography.bodySmall) 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.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) { fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) {
var isEditing by remember { mutableStateOf(false) } var isEditing by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
var updateErrorInfo by remember { mutableStateOf<String?>(null) }
var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") } var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") }
var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") } var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") }
val isStravaConnected = prefs.contains("strava_token") 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) { if (isEditing) {
SetupScreen(prefs) { SetupScreen(prefs) {
isEditing = false isEditing = false
@@ -1625,9 +1762,7 @@ fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpda
if (stravaClientId.isBlank() || stravaClientSecret.isBlank()) { if (stravaClientId.isBlank() || stravaClientSecret.isBlank()) {
Toast.makeText(context, "Entrez votre ID et Secret Client", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Entrez votre ID et Secret Client", Toast.LENGTH_SHORT).show()
} else { } else {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse( 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())
"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"
))
context.startActivity(intent) 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)) Spacer(Modifier.height(32.dp))
Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { Text("Déconnexion") } 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.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true).show()
}, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show() }, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show()
}, modifier = Modifier.fillMaxWidth()) { }, 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)) 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" espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.4" activityCompose = "1.12.4"
kotlin = "2.0.21" kotlinComposePlugin = "2.3.10"
composeBom = "2026.02.00" composeBom = "2026.02.00"
generativeai = "0.9.0" generativeai = "0.9.0"
coil = "2.7.0" coil = "2.7.0"
@@ -22,9 +22,11 @@ playServicesAuth = "21.5.1"
googleServices = "4.4.4" googleServices = "4.4.4"
firebaseBom = "34.9.0" firebaseBom = "34.9.0"
firebaseAppDistribution = "5.2.1" firebaseAppDistribution = "5.2.1"
firebaseAppDistributionSdk = "16.0.0-beta15" firebaseAppDistributionSdk = "16.0.0-beta17"
securityCrypto = "1.1.0" securityCrypto = "1.1.0"
kotlinxCoroutinesPlayServices = "1.9.0" mlkitBarcodeScanning = "17.3.0"
camerax = "1.5.3"
itext = "7.2.6"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-appdistribution = { group = "com.google.firebase", name = "firebase-appdistribution", version.ref = "firebaseAppDistributionSdk" }
firebase-functions = { group = "com.google.firebase", name = "firebase-functions" } firebase-functions = { group = "com.google.firebase", name = "firebase-functions" }
firebase-auth = { group = "com.google.firebase", name = "firebase-auth" } 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-playintegrity = { group = "com.google.firebase", name = "firebase-appcheck-playintegrity" }
firebase-appcheck-debug = { group = "com.google.firebase", name = "firebase-appcheck-debug" } 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 # 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" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" } secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } 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. 📝 Notes de version - Scan-Wich
- 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). Nouveautés et Améliorations :
- 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. 🛡️ Sécurité renforcée :
- Correction du parsing : Nouveau système de traitement des réponses Cloud Functions pour une meilleure fiabilité de l'affichage des macros. - Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les accès non autorisés.
- 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. - Migration de la clé API vers Google Cloud Secret Manager, supprimant toute information sensible du code source.
- 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. ⚡ 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).