This commit is contained in:
mac
2026-02-23 13:03:59 -05:00
parent df1188fc02
commit f76b684d49
8 changed files with 613 additions and 96 deletions

3
.idea/misc.xml generated
View File

@@ -1,7 +1,6 @@
<?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="temurin-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View File

@@ -1,4 +1,5 @@
import java.util.Properties
import java.util.Date
plugins {
alias(libs.plugins.android.application)
@@ -17,13 +18,16 @@ android {
applicationId = "com.example.scanwich"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
// Incrémentation automatique du versionCode basé sur le temps
versionCode = (System.currentTimeMillis() / 60000).toInt()
versionName = "1.0." + (System.currentTimeMillis() / 3600000).toString().takeLast(3)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
// Chargement des propriétés depuis local.properties
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("local.properties")
if (keystorePropertiesFile.exists()) {
@@ -45,11 +49,12 @@ android {
}
}
buildTypes {
val keyFile = project.file("firebase-key.json")
val releaseNotesFile = rootProject.file("release-notes.txt")
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true // Activer l'offuscation
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -61,18 +66,24 @@ android {
if (keyFile.exists()) {
serviceCredentialsFile = keyFile.absolutePath
}
if (releaseNotesFile.exists()) {
releaseNotes = releaseNotesFile.readText()
}
groups = "internal-user"
}
}
debug {
isMinifyEnabled = false
configure<com.google.firebase.appdistribution.gradle.AppDistributionExtension> {
artifactType = "APK"
if (keyFile.exists()) {
serviceCredentialsFile = keyFile.absolutePath
}
if (releaseNotesFile.exists()) {
releaseNotes = releaseNotesFile.readText()
}
groups = "internal-user"
releaseNotes = "Version de développement"
}
}
}
@@ -100,24 +111,33 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
// SDK Firebase App Distribution COMPLET (API + Implémentation)
implementation(libs.firebase.appdistribution)
implementation(libs.google.generativeai)
implementation(libs.coil.compose)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.retrofit.core)
implementation(libs.retrofit.gson)
implementation(libs.okhttp.logging)
implementation(libs.androidx.browser)
implementation(libs.play.services.auth)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.functions)
implementation(libs.firebase.auth)
implementation(libs.firebase.appcheck.playintegrity)
// PDF generation
implementation(libs.itext7.core)
// Security
implementation(libs.androidx.security.crypto)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
@@ -126,4 +146,5 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.firebase.appcheck.debug)
}

View File

@@ -19,3 +19,8 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Obfuscation pour protéger les clés d'API
-keep class com.example.scanwich.BuildConfig { *; }
-dontwarn com.itextpdf.**
-keep class com.itextpdf.** { *; }

View File

@@ -2,6 +2,8 @@ package com.example.scanwich
import android.content.Context
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "meals")
@@ -36,6 +38,17 @@ data class SportActivity(
val date: Long // timestamp
)
@Entity(tableName = "favorite_meals")
data class FavoriteMeal(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String,
val analysisText: String,
val calories: Int,
val carbs: Int,
val protein: Int,
val fat: Int
)
@Dao
interface AppDao {
@Insert suspend fun insertMeal(meal: Meal): Long
@@ -43,27 +56,45 @@ interface AppDao {
@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")
suspend fun getMealsInRangeSync(start: Long, end: Long): List<Meal>
@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")
suspend fun getGlycemiaInRangeSync(start: Long, end: Long): List<Glycemia>
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List<SportActivity>)
@Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow<List<SportActivity>>
@Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>>
@Query("SELECT * FROM sports WHERE date >= :start AND date <= :end ORDER BY date ASC")
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>>
}
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class], version = 6)
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 7)
abstract class AppDatabase : RoomDatabase() {
abstract fun appDao(): AppDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
private val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `favorite_meals` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `analysisText` TEXT NOT NULL, `calories` INTEGER NOT NULL, `carbs` INTEGER NOT NULL, `protein` INTEGER NOT NULL, `fat` INTEGER NOT NULL)")
}
}
fun getDatabase(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
.addMigrations(MIGRATION_6_7)
.fallbackToDestructiveMigration()
.build().also { INSTANCE = it }
}

View File

