Merge pull request 'changes' (#4) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/4
This commit was merged in pull request #4.
This commit is contained in:
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -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">
|
||||
|
||||
@@ -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 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)
|
||||
}
|
||||
|
||||
5
app/proguard-rules.pro
vendored
5
app/proguard-rules.pro
vendored
@@ -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.** { *; }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,23 +812,47 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
||||
minLines = 3
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, error ->
|
||||
if (data != null) {
|
||||
currentMealData = data
|
||||
} else {
|
||||
Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show()
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, error ->
|
||||
if (data != null) {
|
||||
currentMealData = data
|
||||
} 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.fillMaxWidth().padding(top = 8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
|
||||
enabled = !isAnalyzing
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Ressoumettre à l\u0027IA")
|
||||
},
|
||||
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,57 +1421,68 @@ 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 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) { }
|
||||
val base64 = withContext(Dispatchers.Default) {
|
||||
bitmap?.let { getOptimizedImageBase64(it) }
|
||||
}
|
||||
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) {
|
||||
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") }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
9
release-notes.txt
Normal file
9
release-notes.txt
Normal 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.
|
||||
Reference in New Issue
Block a user