Compare commits

...

3 Commits

Author SHA1 Message Date
f8dfa9af63 Merge pull request 'changes' (#4) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/4
2026-02-23 13:08:45 -05:00
mac
374b773443 test 2026-02-23 13:05:18 -05:00
mac
f76b684d49 test 2026-02-23 13:03:59 -05:00
8 changed files with 612 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"> <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="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" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

@@ -1,4 +1,5 @@
import java.util.Properties import java.util.Properties
import java.util.Date
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
@@ -17,13 +18,16 @@ android {
applicationId = "com.example.scanwich" applicationId = "com.example.scanwich"
minSdk = 24 minSdk = 24
targetSdk = 35 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" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {
// Chargement des propriétés depuis local.properties
val keystoreProperties = Properties() val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("local.properties") val keystorePropertiesFile = rootProject.file("local.properties")
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
@@ -45,11 +49,12 @@ android {
} }
} }
buildTypes { val keyFile = project.file("firebase-key.json")
val keyFile = project.file("firebase-key.json") val releaseNotesFile = rootProject.file("release-notes.txt")
buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true // Activer l'offuscation
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -61,18 +66,24 @@ android {
if (keyFile.exists()) { if (keyFile.exists()) {
serviceCredentialsFile = keyFile.absolutePath serviceCredentialsFile = keyFile.absolutePath
} }
if (releaseNotesFile.exists()) {
releaseNotes = releaseNotesFile.readText()
}
groups = "internal-user" groups = "internal-user"
} }
} }
debug { debug {
isMinifyEnabled = false
configure<com.google.firebase.appdistribution.gradle.AppDistributionExtension> { configure<com.google.firebase.appdistribution.gradle.AppDistributionExtension> {
artifactType = "APK" artifactType = "APK"
if (keyFile.exists()) { if (keyFile.exists()) {
serviceCredentialsFile = keyFile.absolutePath serviceCredentialsFile = keyFile.absolutePath
} }
if (releaseNotesFile.exists()) {
releaseNotes = releaseNotesFile.readText()
}
groups = "internal-user" groups = "internal-user"
releaseNotes = "Version de développement"
} }
} }
} }
@@ -100,24 +111,33 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended) 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.google.generativeai)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.androidx.exifinterface) implementation(libs.androidx.exifinterface)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
implementation(libs.retrofit.core) implementation(libs.retrofit.core)
implementation(libs.retrofit.gson) implementation(libs.retrofit.gson)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(libs.play.services.auth) implementation(libs.play.services.auth)
implementation(platform(libs.firebase.bom)) implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics) 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) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
@@ -126,4 +146,5 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.firebase.appcheck.debug)
} }

View File