@@ -21,6 +21,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.items
@@ -49,13 +50,28 @@ import androidx.exifinterface.media.ExifInterface
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.example.scanwich.ui.theme.ScanwichTheme
import com.example.scanwich.ui.theme.ReadableAmber
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.google.firebase.Firebase
import com.google.firebase.appcheck.appCheck
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 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
@@ -66,8 +82,19 @@ import java.util.concurrent.TimeUnit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import org.json.JSONObject
// iText Imports
import com.itextpdf.kernel.pdf.PdfDocument
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?,
@@ -156,6 +183,32 @@ object ApiClient {
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
}
}
fun getEncryptedPrefs(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
"secure_user_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
suspend fun getValidStravaToken(prefs: SharedPreferences): String? {
val stravaToken = prefs.getString("strava_token", null) ?: return null
val expiresAt = prefs.getLong("strava_expires_at", 0)
@@ -212,8 +265,41 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Initialiser Firebase et App Check AVANT tout
try {
Firebase.initialize(context = this)
val appCheckFactory = if (BuildConfig.DEBUG) {
DebugAppCheckProviderFactory.getInstance()
} else {
PlayIntegrityAppCheckProviderFactory.getInstance()
}
Firebase.appCheck.installAppCheckProviderFactory(appCheckFactory)
Log.d("AppCheck", "App Check installed successfully")
// FORCER la génération du jeton pour qu'il apparaisse dans les logs
Firebase.appCheck.getAppCheckToken(false).addOnSuccessListener { tokenResult ->
Log.d("DEBUG_APP_CHECK", "Token: ${tokenResult.token}")
}.addOnFailureListener { e ->
Log.e("DEBUG_APP_CHECK", "Erreur: ${e.message}")
}
} catch (e: Exception) {
Log.e("AppCheck", "Failed to install App Check: ${e.message}")
}
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 {
AuthWrapper(dao)
@@ -231,12 +317,12 @@ class MainActivity : ComponentActivity() {
if (data != null && data.toString().startsWith("coloricam://localhost")) {
val code = data.getQueryParameter("code")
if (code != null) {
val prefs = getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
val prefs = ApiClient.getEncryptedPrefs(this)
val clientId = prefs.getString("strava_client_id", "") ?: ""
val clientSecret = prefs.getString("strava_client_secret", "") ?: ""
if (clientId.isNotEmpty() && clientSecret.isNotEmpty()) {
CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
CoroutineScope(Dispatchers.IO).launch {
try {
val response = ApiClient.stravaApi.exchangeToken(clientId, clientSecret, code)
prefs.edit {
@@ -258,6 +344,9 @@ class MainActivity : ComponentActivity() {
@Suppress("DEPRECATION")
fun AuthWrapper(dao: AppDao) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val auth = remember { FirebaseAuth.getInstance() }
val gso = remember {
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
@@ -265,16 +354,25 @@ fun AuthWrapper(dao: AppDao) {
.build()
}
val googleSignInClient = remember { GoogleSignIn.getClient(context, gso) }
var account by remember { mutableStateOf(GoogleSignIn.getLastSignedInAccount(context)) }
var firebaseUser by remember { mutableStateOf(auth.currentUser) }
val allowedEmails = listOf("marcandre.charest@gmail.com", "everousseau07@gmail.com")
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try {
val signedInAccount = task.getResult(ApiException::class.java)
account = signedInAccount
Log.d("Auth", "Connecté avec : ${signedInAccount.email}")
val account = task.getResult(ApiException::class.java)
val credential = GoogleAuthProvider.getCredential(account.idToken, null)
coroutineScope.launch {
try {
val authResult = auth.signInWithCredential(credential).await()
firebaseUser = authResult.user
Log.d("Auth", "Connecté à Firebase avec : ${firebaseUser?.email}")
} catch (e: Exception) {
Log.e("Auth", "Erreur Firebase Auth : ${e.message}")
Toast.makeText(context, "Erreur de synchronisation Firebase.", Toast.LENGTH_LONG).show()
}
}
} catch (e: ApiException) {
Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}")
val msg = when(e.statusCode) {
@@ -288,15 +386,16 @@ fun AuthWrapper(dao: AppDao) {
}
val onLogout: () -> Unit = {
auth.signOut()
googleSignInClient.signOut().addOnCompleteListener {
account = null
firebaseUser = null
}
}
if (account == null) {
if (firebaseUser == null) {
LoginScreen { launcher.launch(googleSignInClient.signInIntent) }
} else {
val userEmail = account?.email?.lowercase() ?: ""
val userEmail = firebaseUser?.email?.lowercase() ?: ""
if (allowedEmails.contains(userEmail)) {
MainApp(dao, onLogout)
} else {
@@ -307,6 +406,7 @@ fun AuthWrapper(dao: AppDao) {
@Composable
fun LoginScreen(onLoginClick: () -> Unit) {
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -342,7 +442,7 @@ fun AccessDeniedScreen(onLogout: () -> Unit) {
@Composable
fun MainApp(dao: AppDao, onLogout: () -> Unit) {
val context = LocalContext.current
val prefs = remember { context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE) }
val prefs = remember { ApiClient.getEncryptedPrefs(context) }
var showSetup by remember { mutableStateOf(!prefs.contains("target_calories")) }
var isDiabetic by remember { mutableStateOf(prefs.getBoolean("is_diabetic", false)) }
@@ -371,7 +471,7 @@ fun MainApp(dao: AppDao, onLogout: () -> Unit) {
composable("history") { HistoryScreen(dao, prefs) }
composable("sport") { SportScreen(dao, prefs) }
composable("glycemia") { GlycemiaScreen(dao) }
composable("settings") { SettingsScreen(prefs, onLogout) }
composable("settings") { SettingsScreen(prefs, onLogout) { isDiabetic = prefs.getBoolean("is_diabetic", false) } }
}
}
}
@@ -456,7 +556,7 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = weight,
onValueChange = { weight = it },
@@ -556,6 +656,8 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
var showBottomSheet 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) {
@@ -616,6 +718,46 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
}
}
if (showFavoritesSheet) {
ModalBottomSheet(
onDismissRequest = { showFavoritesSheet = false },
containerColor = MaterialTheme.colorScheme.surface
) {
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()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Aucun favori enregistré", color = Color.Gray)
}
} else {
LazyColumn {
items(favoriteMeals) { fav ->
ListItem(
headlineContent = { Text(fav.name) },
supportingContent = { Text("${fav.calories} kcal - G:${fav.carbs} P:${fav.protein} L:${fav.fat}") },
trailingContent = {
IconButton(onClick = {
currentMealData = Triple(fav.name, fav.analysisText, listOf(fav.calories, fav.carbs, fav.protein, fav.fat))
mealDateTime = System.currentTimeMillis()
showFavoritesSheet = false
showBottomSheet = true
}) { Icon(Icons.Default.Add, null) }
},
modifier = Modifier.clickable {
currentMealData = Triple(fav.name, fav.analysisText, listOf(fav.calories, fav.carbs, fav.protein, fav.fat))
mealDateTime = System.currentTimeMillis()
showFavoritesSheet = false
showBottomSheet = true
}
)
}
}
}
}
}
}
if (showBottomSheet && currentMealData != null) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
@@ -670,6 +812,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
minLines = 3
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, error ->
@@ -680,13 +823,36 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
}
}, coroutineScope)
},
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
enabled = !isAnalyzing
) {
Icon(Icons.Default.Refresh, null)
Spacer(Modifier.width(8.dp))
Text("Ressoumettre à l\u0027IA")
Spacer(Modifier.width(4.dp))
Text("Ressoumettre")
}
OutlinedButton(
onClick = {
coroutineScope.launch {
dao.insertFavorite(FavoriteMeal(
name = editableName,
analysisText = editableDesc,
calories = editableCalories.toIntOrNull() ?: 0,
carbs = editableCarbs.toIntOrNull() ?: 0,
protein = editableProtein.toIntOrNull() ?: 0,
fat = editableFat.toIntOrNull() ?: 0
))
Toast.makeText(context, "Ajouté aux favoris !", Toast.LENGTH_SHORT).show()
}
},
modifier = Modifier.weight(1f),
enabled = !isAnalyzing
) {
Icon(Icons.Default.Favorite, null)
Spacer(Modifier.width(4.dp))
Text("Favori")
}
}
Spacer(Modifier.height(16.dp))
@@ -775,6 +941,17 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
}
}
Spacer(Modifier.height(24.dp))
Button(
onClick = { showFavoritesSheet = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
) {
Icon(Icons.Default.Favorite, null)
Spacer(Modifier.width(8.dp))
Text("Utiliser un Favori (${favoriteMeals.size})")
}
Spacer(Modifier.height(32.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
@@ -854,6 +1031,7 @@ fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
var selectedDate by remember { mutableStateOf(Calendar.getInstance()) }
@@ -876,6 +1054,87 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
val tProt = prefs.getString("target_protein", "100")?.toIntOrNull() ?: 100
val tFat = prefs.getString("target_fat", "60")?.toIntOrNull() ?: 60
val isDark = isSystemInDarkTheme()
val calorieColor = if (isDark) Color(0xFF4CAF50) else Color(0xFF2E7D32)
val carbsColor = if (isDark) Color(0xFF2196F3) else Color(0xFF1565C0)
val proteinColor = if (isDark) Color(0xFFFF9800) else ReadableAmber
val fatColor = if (isDark) Color(0xFFE91E63) else Color(0xFFC2185B)
// PDF Export states
var showExportDialog by remember { mutableStateOf(false) }
var exportStartDate by remember { mutableStateOf(Calendar.getInstance()) }
var exportEndDate by remember { mutableStateOf(Calendar.getInstance()) }
val createPdfLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri ->
uri?.let {
coroutineScope.launch {
val start = exportStartDate.clone() as Calendar
start.set(Calendar.HOUR_OF_DAY, 0); start.set(Calendar.MINUTE, 0); start.set(Calendar.SECOND, 0)
val end = exportEndDate.clone() as Calendar
end.set(Calendar.HOUR_OF_DAY, 23); end.set(Calendar.MINUTE, 59); end.set(Calendar.SECOND, 59)
val mealsToExport = dao.getMealsInRangeSync(start.timeInMillis, end.timeInMillis)
val sportsToExport = dao.getSportsInRangeSync(start.timeInMillis, end.timeInMillis)
val glycemiaToExport = dao.getGlycemiaInRangeSync(start.timeInMillis, end.timeInMillis)
withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(it)?.use { os ->
generatePdfReport(os, mealsToExport, sportsToExport, glycemiaToExport, start.time, end.time)
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "PDF Exporté avec succès !", Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, "Erreur PDF: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
}
}
}
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
title = { Text("Exporter l\u0027historique") },
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
}, 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))
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
DatePickerDialog(context, { _, y, m, d ->
exportEndDate.set(y, m, d)
exportEndDate = exportEndDate.clone() as Calendar
}, 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))
}
}
},
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"
createPdfLauncher.launch(fileName)
}) { Text("Exporter") }
},
dismissButton = {
TextButton(onClick = { showExportDialog = false }) { Text("Annuler") }
}
)
}
if (selectedMealForDetail != null) {
AlertDialog(
onDismissRequest = { selectedMealForDetail = null },
@@ -902,7 +1161,7 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, -1)
@@ -912,7 +1171,7 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
Text(
text = SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()).format(selectedDate.time),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickable {
modifier = Modifier.weight(1f).clickable {
DatePickerDialog(context, { _, y, m, d ->
val newDate = Calendar.getInstance()
newDate.set(y, m, d)
@@ -921,6 +1180,10 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
}
)
IconButton(onClick = { showExportDialog = true }) {
Icon(Icons.Default.PictureAsPdf, contentDescription = "Export PDF", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, 1)
@@ -941,10 +1204,10 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
Text("Objectifs Quotidiens", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
DailyGoalChart("Calories", totalIn, tCal, Color(0xFF4CAF50))
DailyGoalChart("Glucides", totalCarbs, tCarb, Color(0xFF2196F3))
DailyGoalChart("Protéines", totalProt, tProt, Color(0xFFFF9800))
DailyGoalChart("Lipides", totalFat, tFat, Color(0xFFE91E63))
DailyGoalChart("Calories", totalIn, tCal, calorieColor)
DailyGoalChart("Glucides", totalCarbs, tCarb, carbsColor)
DailyGoalChart("Protéines", totalProt, tProt, proteinColor)
DailyGoalChart("Lipides", totalFat, tFat, fatColor)
}
Spacer(Modifier.height(12.dp))
HorizontalDivider()
@@ -1036,6 +1299,119 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
}
}
private fun generatePdfReport(
outputStream: OutputStream,
meals: List<Meal>,
sports: List<SportActivity>,
glycemia: List<Glycemia>,
startDate: Date,
endDate: Date
) {
val writer = PdfWriter(outputStream)
val pdf = PdfDocument(writer)
val document = Document(pdf)
val df = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
val tf = SimpleDateFormat("HH:mm", Locale.getDefault())
// Header
document.add(Paragraph("Rapport d\u0027Historique Scan-Wich")
.setTextAlignment(TextAlignment.CENTER)
.setFontSize(20f)
.setBold())
document.add(Paragraph("Période: ${df.format(startDate)} au ${df.format(endDate)}")
.setTextAlignment(TextAlignment.CENTER)
.setFontSize(12f))
document.add(Paragraph("\n"))
// Meals Table
if (meals.isNotEmpty()) {
document.add(Paragraph("Repas").setBold().setFontSize(14f))
val table = Table(UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f, 1f, 1f, 1f, 1f))).useAllAvailableWidth()
table.addHeaderCell("Date")
table.addHeaderCell("Nom")
table.addHeaderCell("Type")
table.addHeaderCell("Kcal")
table.addHeaderCell("Glu")
table.addHeaderCell("Pro")
table.addHeaderCell("Lip")
meals.forEach { meal ->
table.addCell(df.format(Date(meal.date)) + " " + tf.format(Date(meal.date)))
table.addCell(meal.name)
table.addCell(meal.type)
table.addCell(meal.totalCalories.toString())
table.addCell(meal.carbs.toString())
table.addCell(meal.protein.toString())
table.addCell(meal.fat.toString())
}
document.add(table)
document.add(Paragraph("\n"))
}
// Glycemia Table
if (glycemia.isNotEmpty()) {
document.add(Paragraph("Glycémie").setBold().setFontSize(14f))
val table = Table(UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f))).useAllAvailableWidth()
table.addHeaderCell("Date/Heure")
table.addHeaderCell("Moment")
table.addHeaderCell("Valeur (mmol/L)")
glycemia.forEach { gly ->
table.addCell(df.format(Date(gly.date)) + " " + tf.format(Date(gly.date)))
table.addCell(gly.moment)
table.addCell(gly.value.toString())
}
document.add(table)
document.add(Paragraph("\n"))
}
// Sports Table
if (sports.isNotEmpty()) {
document.add(Paragraph("Activités Sportives").setBold().setFontSize(14f))
val table = Table(UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f, 2f))).useAllAvailableWidth()
table.addHeaderCell("Date")
table.addHeaderCell("Activité")
table.addHeaderCell("Type")
table.addHeaderCell("Calories")
sports.forEach { sport ->
table.addCell(df.format(Date(sport.date)))
table.addCell(sport.name)
table.addCell(sport.type)
table.addCell(sport.calories?.toInt()?.toString() ?: "0")
}
document.add(table)
}
document.close()
}
// Fonction pour redimensionner et compresser l'image
private fun getOptimizedImageBase64(bitmap: Bitmap): String {
val maxSize = 1024
var width = bitmap.width
var height = bitmap.height
if (width > maxSize || height > maxSize) {
val ratio = width.toFloat() / height.toFloat()
if (width > height) {
width = maxSize
height = (maxSize / ratio).toInt()
} else {
height = maxSize
width = (maxSize * ratio).toInt()
}
}
val resized = Bitmap.createScaledBitmap(bitmap, width, height, true)
val outputStream = ByteArrayOutputStream()
resized.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}
private fun analyzeImage(
bitmap: Bitmap?,
textDescription: String?,
@@ -1045,38 +1421,41 @@ private fun analyzeImage(
) {
setAnalyzing(true)
var base64: String? = null
if (bitmap != null) {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}
val prompt = if (bitmap != null && textDescription == null) {
"Analyze this food image in FRENCH. Provide ONLY: 1. Name, 2. Summary description in FRENCH, 3. Macros. Format EXACTLY as: {\"name\": \"...\", \"description\": \"...\", \"calories\": int, \"carbs\": int, \"protein\": int, \"fat\": int}"
} else if (bitmap != null && textDescription != null) {
"Analyze this food image in FRENCH, taking into account these corrections or details: \u0027$textDescription\u0027. Provide ONLY: 1. Name, 2. Summary description in FRENCH, 3. Macros. Format EXACTLY as: {\"name\": \"...\", \"description\": \"...\", \"calories\": int, \"carbs\": int, \"protein\": int, \"fat\": int}"
} else {
"Analyze this meal description in FRENCH: \u0027$textDescription\u0027. Estimate the macros. Provide ONLY a JSON object. Format EXACTLY as: {\"name\": \"...\", \"description\": \"...\", \"calories\": int, \"carbs\": int, \"protein\": int, \"fat\": int}"
}
scope.launch {
try {
val response = ApiClient.n8nApi.analyzeMeal(
apiKey = BuildConfig.N8N_API_KEY,
request = N8nMealRequest(
imageBase64 = base64,
mealName = textDescription,
prompt = prompt
)
)
val responseStr = response.string()
val base64 = withContext(Dispatchers.Default) {
bitmap?.let { getOptimizedImageBase64(it) }
}
// On n'envoie plus le prompt, il est construit côté serveur
val data = hashMapOf(
"imageBase64" to base64,
"mealName" to textDescription
)
Firebase.functions("us-central1")
.getHttpsCallable("analyzeMealProxy")
.call(data)
.addOnSuccessListener { result ->
try {
val responseData = result.data
if (responseData is Map<*, *>) {
onResult(Triple(
(responseData["name"] as? String) ?: textDescription ?: "Repas",
(responseData["description"] as? String) ?: "Analyse réussie",
listOf(
(responseData["calories"] as? Number)?.toInt() ?: 0,
(responseData["carbs"] as? Number)?.toInt() ?: 0,
(responseData["protein"] as? Number)?.toInt() ?: 0,
(responseData["fat"] as? Number)?.toInt() ?: 0
)
), null)
} else {
// Fallback pour le parsing JSON manuel si ce n'est pas une Map
val responseStr = responseData.toString()
val jsonStartIndex = responseStr.indexOf("{")
val jsonEndIndex = responseStr.lastIndexOf("}") + 1
if (jsonStartIndex != -1 && jsonEndIndex > jsonStartIndex) {
try {
val jsonPart = responseStr.substring(jsonStartIndex, jsonEndIndex)
val json = JSONObject(jsonPart)
onResult(Triple(
@@ -1089,13 +1468,21 @@ private fun analyzeImage(
json.optInt("fat", 0)
)
), null)
return@launch
} catch (_: Exception) { }
} else {
onResult(null, "Format de réponse invalide")
}
}
} catch (e: Exception) {
onResult(null, "Erreur parsing: ${e.message}")
}
setAnalyzing(false)
}
.addOnFailureListener { e ->
onResult(null, "Erreur Cloud Function: ${e.message}")
setAnalyzing(false)
}
onResult(null, "Format de réponse inconnu")
} catch (e: Exception) {
onResult(null, e.localizedMessage ?: "Erreur réseau")
} finally {
setAnalyzing(false)
}
}
@@ -1175,15 +1562,30 @@ fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
fun Float.format(digits: Int) = "%.${digits}f".format(this)
@Composable
fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit) {
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 (isEditing) { SetupScreen(prefs) { isEditing = false } } else {
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
onProfileUpdated()
}
} else {
val targetCals = prefs.getString("target_calories", "0")
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
Text("Mon Profil", style = MaterialTheme.typography.headlineMedium)
@@ -1248,6 +1650,33 @@ fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit) {
}
}
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") }
}

