Merge pull request 'changes' (#5) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/5
This commit was merged in pull request #5.
This commit is contained in:
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@@ -1,3 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
import java.util.Date
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
@@ -133,6 +132,13 @@ dependencies {
|
|||||||
implementation(libs.firebase.auth)
|
implementation(libs.firebase.auth)
|
||||||
implementation(libs.firebase.appcheck.playintegrity)
|
implementation(libs.firebase.appcheck.playintegrity)
|
||||||
|
|
||||||
|
// Barcode Scanning & Camera
|
||||||
|
implementation(libs.mlkit.barcode.scanning)
|
||||||
|
implementation(libs.androidx.camera.core)
|
||||||
|
implementation(libs.androidx.camera.camera2)
|
||||||
|
implementation(libs.androidx.camera.lifecycle)
|
||||||
|
implementation(libs.androidx.camera.view)
|
||||||
|
|
||||||
// PDF generation
|
// PDF generation
|
||||||
implementation(libs.itext7.core)
|
implementation(libs.itext7.core)
|
||||||
|
|
||||||
|
|||||||
77
app/proguard-rules.pro
vendored
77
app/proguard-rules.pro
vendored
@@ -1,26 +1,61 @@
|
|||||||
# Add project specific ProGuard rules here.
|
# --- SCAN-WICH SECURITY RULES ---
|
||||||
# You can control the set of applied configuration files using the
|
|
||||||
# proguardFiles setting in build.gradle.
|
|
||||||
#
|
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
# Optimisations agressives
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
-optimizationpasses 5
|
||||||
# class:
|
-allowaccessmodification
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
-mergeinterfacesaggressively
|
||||||
# public *;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
# Supprimer les informations de débogage
|
||||||
# debugging stack traces.
|
-renamesourcefileattribute SourceFile
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# Offusquer plus profondément
|
||||||
# hide the original source file name.
|
-repackageclasses ''
|
||||||
#-renamesourcefileattribute SourceFile
|
|
||||||
|
|
||||||
# Obfuscation pour protéger les clés d'API
|
# --- FIREBASE / GOOGLE ---
|
||||||
-keep class com.example.scanwich.BuildConfig { *; }
|
# Les bibliothèques Google fournissent leurs propres règles optimisées.
|
||||||
|
-dontwarn com.google.firebase.**
|
||||||
|
-dontwarn com.google.android.gms.**
|
||||||
|
-dontwarn com.google.errorprone.annotations.**
|
||||||
|
|
||||||
|
# --- ITEXT ---
|
||||||
|
# On cible plus précisément les packages iText utilisés pour le rapport PDF
|
||||||
-dontwarn com.itextpdf.**
|
-dontwarn com.itextpdf.**
|
||||||
-keep class com.itextpdf.** { *; }
|
-keep class com.itextpdf.kernel.** { public protected *; }
|
||||||
|
-keep class com.itextpdf.layout.** { public protected *; }
|
||||||
|
-keep class com.itextpdf.io.** { public protected *; }
|
||||||
|
|
||||||
|
# --- RETROFIT / OKHTTP ---
|
||||||
|
-keepattributes Signature, InnerClasses, EnclosingMethod
|
||||||
|
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||||
|
|
||||||
|
# Garder uniquement les annotations Retrofit
|
||||||
|
-keep @interface retrofit2.http.*
|
||||||
|
-dontwarn retrofit2.**
|
||||||
|
# Note: On laisse Retrofit gérer ses propres règles internes (incluses dans l'AAR)
|
||||||
|
|
||||||
|
# --- ROOM ---
|
||||||
|
# On ne garde que les classes liées à la base de données
|
||||||
|
-keep class * extends androidx.room.RoomDatabase
|
||||||
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# --- DATA MODELS ---
|
||||||
|
# Crucial : On garde tout ce qui est nécessaire au parsing JSON et à Room
|
||||||
|
-keepclassmembers class com.example.scanwich.** {
|
||||||
|
@com.google.gson.annotations.SerializedName <fields>;
|
||||||
|
@androidx.room.PrimaryKey <fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
-keep @androidx.room.Entity class com.example.scanwich.** { *; }
|
||||||
|
|
||||||
|
# On liste explicitement les modèles pour plus de précision
|
||||||
|
-keep class com.example.scanwich.Meal { *; }
|
||||||
|
-keep class com.example.scanwich.SportActivity { *; }
|
||||||
|
-keep class com.example.scanwich.Glycemia { *; }
|
||||||
|
-keep class com.example.scanwich.FavoriteMeal { *; }
|
||||||
|
-keep class com.example.scanwich.N8nMealRequest { *; }
|
||||||
|
-keep class com.example.scanwich.StravaActivity { *; }
|
||||||
|
-keep class com.example.scanwich.StravaTokenResponse { *; }
|
||||||
|
|
||||||
|
# --- CONFIGURATION ---
|
||||||
|
-keep class com.example.scanwich.BuildConfig { *; }
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ data class FavoriteMeal(
|
|||||||
interface AppDao {
|
interface AppDao {
|
||||||
@Insert suspend fun insertMeal(meal: Meal): Long
|
@Insert suspend fun insertMeal(meal: Meal): Long
|
||||||
@Delete suspend fun deleteMeal(meal: Meal)
|
@Delete suspend fun deleteMeal(meal: Meal)
|
||||||
@Query("SELECT * FROM meals ORDER BY date DESC") fun getAllMeals(): Flow<List<Meal>>
|
|
||||||
@Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
@Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
||||||
fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>>
|
fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>>
|
||||||
@Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
@Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
||||||
@@ -61,7 +60,6 @@ interface AppDao {
|
|||||||
|
|
||||||
@Insert suspend fun insertGlycemia(glycemia: Glycemia): Long
|
@Insert suspend fun insertGlycemia(glycemia: Glycemia): Long
|
||||||
@Delete suspend fun deleteGlycemia(glycemia: Glycemia)
|
@Delete suspend fun deleteGlycemia(glycemia: Glycemia)
|
||||||
@Query("SELECT * FROM glycemia ORDER BY date DESC") fun getAllGlycemia(): Flow<List<Glycemia>>
|
|
||||||
@Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
@Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
||||||
fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>>
|
fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>>
|
||||||
@Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
@Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
||||||
@@ -75,8 +73,10 @@ interface AppDao {
|
|||||||
suspend fun getSportsInRangeSync(start: Long, end: Long): List<SportActivity>
|
suspend fun getSportsInRangeSync(start: Long, end: Long): List<SportActivity>
|
||||||
|
|
||||||
@Insert suspend fun insertFavorite(meal: FavoriteMeal)
|
@Insert suspend fun insertFavorite(meal: FavoriteMeal)
|
||||||
@Delete suspend fun deleteFavorite(meal: FavoriteMeal)
|
|
||||||
@Query("SELECT * FROM favorite_meals ORDER BY name ASC") fun getAllFavorites(): Flow<List<FavoriteMeal>>
|
@Query("SELECT * FROM favorite_meals ORDER BY name ASC") fun getAllFavorites(): Flow<List<FavoriteMeal>>
|
||||||
|
|
||||||
|
@Query("SELECT date FROM meals UNION SELECT date FROM sports UNION SELECT date FROM glycemia")
|
||||||
|
fun getAllDatesWithData(): Flow<List<Long>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 7)
|
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 7)
|
||||||
@@ -95,7 +95,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
INSTANCE ?: synchronized(this) {
|
INSTANCE ?: synchronized(this) {
|
||||||
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
|
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
|
||||||
.addMigrations(MIGRATION_6_7)
|
.addMigrations(MIGRATION_6_7)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration(dropAllTables = false)
|
||||||
.build().also { INSTANCE = it }
|
.build().also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,17 +18,27 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.camera.core.*
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
@@ -41,11 +51,16 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@@ -63,22 +78,19 @@ import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory
|
|||||||
import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory
|
import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory
|
||||||
import com.google.firebase.auth.FirebaseAuth
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
import com.google.firebase.auth.GoogleAuthProvider
|
import com.google.firebase.auth.GoogleAuthProvider
|
||||||
import com.google.firebase.appdistribution.FirebaseAppDistribution
|
|
||||||
import com.google.firebase.functions.functions
|
import com.google.firebase.functions.functions
|
||||||
import com.google.firebase.initialize
|
import com.google.firebase.initialize
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||||
|
import com.google.mlkit.vision.common.InputImage
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import retrofit2.http.*
|
import retrofit2.http.*
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
@@ -91,23 +103,30 @@ import com.itextpdf.kernel.pdf.PdfWriter
|
|||||||
import com.itextpdf.layout.Document
|
import com.itextpdf.layout.Document
|
||||||
import com.itextpdf.layout.element.Paragraph
|
import com.itextpdf.layout.element.Paragraph
|
||||||
import com.itextpdf.layout.element.Table
|
import com.itextpdf.layout.element.Table
|
||||||
import com.itextpdf.layout.element.Cell
|
|
||||||
import com.itextpdf.layout.properties.TextAlignment
|
import com.itextpdf.layout.properties.TextAlignment
|
||||||
import com.itextpdf.layout.properties.UnitValue
|
import com.itextpdf.layout.properties.UnitValue
|
||||||
|
|
||||||
// --- API MODELS ---
|
// --- OPEN FOOD FACTS API ---
|
||||||
data class N8nMealRequest(
|
data class OffProductResponse(
|
||||||
val imageBase64: String?,
|
val status: Int,
|
||||||
val mealName: String?,
|
val product: OffProduct?
|
||||||
val prompt: String
|
|
||||||
)
|
)
|
||||||
|
|
||||||
interface N8nApi {
|
data class OffProduct(
|
||||||
@POST("webhook/v1/gemini-proxy")
|
@SerializedName("product_name") val productName: String?,
|
||||||
suspend fun analyzeMeal(
|
val nutriments: OffNutriments?
|
||||||
@Header("X-API-KEY") apiKey: String,
|
)
|
||||||
@Body request: N8nMealRequest
|
|
||||||
): ResponseBody
|
data class OffNutriments(
|
||||||
|
@SerializedName("energy-kcal_100g") val energyKcal: Float?,
|
||||||
|
@SerializedName("carbohydrates_100g") val carbs: Float?,
|
||||||
|
@SerializedName("proteins_100g") val proteins: Float?,
|
||||||
|
@SerializedName("fat_100g") val fat: Float?
|
||||||
|
)
|
||||||
|
|
||||||
|
interface OffApi {
|
||||||
|
@GET("product/{barcode}.json")
|
||||||
|
suspend fun getProduct(@Path("barcode") barcode: String): OffProductResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- STRAVA API ---
|
// --- STRAVA API ---
|
||||||
@@ -158,42 +177,18 @@ interface StravaApi {
|
|||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
object ApiClient {
|
object ApiClient {
|
||||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
|
||||||
level = HttpLoggingInterceptor.Level.BODY
|
|
||||||
}
|
|
||||||
|
|
||||||
private val okHttpClient = OkHttpClient.Builder()
|
|
||||||
.addInterceptor(loggingInterceptor)
|
|
||||||
.connectTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val retrofitStrava = Retrofit.Builder()
|
private val retrofitStrava = Retrofit.Builder()
|
||||||
.baseUrl("https://www.strava.com/api/v3/")
|
.baseUrl("https://www.strava.com/api/v3/")
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val retrofitN8n = Retrofit.Builder()
|
private val retrofitOff = Retrofit.Builder()
|
||||||
.baseUrl("https://n8n.marquis1987.com/")
|
.baseUrl("https://world.openfoodfacts.org/api/v2/")
|
||||||
.client(okHttpClient)
|
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val stravaApi: StravaApi = retrofitStrava.create(StravaApi::class.java)
|
val stravaApi: StravaApi = retrofitStrava.create(StravaApi::class.java)
|
||||||
val n8nApi: N8nApi = retrofitN8n.create(N8nApi::class.java)
|
val offApi: OffApi = retrofitOff.create(OffApi::class.java)
|
||||||
|
|
||||||
// Protection par simple obfuscation (Base64) pour éviter les scanners de texte simples dans l'APK
|
|
||||||
fun getN8nKey(): String {
|
|
||||||
val raw = BuildConfig.N8N_API_KEY
|
|
||||||
return try {
|
|
||||||
// On tente de décoder. Si ça échoue (pas du base64 valide), on renvoie tel quel.
|
|
||||||
val decoded = Base64.decode(raw, Base64.DEFAULT)
|
|
||||||
String(decoded)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getEncryptedPrefs(context: Context): SharedPreferences {
|
fun getEncryptedPrefs(context: Context): SharedPreferences {
|
||||||
val masterKey = MasterKey.Builder(context)
|
val masterKey = MasterKey.Builder(context)
|
||||||
@@ -290,16 +285,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
dao = AppDatabase.getDatabase(this).appDao()
|
dao = AppDatabase.getDatabase(this).appDao()
|
||||||
handleStravaCallback(intent)
|
handleStravaCallback(intent)
|
||||||
|
|
||||||
// Vérification automatique des mises à jour Firebase App Distribution
|
|
||||||
try {
|
|
||||||
FirebaseAppDistribution.getInstance().updateIfNewReleaseAvailable()
|
|
||||||
.addOnFailureListener { e ->
|
|
||||||
Log.e("AppDistribution", "Initial update check failed: ${e.message}")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("AppDistribution", "SDK not implemented in onCreate: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ScanwichTheme {
|
ScanwichTheme {
|
||||||
AuthWrapper(dao)
|
AuthWrapper(dao)
|
||||||
@@ -376,7 +361,7 @@ fun AuthWrapper(dao: AppDao) {
|
|||||||
} catch (e: ApiException) {
|
} catch (e: ApiException) {
|
||||||
Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}")
|
Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}")
|
||||||
val msg = when(e.statusCode) {
|
val msg = when(e.statusCode) {
|
||||||
10 -> "Erreur 10 : SHA-1 non reconnu dans Firebase. Assurez-vous d\u0027avoir ajouté le SHA-1 de TOUTES vos clés de signature."
|
10 -> "Erreur 10 : SHA-1 non reconnu dans Firebase. Assurez-vous d'avoir ajouté le SHA-1 de TOUTES vos clés de signature."
|
||||||
7 -> "Erreur 7 : Problème de réseau."
|
7 -> "Erreur 7 : Problème de réseau."
|
||||||
12500 -> "Erreur 12500 : Problème de configuration Google Play Services."
|
12500 -> "Erreur 12500 : Problème de configuration Google Play Services."
|
||||||
else -> "Erreur Google (Code ${e.statusCode})."
|
else -> "Erreur Google (Code ${e.statusCode})."
|
||||||
@@ -406,7 +391,6 @@ fun AuthWrapper(dao: AppDao) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(onLoginClick: () -> Unit) {
|
fun LoginScreen(onLoginClick: () -> Unit) {
|
||||||
val context = LocalContext.current
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -433,7 +417,7 @@ fun AccessDeniedScreen(onLogout: () -> Unit) {
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Text("Accès Refusé", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.error)
|
Text("Accès Refusé", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.error)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("Votre compte n\u0027est pas autorisé à utiliser cette application.", style = MaterialTheme.typography.bodyLarge)
|
Text("Votre compte n'est pas autorisé à utiliser cette application.", style = MaterialTheme.typography.bodyLarge)
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
Button(onClick = onLogout) { Text("Changer de compte") }
|
Button(onClick = onLogout) { Text("Changer de compte") }
|
||||||
}
|
}
|
||||||
@@ -467,7 +451,7 @@ fun MainApp(dao: AppDao, onLogout: () -> Unit) {
|
|||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
NavHost(navController, "capture", Modifier.padding(innerPadding)) {
|
NavHost(navController, "capture", Modifier.padding(innerPadding)) {
|
||||||
composable("capture") { CaptureScreen(dao, prefs, isDiabetic) }
|
composable("capture") { CaptureScreen(dao) }
|
||||||
composable("history") { HistoryScreen(dao, prefs) }
|
composable("history") { HistoryScreen(dao, prefs) }
|
||||||
composable("sport") { SportScreen(dao, prefs) }
|
composable("sport") { SportScreen(dao, prefs) }
|
||||||
composable("glycemia") { GlycemiaScreen(dao) }
|
composable("glycemia") { GlycemiaScreen(dao) }
|
||||||
@@ -571,7 +555,7 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Text("Niveau d\u0027activité :", style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Start))
|
Text("Niveau d'activité :", style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Start))
|
||||||
activityLevels.forEach { level ->
|
activityLevels.forEach { level ->
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -609,19 +593,13 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val multiplier = activityMultipliers[activityLevel] ?: 1.2
|
val multiplier = activityMultipliers[activityLevel] ?: 1.2
|
||||||
var targetCals = (bmr * multiplier).toInt()
|
val targetCals = (bmr * multiplier).toInt().let { if (goal == "Perdre du poids") it - 500 else it }
|
||||||
|
|
||||||
if (goal == "Perdre du poids") targetCals -= 500
|
|
||||||
|
|
||||||
val targetCarbs = (targetCals * 0.5 / 4).toInt()
|
|
||||||
val targetProtein = (targetCals * 0.2 / 4).toInt()
|
|
||||||
val targetFat = (targetCals * 0.3 / 9).toInt()
|
|
||||||
|
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putString("target_calories", targetCals.toString())
|
putString("target_calories", targetCals.toString())
|
||||||
putString("target_carbs", targetCarbs.toString())
|
putString("target_carbs", (targetCals * 0.5 / 4).toInt().toString())
|
||||||
putString("target_protein", targetProtein.toString())
|
putString("target_protein", (targetCals * 0.2 / 4).toInt().toString())
|
||||||
putString("target_fat", targetFat.toString())
|
putString("target_fat", (targetCals * 0.3 / 9).toInt().toString())
|
||||||
putString("weight_kg", weightKg.toString())
|
putString("weight_kg", weightKg.toString())
|
||||||
putString("weight_display", weightDisplay)
|
putString("weight_display", weightDisplay)
|
||||||
putBoolean("is_lbs", isLbs)
|
putBoolean("is_lbs", isLbs)
|
||||||
@@ -644,7 +622,7 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
fun CaptureScreen(dao: AppDao) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
@@ -654,21 +632,21 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
var mealDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
var mealDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
|
var showBarcodeScanner by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var manualMealName by remember { mutableStateOf("") }
|
var manualMealName by remember { mutableStateOf("") }
|
||||||
val favoriteMeals by dao.getAllFavorites().collectAsState(initial = emptyList())
|
|
||||||
var showFavoritesSheet by remember { mutableStateOf(false) }
|
var showFavoritesSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
|
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
capturedBitmap = bitmap
|
capturedBitmap = bitmap
|
||||||
mealDateTime = System.currentTimeMillis()
|
mealDateTime = System.currentTimeMillis()
|
||||||
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error ->
|
analyzeImage(bitmap, null, { isAnalyzing = it }, { data: Triple<String, String, List<Int>>?, _ ->
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
currentMealData = data
|
currentMealData = data
|
||||||
showBottomSheet = true
|
showBottomSheet = true
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show()
|
Toast.makeText(context, "Analyse échouée", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}, coroutineScope)
|
}, coroutineScope)
|
||||||
}
|
}
|
||||||
@@ -684,6 +662,16 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val barcodePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { isGranted: Boolean ->
|
||||||
|
if (isGranted) {
|
||||||
|
showBarcodeScanner = true
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Permission caméra requise pour le scan", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
uri?.let {
|
uri?.let {
|
||||||
try {
|
try {
|
||||||
@@ -704,12 +692,12 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
exifStream.close()
|
exifStream.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error ->
|
analyzeImage(bitmap, null, { isAnalyzing = it }, { data: Triple<String, String, List<Int>>?, _ ->
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
currentMealData = data
|
currentMealData = data
|
||||||
showBottomSheet = true
|
showBottomSheet = true
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "L\u0027IA n\u0027a pas pu identifier le repas.", Toast.LENGTH_LONG).show()
|
Toast.makeText(context, "L'IA n'a pas pu identifier le repas.", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}, coroutineScope)
|
}, coroutineScope)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -718,21 +706,59 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showBarcodeScanner) {
|
||||||
|
BarcodeScannerDialog(
|
||||||
|
onBarcodeScanned = { barcode ->
|
||||||
|
showBarcodeScanner = false
|
||||||
|
isAnalyzing = true
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val response = ApiClient.offApi.getProduct(barcode)
|
||||||
|
if (response.status == 1 && response.product != null) {
|
||||||
|
val p = response.product
|
||||||
|
val nut = p.nutriments
|
||||||
|
currentMealData = Triple(
|
||||||
|
p.productName ?: "Produit inconnu",
|
||||||
|
"Scanné via OpenFoodFacts",
|
||||||
|
listOf(
|
||||||
|
nut?.energyKcal?.toInt() ?: 0,
|
||||||
|
nut?.carbs?.toInt() ?: 0,
|
||||||
|
nut?.proteins?.toInt() ?: 0,
|
||||||
|
nut?.fat?.toInt() ?: 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mealDateTime = System.currentTimeMillis()
|
||||||
|
showBottomSheet = true
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Produit non trouvé", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(context, "Erreur API: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
|
} finally {
|
||||||
|
isAnalyzing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { showBarcodeScanner = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showFavoritesSheet) {
|
if (showFavoritesSheet) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { showFavoritesSheet = false },
|
onDismissRequest = { showFavoritesSheet = false },
|
||||||
containerColor = MaterialTheme.colorScheme.surface
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
) {
|
) {
|
||||||
|
val favMeals by dao.getAllFavorites().collectAsState(initial = emptyList())
|
||||||
Column(modifier = Modifier.padding(16.dp).fillMaxHeight(0.6f)) {
|
Column(modifier = Modifier.padding(16.dp).fillMaxHeight(0.6f)) {
|
||||||
Text("Mes Favoris", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
Text("Mes Favoris", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
if (favoriteMeals.isEmpty()) {
|
if (favMeals.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text("Aucun favori enregistré", color = Color.Gray)
|
Text("Aucun favori enregistré", color = Color.Gray)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(favoriteMeals) { fav ->
|
items(favMeals) { fav ->
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(fav.name) },
|
headlineContent = { Text(fav.name) },
|
||||||
supportingContent = { Text("${fav.calories} kcal - G:${fav.carbs} P:${fav.protein} L:${fav.fat}") },
|
supportingContent = { Text("${fav.calories} kcal - G:${fav.carbs} P:${fav.protein} L:${fav.fat}") },
|
||||||
@@ -771,10 +797,11 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
var editableName by remember { mutableStateOf(currentMealData!!.first) }
|
var editableName by remember { mutableStateOf(currentMealData!!.first) }
|
||||||
var editableDesc by remember { mutableStateOf(currentMealData!!.second) }
|
var editableDesc by remember { mutableStateOf(currentMealData!!.second) }
|
||||||
|
|
||||||
val editableCalories = currentMealData!!.third[0].toString()
|
val mealValues = currentMealData!!.third
|
||||||
val editableCarbs = currentMealData!!.third[1].toString()
|
val editableCalories = mealValues[0].toString()
|
||||||
val editableProtein = currentMealData!!.third[2].toString()
|
val editableCarbs = mealValues[1].toString()
|
||||||
val editableFat = currentMealData!!.third[3].toString()
|
val editableProtein = mealValues[2].toString()
|
||||||
|
val editableFat = mealValues[3].toString()
|
||||||
|
|
||||||
// Update local state if currentMealData changes (e.g. after resubmission)
|
// Update local state if currentMealData changes (e.g. after resubmission)
|
||||||
LaunchedEffect(currentMealData) {
|
LaunchedEffect(currentMealData) {
|
||||||
@@ -807,7 +834,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = editableDesc,
|
value = editableDesc,
|
||||||
onValueChange = { editableDesc = it },
|
onValueChange = { editableDesc = it },
|
||||||
label = { Text("Description / Précisions pour l\u0027IA") },
|
label = { Text("Description / Précisions pour l'IA") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
minLines = 3
|
minLines = 3
|
||||||
)
|
)
|
||||||
@@ -815,11 +842,11 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, error ->
|
analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data: Triple<String, String, List<Int>>?, _ ->
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
currentMealData = data
|
currentMealData = data
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show()
|
Toast.makeText(context, "Analyse échouée", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}, coroutineScope)
|
}, coroutineScope)
|
||||||
},
|
},
|
||||||
@@ -876,7 +903,8 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) {
|
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) {
|
||||||
Icon(Icons.Default.DateRange, null)
|
Icon(Icons.Default.DateRange, null)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Date/Heure: " + SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(mealDateTime)))
|
val formattedDate = SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(mealDateTime))
|
||||||
|
Text("Date/Heure: $formattedDate")
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
@@ -912,15 +940,24 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
|
||||||
Text("Scan-Wich Analysis", style = MaterialTheme.typography.headlineMedium)
|
Text("Scan-Wich Analysis", style = MaterialTheme.typography.headlineMedium)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||||
cameraLauncher.launch(null)
|
cameraLauncher.launch(null)
|
||||||
} else {
|
} else {
|
||||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
}
|
}
|
||||||
}) { Icon(Icons.Default.Add, null); Text(" Caméra") }
|
}, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Add, null); Text(" Caméra") }
|
||||||
Button(onClick = { galleryLauncher.launch("image/*") }) { Icon(Icons.Default.Share, null); Text(" Galerie") }
|
|
||||||
|
Button(onClick = {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
showBarcodeScanner = true
|
||||||
|
} else {
|
||||||
|
barcodePermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}, modifier = Modifier.weight(1f)) { Icon(Icons.Default.QrCodeScanner, null); Text(" Barcode") }
|
||||||
|
|
||||||
|
Button(onClick = { galleryLauncher.launch("image/*") }, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Share, null); Text(" Galerie") }
|
||||||
}
|
}
|
||||||
|
|
||||||
capturedBitmap?.let {
|
capturedBitmap?.let {
|
||||||
@@ -937,7 +974,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
Text("Analyse par l\u0027IA en cours...", modifier = Modifier.padding(top = 8.dp))
|
Text("Analyse en cours...", modifier = Modifier.padding(top = 8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,7 +986,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Favorite, null)
|
Icon(Icons.Default.Favorite, null)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Utiliser un Favori (${favoriteMeals.size})")
|
Text("Utiliser un Favori")
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
@@ -960,7 +997,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = manualMealName,
|
value = manualMealName,
|
||||||
onValueChange = { manualMealName = it },
|
onValueChange = { manualMealName = it },
|
||||||
label = { Text("Qu\u0027avez-vous mangé ?") },
|
label = { Text("Qu'avez-vous mangé ?") },
|
||||||
placeholder = { Text("ex: Un sandwich au poulet et une pomme") },
|
placeholder = { Text("ex: Un sandwich au poulet et une pomme") },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
@@ -968,12 +1005,12 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
analyzeImage(null, manualMealName, { isAnalyzing = it }, { data, error ->
|
analyzeImage(null, manualMealName, { isAnalyzing = it }, { data, _ ->
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
currentMealData = data
|
currentMealData = data
|
||||||
showBottomSheet = true
|
showBottomSheet = true
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "Erreur AI: $error", Toast.LENGTH_LONG).show()
|
Toast.makeText(context, "Erreur AI", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}, coroutineScope)
|
}, coroutineScope)
|
||||||
},
|
},
|
||||||
@@ -1019,8 +1056,9 @@ fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) {
|
|||||||
strokeWidth = 6.dp,
|
strokeWidth = 6.dp,
|
||||||
strokeCap = StrokeCap.Round,
|
strokeCap = StrokeCap.Round,
|
||||||
)
|
)
|
||||||
|
val percent = (progress * 100).toInt()
|
||||||
Text(
|
Text(
|
||||||
text = "${(progress * 100).toInt()}%",
|
text = "$percent%",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
@@ -1035,6 +1073,21 @@ fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
||||||
var selectedDate by remember { mutableStateOf(Calendar.getInstance()) }
|
var selectedDate by remember { mutableStateOf(Calendar.getInstance()) }
|
||||||
|
val datesWithData by dao.getAllDatesWithData().collectAsState(initial = emptyList())
|
||||||
|
var showMonthPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Normalize datesWithData to a set of "days since epoch" for fast lookup
|
||||||
|
val normalizedDatesWithData = remember(datesWithData) {
|
||||||
|
datesWithData.map { timestamp ->
|
||||||
|
val cal = Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||||
|
cal.set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
cal.set(Calendar.MINUTE, 0)
|
||||||
|
cal.set(Calendar.SECOND, 0)
|
||||||
|
cal.set(Calendar.MILLISECOND, 0)
|
||||||
|
cal.timeInMillis
|
||||||
|
}.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
val startOfDay = selectedDate.clone() as Calendar
|
val startOfDay = selectedDate.clone() as Calendar
|
||||||
startOfDay.set(Calendar.HOUR_OF_DAY, 0); startOfDay.set(Calendar.MINUTE, 0); startOfDay.set(Calendar.SECOND, 0); startOfDay.set(Calendar.MILLISECOND, 0)
|
startOfDay.set(Calendar.HOUR_OF_DAY, 0); startOfDay.set(Calendar.MINUTE, 0); startOfDay.set(Calendar.SECOND, 0); startOfDay.set(Calendar.MILLISECOND, 0)
|
||||||
val endOfDay = startOfDay.clone() as Calendar
|
val endOfDay = startOfDay.clone() as Calendar
|
||||||
@@ -1095,37 +1148,124 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showMonthPicker) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showMonthPicker = false },
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
val newDate = selectedDate.clone() as Calendar
|
||||||
|
newDate.add(Calendar.MONTH, -1)
|
||||||
|
selectedDate = newDate
|
||||||
|
}) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null) }
|
||||||
|
val monthLabel = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(selectedDate.time)
|
||||||
|
Text(monthLabel)
|
||||||
|
IconButton(onClick = {
|
||||||
|
val newDate = selectedDate.clone() as Calendar
|
||||||
|
newDate.add(Calendar.MONTH, 1)
|
||||||
|
selectedDate = newDate
|
||||||
|
}) { Icon(Icons.AutoMirrored.Filled.ArrowForward, null) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
val daysOfWeek = listOf("L", "M", "M", "J", "V", "S", "D")
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
daysOfWeek.forEach { day ->
|
||||||
|
Text(day, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(32.dp), textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val cal = selectedDate.clone() as Calendar
|
||||||
|
cal.set(Calendar.DAY_OF_MONTH, 1)
|
||||||
|
val firstDayIdx = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7
|
||||||
|
val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
|
||||||
|
|
||||||
|
val gridItems = mutableListOf<Int?>()
|
||||||
|
repeat(firstDayIdx) { gridItems.add(null) }
|
||||||
|
for (i in 1..daysInMonth) { gridItems.add(i) }
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(7),
|
||||||
|
modifier = Modifier.height(250.dp).padding(top = 8.dp)
|
||||||
|
) {
|
||||||
|
items(gridItems) { day ->
|
||||||
|
if (day != null) {
|
||||||
|
val dayCal = selectedDate.clone() as Calendar
|
||||||
|
dayCal.set(Calendar.DAY_OF_MONTH, day)
|
||||||
|
dayCal.set(Calendar.HOUR_OF_DAY, 0); dayCal.set(Calendar.MINUTE, 0); dayCal.set(Calendar.SECOND, 0); dayCal.set(Calendar.MILLISECOND, 0)
|
||||||
|
val hasData = normalizedDatesWithData.contains(dayCal.timeInMillis)
|
||||||
|
val isSelected = day == selectedDate.get(Calendar.DAY_OF_MONTH)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||||
|
.clickable {
|
||||||
|
selectedDate = dayCal
|
||||||
|
showMonthPicker = false
|
||||||
|
}
|
||||||
|
.padding(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = day.toString(),
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
if (hasData) {
|
||||||
|
Box(Modifier.size(4.dp).clip(CircleShape).background(if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.size(40.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { showMonthPicker = false }) { Text("Fermer") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showExportDialog) {
|
if (showExportDialog) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showExportDialog = false },
|
onDismissRequest = { showExportDialog = false },
|
||||||
title = { Text("Exporter l\u0027historique") },
|
title = { Text("Exporter l'historique") },
|
||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
Text("Sélectionnez la plage de dates :")
|
Text("Sélectionnez la plage de dates :")
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
DatePickerDialog(context, { _, y, m, d ->
|
DatePickerDialog(context, { _, y, m, d ->
|
||||||
exportStartDate.set(y, m, d)
|
val newDate = Calendar.getInstance()
|
||||||
exportStartDate = exportStartDate.clone() as Calendar
|
newDate.set(y, m, d)
|
||||||
|
exportStartDate = newDate
|
||||||
}, exportStartDate.get(Calendar.YEAR), exportStartDate.get(Calendar.MONTH), exportStartDate.get(Calendar.DAY_OF_MONTH)).show()
|
}, exportStartDate.get(Calendar.YEAR), exportStartDate.get(Calendar.MONTH), exportStartDate.get(Calendar.DAY_OF_MONTH)).show()
|
||||||
}, modifier = Modifier.fillMaxWidth()) {
|
}, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Du: " + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportStartDate.time))
|
val dateLabel = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportStartDate.time)
|
||||||
|
Text("Du: $dateLabel")
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
DatePickerDialog(context, { _, y, m, d ->
|
DatePickerDialog(context, { _, y, m, d ->
|
||||||
exportEndDate.set(y, m, d)
|
val newDate = Calendar.getInstance()
|
||||||
exportEndDate = exportEndDate.clone() as Calendar
|
newDate.set(y, m, d)
|
||||||
|
exportEndDate = newDate
|
||||||
}, exportEndDate.get(Calendar.YEAR), exportEndDate.get(Calendar.MONTH), exportEndDate.get(Calendar.DAY_OF_MONTH)).show()
|
}, exportEndDate.get(Calendar.YEAR), exportEndDate.get(Calendar.MONTH), exportEndDate.get(Calendar.DAY_OF_MONTH)).show()
|
||||||
}, modifier = Modifier.fillMaxWidth()) {
|
}, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Au: " + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportEndDate.time))
|
val dateLabel = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportEndDate.time)
|
||||||
|
Text("Au: $dateLabel")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
showExportDialog = false
|
showExportDialog = false
|
||||||
val fileName = "ScanWich_Rapport_${SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportStartDate.time)}_au_${SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportEndDate.time)}.pdf"
|
val startLabel = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportStartDate.time)
|
||||||
|
val endLabel = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportEndDate.time)
|
||||||
|
val fileName = "ScanWich_Rapport_${startLabel}_au_${endLabel}.pdf"
|
||||||
createPdfLauncher.launch(fileName)
|
createPdfLauncher.launch(fileName)
|
||||||
}) { Text("Exporter") }
|
}) { Text("Exporter") }
|
||||||
},
|
},
|
||||||
@@ -1136,22 +1276,24 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedMealForDetail != null) {
|
if (selectedMealForDetail != null) {
|
||||||
|
val meal = selectedMealForDetail!!
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { selectedMealForDetail = null },
|
onDismissRequest = { selectedMealForDetail = null },
|
||||||
title = { Text(selectedMealForDetail!!.name) },
|
title = { Text(meal.name) },
|
||||||
text = {
|
text = {
|
||||||
Column(modifier = Modifier
|
Column(modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = 450.dp)
|
.heightIn(max = 450.dp)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
Text("Type: ${selectedMealForDetail!!.type}", fontWeight = FontWeight.Bold)
|
Text("Type: ${meal.type}", fontWeight = FontWeight.Bold)
|
||||||
Text("Calories: ${selectedMealForDetail!!.totalCalories} kcal")
|
Text("Calories: ${meal.totalCalories} kcal")
|
||||||
Text("Macro: G ${selectedMealForDetail!!.carbs}g | P ${selectedMealForDetail!!.protein}g | L ${selectedMealForDetail!!.fat}g")
|
Text("Macro: G ${meal.carbs}g | P ${meal.protein}g | L ${meal.fat}g")
|
||||||
Text("Heure: ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(selectedMealForDetail!!.date))}")
|
val timeLabel = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(meal.date))
|
||||||
|
Text("Heure: $timeLabel")
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("Analyse complète :", style = MaterialTheme.typography.labelMedium)
|
Text("Analyse complète :", style = MaterialTheme.typography.labelMedium)
|
||||||
Text(selectedMealForDetail!!.analysisText)
|
Text(meal.analysisText)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
@@ -1168,17 +1310,18 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
selectedDate = newDate
|
selectedDate = newDate
|
||||||
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, null) }
|
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, null) }
|
||||||
|
|
||||||
Text(
|
Column(modifier = Modifier.weight(1f).clickable { showMonthPicker = true }) {
|
||||||
text = SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()).format(selectedDate.time),
|
Text(
|
||||||
style = MaterialTheme.typography.titleLarge,
|
text = SimpleDateFormat("EEEE d MMMM", Locale.getDefault()).format(selectedDate.time).replaceFirstChar { it.uppercase() },
|
||||||
modifier = Modifier.weight(1f).clickable {
|
style = MaterialTheme.typography.titleLarge,
|
||||||
DatePickerDialog(context, { _, y, m, d ->
|
fontWeight = FontWeight.Bold
|
||||||
val newDate = Calendar.getInstance()
|
)
|
||||||
newDate.set(y, m, d)
|
Text(
|
||||||
selectedDate = newDate
|
text = "Cliquer pour voir le calendrier",
|
||||||
}, selectedDate.get(Calendar.YEAR), selectedDate.get(Calendar.MONTH), selectedDate.get(Calendar.DAY_OF_MONTH)).show()
|
style = MaterialTheme.typography.labelSmall,
|
||||||
}
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
IconButton(onClick = { showExportDialog = true }) {
|
IconButton(onClick = { showExportDialog = true }) {
|
||||||
Icon(Icons.Default.PictureAsPdf, contentDescription = "Export PDF", tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Default.PictureAsPdf, contentDescription = "Export PDF", tint = MaterialTheme.colorScheme.primary)
|
||||||
@@ -1315,7 +1458,7 @@ private fun generatePdfReport(
|
|||||||
val tf = SimpleDateFormat("HH:mm", Locale.getDefault())
|
val tf = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
document.add(Paragraph("Rapport d\u0027Historique Scan-Wich")
|
document.add(Paragraph("Rapport d'Historique Scan-Wich")
|
||||||
.setTextAlignment(TextAlignment.CENTER)
|
.setTextAlignment(TextAlignment.CENTER)
|
||||||
.setFontSize(20f)
|
.setFontSize(20f)
|
||||||
.setBold())
|
.setBold())
|
||||||
@@ -1339,7 +1482,8 @@ private fun generatePdfReport(
|
|||||||
table.addHeaderCell("Lip")
|
table.addHeaderCell("Lip")
|
||||||
|
|
||||||
meals.forEach { meal ->
|
meals.forEach { meal ->
|
||||||
table.addCell(df.format(Date(meal.date)) + " " + tf.format(Date(meal.date)))
|
val dateLabel = df.format(Date(meal.date)) + " " + tf.format(Date(meal.date))
|
||||||
|
table.addCell(dateLabel)
|
||||||
table.addCell(meal.name)
|
table.addCell(meal.name)
|
||||||
table.addCell(meal.type)
|
table.addCell(meal.type)
|
||||||
table.addCell(meal.totalCalories.toString())
|
table.addCell(meal.totalCalories.toString())
|
||||||
@@ -1360,7 +1504,8 @@ private fun generatePdfReport(
|
|||||||
table.addHeaderCell("Valeur (mmol/L)")
|
table.addHeaderCell("Valeur (mmol/L)")
|
||||||
|
|
||||||
glycemia.forEach { gly ->
|
glycemia.forEach { gly ->
|
||||||
table.addCell(df.format(Date(gly.date)) + " " + tf.format(Date(gly.date)))
|
val dateLabel = df.format(Date(gly.date)) + " " + tf.format(Date(gly.date))
|
||||||
|
table.addCell(dateLabel)
|
||||||
table.addCell(gly.moment)
|
table.addCell(gly.moment)
|
||||||
table.addCell(gly.value.toString())
|
table.addCell(gly.value.toString())
|
||||||
}
|
}
|
||||||
@@ -1406,7 +1551,7 @@ private fun getOptimizedImageBase64(bitmap: Bitmap): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val resized = Bitmap.createScaledBitmap(bitmap, width, height, true)
|
val resized = bitmap.scale(width, height, true)
|
||||||
val outputStream = ByteArrayOutputStream()
|
val outputStream = ByteArrayOutputStream()
|
||||||
resized.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
|
resized.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
|
||||||
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
||||||
@@ -1548,10 +1693,12 @@ fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||||
Text(SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)), style = MaterialTheme.typography.bodySmall)
|
val dateLabel = SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date))
|
||||||
|
Text(dateLabel, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
Text("${activity.type} - ${(activity.distance / 1000).format(2)} km")
|
Text("${activity.type} - ${(activity.distance / 1000).format(2)} km")
|
||||||
Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
val calLabel = "${activity.calories?.toInt() ?: 0} kcal brûlées"
|
||||||
|
Text(calLabel, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1565,21 +1712,11 @@ fun Float.format(digits: Int) = "%.${digits}f".format(this)
|
|||||||
fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) {
|
fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) {
|
||||||
var isEditing by remember { mutableStateOf(false) }
|
var isEditing by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var updateErrorInfo by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") }
|
var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") }
|
||||||
var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") }
|
var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") }
|
||||||
val isStravaConnected = prefs.contains("strava_token")
|
val isStravaConnected = prefs.contains("strava_token")
|
||||||
|
|
||||||
if (updateErrorInfo != null) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { updateErrorInfo = null },
|
|
||||||
title = { Text("Détail de l'erreur mise à jour") },
|
|
||||||
text = { Text(updateErrorInfo!!) },
|
|
||||||
confirmButton = { TextButton(onClick = { updateErrorInfo = null }) { Text("OK") } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
SetupScreen(prefs) {
|
SetupScreen(prefs) {
|
||||||
isEditing = false
|
isEditing = false
|
||||||
@@ -1625,9 +1762,7 @@ fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpda
|
|||||||
if (stravaClientId.isBlank() || stravaClientSecret.isBlank()) {
|
if (stravaClientId.isBlank() || stravaClientSecret.isBlank()) {
|
||||||
Toast.makeText(context, "Entrez votre ID et Secret Client", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Entrez votre ID et Secret Client", Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(
|
val intent = Intent(Intent.ACTION_VIEW, "https://www.strava.com/oauth/mobile/authorize?client_id=$stravaClientId&redirect_uri=coloricam://localhost&response_type=code&approval_prompt=auto&scope=read,activity:read_all".toUri())
|
||||||
"https://www.strava.com/oauth/mobile/authorize?client_id=$stravaClientId&redirect_uri=coloricam://localhost&response_type=code&approval_prompt=auto&scope=read,activity:read_all"
|
|
||||||
))
|
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1650,33 +1785,6 @@ fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpda
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
Text("Mises à jour", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
try {
|
|
||||||
FirebaseAppDistribution.getInstance().updateIfNewReleaseAvailable()
|
|
||||||
.addOnSuccessListener {
|
|
||||||
Log.d("AppDistribution", "Check success")
|
|
||||||
Toast.makeText(context, "Vérification en cours...", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
.addOnFailureListener { e ->
|
|
||||||
Log.e("AppDistribution", "Error: ${e.message}")
|
|
||||||
updateErrorInfo = e.message ?: "Erreur inconnue lors de la vérification."
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
updateErrorInfo = "Le SDK Firebase App Distribution n'est pas disponible sur ce type de build (Local/USB)."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary)
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Refresh, null)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Vérifier les mises à jour")
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { Text("Déconnexion") }
|
Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { Text("Déconnexion") }
|
||||||
}
|
}
|
||||||
@@ -1737,7 +1845,8 @@ fun GlycemiaScreen(dao: AppDao) {
|
|||||||
}, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true).show()
|
}, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true).show()
|
||||||
}, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show()
|
}, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show()
|
||||||
}, modifier = Modifier.fillMaxWidth()) {
|
}, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text("Date/Heure: " + SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(selectedDateTime)))
|
val dateLabel = SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(selectedDateTime))
|
||||||
|
Text("Date/Heure: $dateLabel")
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
@@ -1759,3 +1868,73 @@ fun GlycemiaScreen(dao: AppDao) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalGetImage::class)
|
||||||
|
@Composable
|
||||||
|
fun BarcodeScannerDialog(onBarcodeScanned: (String) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = { },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
|
||||||
|
text = {
|
||||||
|
Box(modifier = Modifier.size(300.dp).clip(MaterialTheme.shapes.medium)) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
val previewView = PreviewView(ctx)
|
||||||
|
val executor = ContextCompat.getMainExecutor(ctx)
|
||||||
|
cameraProviderFuture.addListener({
|
||||||
|
val cameraProvider = cameraProviderFuture.get()
|
||||||
|
val preview = Preview.Builder().build().also {
|
||||||
|
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
val barcodeScanner = BarcodeScanning.getClient()
|
||||||
|
val imageAnalysis = ImageAnalysis.Builder()
|
||||||
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||||
|
.build()
|
||||||
|
.also {
|
||||||
|
it.setAnalyzer(executor) { imageProxy ->
|
||||||
|
val mediaImage = imageProxy.image
|
||||||
|
if (mediaImage != null) {
|
||||||
|
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||||
|
barcodeScanner.process(image)
|
||||||
|
.addOnSuccessListener { barcodes ->
|
||||||
|
if (barcodes.isNotEmpty()) {
|
||||||
|
barcodes[0].rawValue?.let { barcode ->
|
||||||
|
onBarcodeScanned(barcode)
|
||||||
|
cameraProvider.unbindAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnCompleteListener { imageProxy.close() }
|
||||||
|
} else {
|
||||||
|
imageProxy.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
cameraProvider.unbindAll()
|
||||||
|
cameraProvider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("BarcodeScanner", "Camera binding failed", e)
|
||||||
|
}
|
||||||
|
}, executor)
|
||||||
|
previewView
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
// Overlay to guide the user
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.border(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ junitVersion = "1.3.0"
|
|||||||
espressoCore = "3.7.0"
|
espressoCore = "3.7.0"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.12.4"
|
activityCompose = "1.12.4"
|
||||||
kotlin = "2.0.21"
|
kotlinComposePlugin = "2.3.10"
|
||||||
composeBom = "2026.02.00"
|
composeBom = "2026.02.00"
|
||||||
generativeai = "0.9.0"
|
generativeai = "0.9.0"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
@@ -22,9 +22,11 @@ playServicesAuth = "21.5.1"
|
|||||||
googleServices = "4.4.4"
|
googleServices = "4.4.4"
|
||||||
firebaseBom = "34.9.0"
|
firebaseBom = "34.9.0"
|
||||||
firebaseAppDistribution = "5.2.1"
|
firebaseAppDistribution = "5.2.1"
|
||||||
firebaseAppDistributionSdk = "16.0.0-beta15"
|
firebaseAppDistributionSdk = "16.0.0-beta17"
|
||||||
securityCrypto = "1.1.0"
|
securityCrypto = "1.1.0"
|
||||||
kotlinxCoroutinesPlayServices = "1.9.0"
|
mlkitBarcodeScanning = "17.3.0"
|
||||||
|
camerax = "1.5.3"
|
||||||
|
itext = "7.2.6"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -59,17 +61,21 @@ firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics
|
|||||||
firebase-appdistribution = { group = "com.google.firebase", name = "firebase-appdistribution", version.ref = "firebaseAppDistributionSdk" }
|
firebase-appdistribution = { group = "com.google.firebase", name = "firebase-appdistribution", version.ref = "firebaseAppDistributionSdk" }
|
||||||
firebase-functions = { group = "com.google.firebase", name = "firebase-functions" }
|
firebase-functions = { group = "com.google.firebase", name = "firebase-functions" }
|
||||||
firebase-auth = { group = "com.google.firebase", name = "firebase-auth" }
|
firebase-auth = { group = "com.google.firebase", name = "firebase-auth" }
|
||||||
firebase-appcheck = { group = "com.google.firebase", name = "firebase-appcheck" }
|
|
||||||
firebase-appcheck-playintegrity = { group = "com.google.firebase", name = "firebase-appcheck-playintegrity" }
|
firebase-appcheck-playintegrity = { group = "com.google.firebase", name = "firebase-appcheck-playintegrity" }
|
||||||
firebase-appcheck-debug = { group = "com.google.firebase", name = "firebase-appcheck-debug" }
|
firebase-appcheck-debug = { group = "com.google.firebase", name = "firebase-appcheck-debug" }
|
||||||
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" }
|
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version = "17.3.0" }
|
||||||
|
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
|
||||||
|
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
|
||||||
|
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
||||||
|
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
||||||
|
|
||||||
# iText Core is needed for PDF generation
|
# iText Core is needed for PDF generation
|
||||||
itext7-core = { group = "com.itextpdf", name = "itext7-core", version = "7.2.5" }
|
itext7-core = { group = "com.itextpdf", name = "itext7-core", version.ref = "itext" }
|
||||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinComposePlugin" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" }
|
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" }
|
||||||
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
|
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
|
||||||
|
|||||||
@@ -1,9 +1,32 @@
|
|||||||
- Renforcement de la sécurité : Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les utilisations non autorisées et sécuriser les appels vers l'IA.
|
📝 Notes de version - Scan-Wich
|
||||||
- Protection des données sensibles : Migration de la clé API vers Google Cloud Secret Manager, garantissant qu'aucune information confidentielle n'est stockée en dur dans le code.
|
|
||||||
- Optimisation majeure de l'analyse : Réduction drastique de la latence réseau grâce à un nouveau moteur de compression d'image (passage de 2.2 Mo à ~150 Ko par scan).
|
Nouveautés et Améliorations :
|
||||||
- Sécurisation de l'IA : Migration de la logique des instructions (prompts) côté serveur (Cloud Functions) pour prévenir les manipulations et garantir des résultats fiables.
|
|
||||||
- Amélioration de l'authentification : Liaison directe entre Google Sign-In et Firebase Auth pour une session utilisateur plus robuste et sécurisée.
|
🛡️ Sécurité renforcée :
|
||||||
- Correction du parsing : Nouveau système de traitement des réponses Cloud Functions pour une meilleure fiabilité de l'affichage des macros.
|
- Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les accès non autorisés.
|
||||||
- Export PDF de l'historique : nouvelle fonctionnalité permettant d'exporter vos repas, suivis de glycémie et activités sportives sous forme de rapport PDF professionnel.
|
- Migration de la clé API vers Google Cloud Secret Manager, supprimant toute information sensible du code source.
|
||||||
- Refonte de l'accès aux favoris : intégration d'un bouton dédié et d'une liste modale pour un écran d'accueil plus clair.
|
|
||||||
- Mise à jour système : Optimisation pour Android 36 et amélioration de la stabilité globale.
|
⚡ Analyse Ultra-Rapide :
|
||||||
|
- Nouveau moteur de compression d'image intelligent (réduction de ~2.2 Mo à 150 Ko par scan), accélérant drastiquement l'analyse IA.
|
||||||
|
|
||||||
|
🤖 IA Sécurisée :
|
||||||
|
- Migration de la logique d'analyse (prompts) vers des Cloud Functions pour garantir des résultats plus fiables et protégés.
|
||||||
|
|
||||||
|
📄 Export PDF Professionnel :
|
||||||
|
- Nouvelle fonctionnalité d'exportation de l'historique. Générez un rapport PDF complet incluant vos repas, activités sportives et suivis de glycémie.
|
||||||
|
|
||||||
|
🩸 Suivi Diabétique complet :
|
||||||
|
- Possibilité d'enregistrer et de visualiser sa glycémie avant/après chaque catégorie de repas directement dans l'historique.
|
||||||
|
|
||||||
|
🚴 Synchronisation Strava :
|
||||||
|
- Connexion directe à Strava pour importer vos activités et calculer précisément les calories brûlées.
|
||||||
|
|
||||||
|
⭐ Gestion des Favoris :
|
||||||
|
- Refonte de l'interface des favoris avec une nouvelle fenêtre modale pour un ajout rapide de vos repas récurrents.
|
||||||
|
|
||||||
|
🔍 Scanner de Code-barres :
|
||||||
|
- Intégration d'Open Food Facts pour identifier instantanément les produits industriels via leur code-barres.
|
||||||
|
|
||||||
|
🔧 Stabilité et Modernisation :
|
||||||
|
- Optimisation pour Android 36.
|
||||||
|
- Correction de bugs majeurs sur le stockage sécurisé des préférences (MasterKey) et la synchronisation Firebase (collectAsState).
|
||||||
Reference in New Issue
Block a user