@@ -19,3 +19,8 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-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 android.content.Context
import androidx.room.* import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Entity(tableName = "meals") @Entity(tableName = "meals")
@@ -36,6 +38,17 @@ data class SportActivity(
val date: Long // timestamp 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 @Dao
interface AppDao { interface AppDao {
@Insert suspend fun insertMeal(meal: Meal): Long @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 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")
suspend fun getMealsInRangeSync(start: Long, end: Long): List<Meal>
@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 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")
suspend fun getGlycemiaInRangeSync(start: Long, end: Long): List<Glycemia>
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List<SportActivity>) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List<SportActivity>)
@Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow<List<SportActivity>> @Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow<List<SportActivity>>
@Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") @Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>> fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>>
@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 class AppDatabase : RoomDatabase() {
abstract fun appDao(): AppDao abstract fun appDao(): AppDao
companion object { companion object {
@Volatile private var INSTANCE: AppDatabase? = null @Volatile private var INSTANCE: AppDatabase? = null
private val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `favorite_meals` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `analysisText` TEXT NOT NULL, `calories` INTEGER NOT NULL, `carbs` INTEGER NOT NULL, `protein` INTEGER NOT NULL, `fat` INTEGER NOT NULL)")
}
}
fun getDatabase(context: Context): AppDatabase = fun getDatabase(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db") Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
.addMigrations(MIGRATION_6_7)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build().also { INSTANCE = it } .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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
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.items import androidx.compose.foundation.lazy.items
@@ -49,13 +50,28 @@ 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
import androidx.navigation.compose.rememberNavController 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.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.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException 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 com.google.gson.annotations.SerializedName
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
@@ -66,8 +82,19 @@ 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
import java.io.OutputStream
import org.json.JSONObject 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 --- // --- API MODELS ---
data class N8nMealRequest( data class N8nMealRequest(
val imageBase64: String?, val imageBase64: String?,
@@ -156,6 +183,32 @@ object ApiClient {
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 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? { suspend fun getValidStravaToken(prefs: SharedPreferences): String? {
val stravaToken = prefs.getString("strava_token", null) ?: return null val stravaToken = prefs.getString("strava_token", null) ?: return null
val expiresAt = prefs.getLong("strava_expires_at", 0) val expiresAt = prefs.getLong("strava_expires_at", 0)
@@ -212,8 +265,41 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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() 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)
@@ -231,12 +317,12 @@ class MainActivity : ComponentActivity() {
if (data != null && data.toString().startsWith("coloricam://localhost")) { if (data != null && data.toString().startsWith("coloricam://localhost")) {
val code = data.getQueryParameter("code") val code = data.getQueryParameter("code")
if (code != null) { if (code != null) {
val prefs = getSharedPreferences("user_prefs", Context.MODE_PRIVATE) val prefs = ApiClient.getEncryptedPrefs(this)
val clientId = prefs.getString("strava_client_id", "") ?: "" val clientId = prefs.getString("strava_client_id", "") ?: ""
val clientSecret = prefs.getString("strava_client_secret", "") ?: "" val clientSecret = prefs.getString("strava_client_secret", "") ?: ""
if (clientId.isNotEmpty() && clientSecret.isNotEmpty()) { if (clientId.isNotEmpty() && clientSecret.isNotEmpty()) {
CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val response = ApiClient.stravaApi.exchangeToken(clientId, clientSecret, code) val response = ApiClient.stravaApi.exchangeToken(clientId, clientSecret, code)
prefs.edit { prefs.edit {
@@ -258,6 +344,9 @@ class MainActivity : ComponentActivity() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
fun AuthWrapper(dao: AppDao) { fun AuthWrapper(dao: AppDao) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val auth = remember { FirebaseAuth.getInstance() }
val gso = remember { val gso = remember {
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail() .requestEmail()
@@ -265,16 +354,25 @@ fun AuthWrapper(dao: AppDao) {
.build() .build()
} }
val googleSignInClient = remember { GoogleSignIn.getClient(context, gso) } 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 allowedEmails = listOf("marcandre.charest@gmail.com", "everousseau07@gmail.com")
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try { try {
val signedInAccount = task.getResult(ApiException::class.java) val account = task.getResult(ApiException::class.java)
account = signedInAccount val credential = GoogleAuthProvider.getCredential(account.idToken, null)
Log.d("Auth", "Connecté avec : ${signedInAccount.email}") 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) { } 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) {
@@ -288,15 +386,16 @@ fun AuthWrapper(dao: AppDao) {
} }
val onLogout: () -> Unit = { val onLogout: () -> Unit = {
auth.signOut()
googleSignInClient.signOut().addOnCompleteListener { googleSignInClient.signOut().addOnCompleteListener {
account = null firebaseUser = null
} }
} }
if (account == null) { if (firebaseUser == null) {
LoginScreen { launcher.launch(googleSignInClient.signInIntent) } LoginScreen { launcher.launch(googleSignInClient.signInIntent) }
} else { } else {
val userEmail = account?.email?.lowercase() ?: "" val userEmail = firebaseUser?.email?.lowercase() ?: ""
if (allowedEmails.contains(userEmail)) { if (allowedEmails.contains(userEmail)) {
MainApp(dao, onLogout) MainApp(dao, onLogout)
} else { } else {
@@ -307,6 +406,7 @@ 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,
@@ -342,7 +442,7 @@ fun AccessDeniedScreen(onLogout: () -> Unit) {
@Composable @Composable
fun MainApp(dao: AppDao, onLogout: () -> Unit) { fun MainApp(dao: AppDao, onLogout: () -> Unit) {
val context = LocalContext.current 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 showSetup by remember { mutableStateOf(!prefs.contains("target_calories")) }
var isDiabetic by remember { mutableStateOf(prefs.getBoolean("is_diabetic", false)) } 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("history") { HistoryScreen(dao, prefs) }
composable("sport") { SportScreen(dao, prefs) } composable("sport") { SportScreen(dao, prefs) }
composable("glycemia") { GlycemiaScreen(dao) } 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)) Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField( OutlinedTextField(
value = weight, value = weight,
onValueChange = { weight = it }, onValueChange = { weight = it },
@@ -556,6 +656,8 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet 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) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
if (bitmap != null) { 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) { if (showBottomSheet && currentMealData != null) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { showBottomSheet = false }, onDismissRequest = { showBottomSheet = false },
@@ -670,23 +812,47 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
minLines = 3 minLines = 3
) )
Button( Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
onClick = { Button(
analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, error -> onClick = {
if (data != null) { analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, error ->
currentMealData = data if (data != null) {
} else { currentMealData = data
Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show() } else {
Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show()
}
}, coroutineScope)
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
enabled = !isAnalyzing
) {
Icon(Icons.Default.Refresh, null)
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()
} }
}, coroutineScope) },
}, modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxWidth().padding(top = 8.dp), enabled = !isAnalyzing
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), ) {
enabled = !isAnalyzing Icon(Icons.Default.Favorite, null)
) { Spacer(Modifier.width(4.dp))
Icon(Icons.Default.Refresh, null) Text("Favori")
Spacer(Modifier.width(8.dp)) }
Text("Ressoumettre à l\u0027IA")
} }
Spacer(Modifier.height(16.dp)) 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)) Spacer(Modifier.height(32.dp))
HorizontalDivider() HorizontalDivider()
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -854,6 +1031,7 @@ fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@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()) }
@@ -876,6 +1054,87 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
val tProt = prefs.getString("target_protein", "100")?.toIntOrNull() ?: 100 val tProt = prefs.getString("target_protein", "100")?.toIntOrNull() ?: 100
val tFat = prefs.getString("target_fat", "60")?.toIntOrNull() ?: 60 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) { if (selectedMealForDetail != null) {
AlertDialog( AlertDialog(
onDismissRequest = { selectedMealForDetail = null }, onDismissRequest = { selectedMealForDetail = null },
@@ -902,7 +1161,7 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
} }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { 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 = { IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, -1) newDate.add(Calendar.DAY_OF_MONTH, -1)
@@ -912,7 +1171,7 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
Text( Text(
text = SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()).format(selectedDate.time), text = SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()).format(selectedDate.time),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickable { modifier = Modifier.weight(1f).clickable {
DatePickerDialog(context, { _, y, m, d -> DatePickerDialog(context, { _, y, m, d ->
val newDate = Calendar.getInstance() val newDate = Calendar.getInstance()
newDate.set(y, m, d) 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 = { IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, 1) 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) Text("Objectifs Quotidiens", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
DailyGoalChart("Calories", totalIn, tCal, Color(0xFF4CAF50)) DailyGoalChart("Calories", totalIn, tCal, calorieColor)
DailyGoalChart("Glucides", totalCarbs, tCarb, Color(0xFF2196F3)) DailyGoalChart("Glucides", totalCarbs, tCarb, carbsColor)
DailyGoalChart("Protéines", totalProt, tProt, Color(0xFFFF9800)) DailyGoalChart("Protéines", totalProt, tProt, proteinColor)
DailyGoalChart("Lipides", totalFat, tFat, Color(0xFFE91E63)) DailyGoalChart("Lipides", totalFat, tFat, fatColor)
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
HorizontalDivider() 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( private fun analyzeImage(
bitmap: Bitmap?, bitmap: Bitmap?,
textDescription: String?, textDescription: String?,
@@ -1045,57 +1421,68 @@ private fun analyzeImage(
) { ) {
setAnalyzing(true) 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 { scope.launch {
try { try {
val response = ApiClient.n8nApi.analyzeMeal( val base64 = withContext(Dispatchers.Default) {
apiKey = BuildConfig.N8N_API_KEY, bitmap?.let { getOptimizedImageBase64(it) }
request = N8nMealRequest(
imageBase64 = base64,
mealName = textDescription,
prompt = prompt
)
)
val responseStr = response.string()
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(
json.optString("name", textDescription ?: "Repas"),
json.optString("description", "Analyse réussie"),
listOf(
json.optInt("calories", 0),
json.optInt("carbs", 0),
json.optInt("protein", 0),
json.optInt("fat", 0)
)
), null)
return@launch
} catch (_: Exception) { }
} }
onResult(null, "Format de réponse inconnu")
// 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) {
val jsonPart = responseStr.substring(jsonStartIndex, jsonEndIndex)
val json = JSONObject(jsonPart)
onResult(Triple(
json.optString("name", textDescription ?: "Repas"),
json.optString("description", "Analyse réussie"),
listOf(
json.optInt("calories", 0),
json.optInt("carbs", 0),
json.optInt("protein", 0),
json.optInt("fat", 0)
)
), null)
} 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)
}
} catch (e: Exception) { } catch (e: Exception) {
onResult(null, e.localizedMessage ?: "Erreur réseau") onResult(null, e.localizedMessage ?: "Erreur réseau")
} finally {
setAnalyzing(false) setAnalyzing(false)
} }
} }
@@ -1175,15 +1562,30 @@ fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
fun Float.format(digits: Int) = "%.${digits}f".format(this) fun Float.format(digits: Int) = "%.${digits}f".format(this)
@Composable @Composable
fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> 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 (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") val targetCals = prefs.getString("target_calories", "0")
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) { Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
Text("Mon Profil", style = MaterialTheme.typography.headlineMedium) 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)) 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") }
} }

View File

@@ -9,3 +9,12 @@ val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4) val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71) val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260) 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" 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"
securityCrypto = "1.1.0"
kotlinxCoroutinesPlayServices = "1.9.0"
[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" }
@@ -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" } 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-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

9
release-notes.txt Normal file
View File

@@ -0,0 +1,9 @@
- Renforcement de la sécurité : Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les utilisations non autorisées et sécuriser les appels vers l'IA.
- Protection des données sensibles : Migration de la clé API vers Google Cloud Secret Manager, garantissant qu'aucune information confidentielle n'est stockée en dur dans le code.
- Optimisation majeure de l'analyse : Réduction drastique de la latence réseau grâce à un nouveau moteur de compression d'image (passage de 2.2 Mo à ~150 Ko par scan).
- Sécurisation de l'IA : Migration de la logique des instructions (prompts) côté serveur (Cloud Functions) pour prévenir les manipulations et garantir des résultats fiables.
- Amélioration de l'authentification : Liaison directe entre Google Sign-In et Firebase Auth pour une session utilisateur plus robuste et sécurisée.
- Correction du parsing : Nouveau système de traitement des réponses Cloud Functions pour une meilleure fiabilité de l'affichage des macros.
- Export PDF de l'historique : nouvelle fonctionnalité permettant d'exporter vos repas, suivis de glycémie et activités sportives sous forme de rapport PDF professionnel.
- Refonte de l'accès aux favoris : intégration d'un bouton dédié et d'une liste modale pour un écran d'accueil plus clair.
- Mise à jour système : Optimisation pour Android 36 et amélioration de la stabilité globale.