View File

@@ -9,3 +9,12 @@ val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// Couleurs personnalisées pour une meilleure lisibilité
val DarkGreen = Color(0xFF2E7D32)
val DarkBlue = Color(0xFF1565C0)
val DeepOrange = Color(0xFFE64A19)
val DeepPink = Color(0xFFC2185B)
// Couleur pour remplacer le jaune illisible sur fond clair
val ReadableAmber = Color(0xFFB45F04)

View File

@@ -22,6 +22,9 @@ playServicesAuth = "21.5.1"
googleServices = "4.4.4"
firebaseBom = "34.9.0"
firebaseAppDistribution = "5.2.1"
firebaseAppDistributionSdk = "16.0.0-beta15"
securityCrypto = "1.1.0"
kotlinxCoroutinesPlayServices = "1.9.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -53,6 +56,16 @@ androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterfa
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
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" }
# iText Core is needed for PDF generation
itext7-core = { group = "com.itextpdf", name = "itext7-core", version = "7.2.5" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

10
release-notes.txt Normal file
View File

@@ -0,0 +1,10 @@
- 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 pour une plage de dates personnalisée.
- Amélioration de la gestion des données : ajout de requêtes synchrones pour une extraction plus rapide et fiable des rapports.
- Correction du script de build : résolution de l'erreur de compilation liée à l'accès au fichier de clés Firebase (firebase-key.json).
- Optimisation de la lisibilité : les couleurs des graphiques s'adaptent désormais automatiquement au thème du téléphone (ambre foncé en mode clair pour un meilleur contraste).
- Automatisation du versionnage : le numéro de version s'incrémente désormais automatiquement à chaque publication sur Firebase.
- Refonte de l'accès aux favoris : intégration d'un bouton dédié et d'une liste modale (Bottom Sheet) pour un écran d'accueil plus clair.
- Amélioration de la fonction "Vérifier les mises à jour" dans les paramètres avec une meilleure gestion des erreurs.
- Possibilité d'enregistrer n'importe quel repas analysé comme favori directement après le scan.
- Migration de la base de données vers la version 7 pour supporter les favoris (conservation de vos données existantes).
- Mise à jour des bibliothèques système et du SDK Android 36 pour une meilleure stabilité.