Compare commits

...

24 Commits

Author SHA1 Message Date
mac
f1379e7cc3 test 2026-03-09 21:29:55 -04:00
2bec3bc681 Merge pull request 'changes' (#8) from dev into master
Reviewed-on: #8
2026-03-09 20:57:17 -04:00
9b87930e9a changes 2026-03-09 20:55:48 -04:00
c9d05be4b1 Merge pull request 'test' (#7) from changes into master
Reviewed-on: #7
2026-03-09 20:26:02 -04:00
mac
73a7f46509 test 2026-03-09 20:25:10 -04:00
7699ebcf2e Merge pull request 'changes' (#6) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/6
2026-02-24 12:57:43 -05:00
mac
c191394ee6 test 2026-02-24 12:46:27 -05:00
mac
93c8814b84 test 2026-02-24 12:04:00 -05:00
mac
1bb637ae62 test 2026-02-24 10:03:35 -05:00
f8c702398f Merge pull request 'changes' (#5) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/5
2026-02-23 21:34:07 -05:00
mac
e9c586adcd test 2026-02-23 21:33:21 -05:00
mac
75300292ec test 2026-02-23 16:47:22 -05:00
f8dfa9af63 Merge pull request 'changes' (#4) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/4
2026-02-23 13:08:45 -05:00
mac
374b773443 test 2026-02-23 13:05:18 -05:00
mac
f76b684d49 test 2026-02-23 13:03:59 -05:00
7c7041b7bc Merge pull request 'changes' (#3) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/3
2026-02-22 21:24:24 -05:00
mac
df1188fc02 test 2026-02-22 21:08:44 -05:00
mac
26330349c6 test 2026-02-22 20:31:01 -05:00
mac
670880d197 test 2026-02-22 20:28:25 -05:00
4712dae04a Merge pull request 'changes' (#2) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/2
2026-02-22 18:24:40 -05:00
mac
1140dcc1fc test 2026-02-22 18:23:51 -05:00
mac
76efbec42c test 2026-02-22 18:22:26 -05:00
c8d9b8a807 Merge pull request 'test' (#1) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/1
2026-02-22 16:51:42 -05:00
mac
b1efe8515a test 2026-02-22 16:50:36 -05:00
62 changed files with 3123 additions and 716 deletions

2
.gitignore vendored
View File

@@ -13,3 +13,5 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
app/firebase-key.json
firebase-key.json

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
scan-wich

View File

@@ -4,14 +4,27 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-02-20T21:41:35.128842100Z"> <DropdownSelection timestamp="2026-03-10T00:31:28.813056900Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\marca\.android\avd\Medium_Phone.avd" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=58051FDCG0038S" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>
<DialogSelection /> <DialogSelection>
<targets>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=57191FDCG0001D" />
</handle>
</Target>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=58051FDCG0038S" />
</handle>
</Target>
</targets>
</DialogSelection>
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

3
.idea/misc.xml generated
View File

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

2
.idea/vcs.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>

View File

@@ -1,31 +1,90 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.secrets) alias(libs.plugins.secrets)
alias(libs.plugins.google.services)
alias(libs.plugins.firebase.appdistribution)
} }
android { android {
namespace = "com.example.coloricam" namespace = "com.example.scanwich"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.example.coloricam" applicationId = "com.example.scanwich"
minSdk = 24 minSdk = 24
targetSdk = 35 targetSdk = 36
versionCode = 1
versionName = "1.0" // Incrémentation automatique du versionCode basé sur le temps
versionCode = (System.currentTimeMillis() / 60000).toInt()
versionName = "1.0." + (System.currentTimeMillis() / 3600000).toString().takeLast(3)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs {
// Chargement des propriétés depuis local.properties
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("local.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
getByName("debug") {
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
getByName("debug").storePassword = "android"
getByName("debug").keyAlias = "androiddebugkey"
getByName("debug").keyPassword = "android"
}
create("release") {
// Utilisation d'un chemin relatif au dossier Utilisateur pour fonctionner sur tous les PC
storeFile = file("${System.getProperty("user.home")}/keys/keys")
storePassword = keystoreProperties.getProperty("RELEASE_STORE_PASSWORD")
keyAlias = keystoreProperties.getProperty("RELEASE_KEY_ALIAS") ?: "key0"
keyPassword = keystoreProperties.getProperty("RELEASE_KEY_PASSWORD")
}
}
val keyFile = project.file("firebase-key.json")
val releaseNotesFile = rootProject.file("release-notes.txt")
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true // Activer l'offuscation
signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
configure<com.google.firebase.appdistribution.gradle.AppDistributionExtension> {
artifactType = "APK"
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"
}
} }
} }
compileOptions { compileOptions {
@@ -39,7 +98,6 @@ android {
} }
secrets { secrets {
// A list of keys that should be ignored by the plugin by default.
ignoreList.add("properties") ignoreList.add("properties")
} }
@@ -52,26 +110,46 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
// SDK Firebase App Distribution COMPLET (API + Implémentation)
implementation(libs.firebase.appdistribution)
implementation(libs.google.generativeai) implementation(libs.google.generativeai)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.androidx.exifinterface) implementation(libs.androidx.exifinterface)
// Navigation
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
// Room
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
// Network & Strava Auth
implementation(libs.retrofit.core) implementation(libs.retrofit.core)
implementation(libs.retrofit.gson) implementation(libs.retrofit.gson)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
// Google Sign-In
implementation(libs.play.services.auth) 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.firestore)
implementation(libs.firebase.appcheck.playintegrity)
// On met le debug provider en implementation pour qu'il soit disponible à la compilation en Release
// (le code MainActivity utilise un check BuildConfig.DEBUG pour ne pas l'utiliser en prod)
implementation(libs.firebase.appcheck.debug)
// 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
implementation(libs.itext7.core)
// Security
implementation(libs.androidx.security.crypto)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)

63
app/google-services.json Normal file
View File

@@ -0,0 +1,63 @@
{
"project_info": {
"project_number": "652626507041",
"project_id": "scan-wich",
"storage_bucket": "scan-wich.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:652626507041:android:e7a106785fe96df5137a60",
"android_client_info": {
"package_name": "com.example.scanwich"
}
},
"oauth_client": [
{
"client_id": "652626507041-1uesa4utc6r27gc3o10r4o2h8dhld7c2.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.example.scanwich",
"certificate_hash": "2c39e4131dcac8a1d4257b804718ac113f855b04"
}
},
{
"client_id": "652626507041-i6ne7rt1b711gfpbc5f5hd1q60kd0ntv.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.example.scanwich",
"certificate_hash": "ebcc060f9a1fdeb1186536d3828574b42cefa03c"
}
},
{
"client_id": "652626507041-i928hstoseh72dta5d0lokm9c55tma2p.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.example.scanwich",
"certificate_hash": "6f363d957ca44b3ca607c29f58f575d0ae71571d"
}
},
{
"client_id": "652626507041-5n42q37adh1guuv9gibfcf5uvekgunbe.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCHa7wOT-HR0RUQ-U_F0RE7ugY-1NpLPsM"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "652626507041-5n42q37adh1guuv9gibfcf5uvekgunbe.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View File

@@ -1,21 +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
# --- FIREBASE / GOOGLE ---
# 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.**
-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 { *; }

Binary file not shown.

View File

@@ -4,7 +4,7 @@
"type": "APK", "type": "APK",
"kind": "Directory" "kind": "Directory"
}, },
"applicationId": "com.example.coloricam", "applicationId": "com.example.scanwich",
"variantName": "release", "variantName": "release",
"elements": [ "elements": [
{ {

View File

@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -14,6 +17,17 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Coloricam"> android:theme="@style/Theme.Coloricam">
<receiver
android:name=".MealReminderReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

View File

@@ -1,64 +0,0 @@
package com.example.coloricam
import android.content.Context
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "meals")
data class Meal(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val date: Long,
val name: String = "Repas",
val analysisText: String,
val totalCalories: Int,
val type: String = "Collation"
)
@Entity(tableName = "glycemia")
data class Glycemia(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val date: Long,
val value: Double,
val moment: String
)
@Entity(tableName = "sports")
data class SportActivity(
@PrimaryKey val id: Long,
val name: String,
val type: String,
val distance: Float,
val movingTime: Int,
val calories: Float?,
val date: Long // timestamp
)
@Dao
interface AppDao {
@Insert suspend fun insertMeal(meal: Meal): Long
@Delete suspend fun deleteMeal(meal: Meal)
@Query("SELECT * FROM meals ORDER BY date DESC") fun getAllMeals(): Flow<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>>
@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>>
}
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class], version = 5)
abstract class AppDatabase : RoomDatabase() {
abstract fun appDao(): AppDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
.fallbackToDestructiveMigration()
.build().also { INSTANCE = it }
}
}
}

View File

@@ -1,381 +0,0 @@
package com.example.coloricam
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.exifinterface.media.ExifInterface
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.coloricam.ui.theme.ColoricamTheme
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 kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import java.io.ByteArrayOutputStream
import org.json.JSONObject
import org.json.JSONArray
// --- API MODELS ---
data class N8nMealRequest(
val imageBase64: String?,
val mealName: String?,
val prompt: String
)
interface N8nApi {
@POST("webhook/v1/gemini-proxy")
suspend fun analyzeMeal(
@Header("X-API-KEY") apiKey: String,
@Body request: N8nMealRequest
): ResponseBody
}
// --- STRAVA API ---
data class StravaActivity(
val id: Long,
val name: String,
val type: String,
val distance: Float,
val moving_time: Int,
val elapsed_time: Int,
val calories: Float?,
val start_date: String,
val start_date_local: String
)
data class StravaTokenResponse(
val access_token: String,
val refresh_token: String,
val expires_at: Long
)
interface StravaApi {
@GET("athlete/activities")
suspend fun getActivities(
@Header("Authorization") token: String,
@Query("before") before: Long? = null,
@Query("after") after: Long? = null,
@Query("page") page: Int? = null,
@Query("per_page") perPage: Int? = 30
): List<StravaActivity>
@POST("oauth/token")
suspend fun exchangeToken(
@Query("client_id") clientId: String,
@Query("client_secret") clientSecret: String,
@Query("code") code: String,
@Query("grant_type") grantType: String = "authorization_code"
): StravaTokenResponse
@POST("oauth/token")
suspend fun refreshToken(
@Query("client_id") clientId: String,
@Query("client_secret") clientSecret: String,
@Query("refresh_token") refreshToken: String,
@Query("grant_type") grantType: String = "refresh_token"
): StravaTokenResponse
}
// Helpers
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()
.baseUrl("https://www.strava.com/api/v3/")
.addConverterFactory(GsonConverterFactory.create())
.build()
private val retrofitN8n = Retrofit.Builder()
.baseUrl("https://n8n.marquis1987.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val stravaApi: StravaApi = retrofitStrava.create(StravaApi::class.java)
val n8nApi: N8nApi = retrofitN8n.create(N8nApi::class.java)
suspend fun getValidStravaToken(prefs: SharedPreferences): String? {
val stravaToken = prefs.getString("strava_token", null) ?: return null
val expiresAt = prefs.getLong("strava_expires_at", 0)
val refreshToken = prefs.getString("strava_refresh_token", null)
val clientId = prefs.getString("strava_client_id", "") ?: ""
val clientSecret = prefs.getString("strava_client_secret", "") ?: ""
val currentTime = System.currentTimeMillis() / 1000
if (currentTime >= expiresAt && refreshToken != null && clientId.isNotBlank()) {
try {
val refreshResponse = stravaApi.refreshToken(clientId, clientSecret, refreshToken)
prefs.edit()
.putString("strava_token", refreshResponse.access_token)
.putString("strava_refresh_token", refreshResponse.refresh_token)
.putLong("strava_expires_at", refreshResponse.expires_at)
.apply()
return refreshResponse.access_token
} catch (e: Exception) {
return null
}
}
return stravaToken
}
fun parseStravaDate(dateStr: String): Long {
return try {
val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
inputFormat.timeZone = TimeZone.getTimeZone("UTC")
inputFormat.parse(dateStr)?.time ?: 0L
} catch (e: Exception) { 0L }
}
fun estimateCaloriesFromDb(activity: SportActivity, weightKg: Double): Int {
if (activity.calories != null && activity.calories > 0) return activity.calories.toInt()
val met = when (activity.type.lowercase()) {
"run" -> 10.0
"ride" -> 8.0
"walk" -> 3.5
"hike" -> 6.0
"swim" -> 7.0
"weighttraining" -> 5.0
"workout" -> 4.5
else -> 5.0
}
val durationHours = activity.movingTime / 3600.0
return (met * weightKg * durationHours).toInt()
}
}
// --- UI COMPONENTS ---
class MainActivity : ComponentActivity() {
private lateinit var dao: AppDao
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
dao = AppDatabase.getDatabase(this).appDao()
handleStravaCallback(intent)
setContent {
ColoricamTheme {
AuthWrapper(dao)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleStravaCallback(intent)
}
private fun handleStravaCallback(intent: Intent) {
val data: Uri? = intent.data
if (data != null && data.toString().startsWith("coloricam://localhost")) {
val code = data.getQueryParameter("code")
if (code != null) {
val prefs = getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("strava_code", code).apply()
}
}
}
}
@Composable
fun AuthWrapper(dao: AppDao) {
val context = LocalContext.current
val gso = remember {
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.build()
}
val googleSignInClient = remember { GoogleSignIn.getClient(context, gso) }
var account by remember { mutableStateOf(GoogleSignIn.getLastSignedInAccount(context)) }
// Whitelist
val allowedEmails = listOf("marcandre.charest@gmail.com")
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try {
account = task.getResult(ApiException::class.java)
} catch (e: ApiException) {
Log.e("Auth", "signInResult:failed code=" + e.statusCode)
}
}
val onLogout: () -> Unit = {
googleSignInClient.signOut().addOnCompleteListener {
account = null
}
}
if (account == null) {
LoginScreen { launcher.launch(googleSignInClient.signInIntent) }
} else {
if (allowedEmails.contains(account?.email)) {
MainApp(dao, onLogout)
} else {
AccessDeniedScreen(onLogout)
}
}
}
@Composable
fun LoginScreen(onLoginClick: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Coloricam", style = MaterialTheme.typography.displayLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(32.dp))
Button(onClick = onLoginClick, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Icon(Icons.Default.AccountCircle, null)
Spacer(Modifier.width(8.dp))
Text("Se connecter avec Google")
}
}
}
@Composable
fun AccessDeniedScreen(onLogout: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Warning, null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(16.dp))
Text("Accès Refusé", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
Text("Votre compte n'est pas autorisé à utiliser cette application.", style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(32.dp))
Button(onClick = onLogout) { Text("Changer de compte") }
}
}
@Composable
fun MainApp(dao: AppDao, onLogout: () -> Unit) {
val context = LocalContext.current
val prefs = remember { context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE) }
var showSetup by remember { mutableStateOf(!prefs.contains("target_calories")) }
var isDiabetic by remember { mutableStateOf(prefs.getBoolean("is_diabetic", false)) }
if (showSetup) {
SetupScreen(prefs) {
showSetup = false
isDiabetic = prefs.getBoolean("is_diabetic", false)
}
} else {
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(icon = { Icon(Icons.Default.Home, "Repas") }, label = { Text("Repas") }, selected = false, onClick = { navController.navigate("capture") })
NavigationBarItem(icon = { Icon(Icons.Default.Add, "Sport") }, label = { Text("Sport") }, selected = false, onClick = { navController.navigate("sport") })
NavigationBarItem(icon = { Icon(Icons.Default.Settings, "Paramètres") }, label = { Text("Paramètres") }, selected = false, onClick = { navController.navigate("settings") })
}
}
) { innerPadding ->
NavHost(navController, "capture", Modifier.padding(innerPadding)) {
composable("capture") { CaptureScreen(dao, prefs, isDiabetic) }
composable("sport") { SportScreen(dao, prefs) }
composable("settings") { SettingsScreen(prefs, onLogout) }
}
}
}
}
@Composable
fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
var targetCalories by remember { mutableStateOf("2000") }
var targetCarbs by remember { mutableStateOf("250") }
var weightKg by remember { mutableStateOf("70") }
var isDiabetic by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Configuration initiale", style = MaterialTheme.typography.headlineLarge)
Spacer(Modifier.height(24.dp))
OutlinedTextField(value = targetCalories, onValueChange = { targetCalories = it }, label = { Text("Objectif Calories / jour") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(16.dp))
OutlinedTextField(value = targetCarbs, onValueChange = { targetCarbs = it }, label = { Text("Objectif Glucides / jour (g)") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(16.dp))
OutlinedTextField(value = weightKg, onValueChange = { weightKg = it }, label = { Text("Poids (kg)") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(24.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(checked = isDiabetic, onCheckedChange = { isDiabetic = it })
Text("Je suis diabétique (ajoute l'estimation d'insuline)")
}
Spacer(Modifier.height(32.dp))
Button(onClick = {
prefs.edit()
.putString("target_calories", targetCalories)
.putString("target_carbs", targetCarbs)
.putString("weight_kg", weightKg)
.putBoolean("is_diabetic", isDiabetic)
.apply()
onComplete()
}, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("Commencer")
}
}
}

View File

@@ -1,11 +0,0 @@
package com.example.coloricam.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -0,0 +1,29 @@
package com.example.scanwich
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun AccessDeniedScreen(onLogout: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Warning, null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(16.dp))
Text("Accès Refusé", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
Text("Votre compte n'est pas autorisé à utiliser cette application.", style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(32.dp))
Button(onClick = onLogout) { Text("Changer de compte") }
}
}

View File

@@ -0,0 +1,156 @@
package com.example.scanwich
import android.content.Context
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
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.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.GoogleAuthProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import android.widget.Toast
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.example.scanwich.LoginScreen
import com.example.scanwich.MainApp
import com.example.scanwich.AccessDeniedScreen
import com.google.firebase.firestore.FirebaseFirestore
@Composable
@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()
.requestIdToken("652626507041-5n42q37adh1guuv9gibfcf5uvekgunbe.apps.googleusercontent.com")
.build()
}
val googleSignInClient = remember { GoogleSignIn.getClient(context, gso) }
var firebaseUser by remember { mutableStateOf<FirebaseUser?>(auth.currentUser) }
var isAuthorized by remember { mutableStateOf<Boolean?>(null) }
// État pour savoir si la synchro initiale est terminée
var syncCompleted by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try {
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
} 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) {
10 -> "Erreur 10 : SHA-1 non reconnu."
7 -> "Erreur 7 : Réseau."
else -> "Erreur Google (Code ${e.statusCode})."
}
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
}
}
val onLogout: () -> Unit = {
auth.signOut()
googleSignInClient.signOut().addOnCompleteListener {
firebaseUser = null
isAuthorized = null
syncCompleted = false
}
}
LaunchedEffect(firebaseUser) {
isAuthorized = null
val email = firebaseUser?.email?.trim()?.lowercase()
if (email != null && email.isNotEmpty()) {
try {
val db = FirebaseFirestore.getInstance("scan-wich")
val docRef = db.collection("authorized_users").document(email)
val document = docRef.get().await()
if (document.exists()) {
Log.d("Auth", "Accès autorisé. Lancement de la synchronisation...")
val prefs = ApiClient.getEncryptedPrefs(context)
// On attend que la synchro soit finie pour afficher l'app
FirebaseUtils.fetchAllDataFromFirestore(dao, prefs)
syncCompleted = true
isAuthorized = true
} else {
isAuthorized = false
}
} catch (e: Exception) {
Log.e("Auth", "Erreur critique Firestore.", e)
isAuthorized = false
}
} else if (firebaseUser != null) {
isAuthorized = false
}
}
if (firebaseUser == null) {
LoginScreen { launcher.launch(googleSignInClient.signInIntent) }
} else {
when (isAuthorized) {
true -> {
if (syncCompleted) {
MainApp(dao = dao, onLogout = onLogout, userId = firebaseUser!!.uid)
} else {
LoadingBox("Synchronisation de votre profil...")
}
}
false -> AccessDeniedScreen(onLogout)
null -> LoadingBox("Vérification de l'accès...")
}
}
}
@Composable
fun LoadingBox(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(text)
}
}
}

View File

@@ -0,0 +1,96 @@
package com.example.scanwich
import android.util.Log
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
@androidx.annotation.OptIn(androidx.camera.core.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()
)
Box(
modifier = Modifier
.fillMaxSize()
.border(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
)
}
}
)
}

View File

@@ -0,0 +1,449 @@
package com.example.scanwich
import android.Manifest
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
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.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
import com.example.scanwich.FirebaseUtils.syncMealToFirestore
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CaptureScreen(dao: AppDao) {
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
var capturedBitmap by remember { mutableStateOf<Bitmap?>(null) }
var isAnalyzing by remember { mutableStateOf(false) }
var currentMealData by remember { mutableStateOf<Triple<String, String, List<Int>>?>(null) }
var mealDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var showBottomSheet by remember { mutableStateOf(false) }
var showBarcodeScanner by remember { mutableStateOf(false) }
var manualMealName by remember { mutableStateOf("") }
var showFavoritesSheet by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
if (bitmap != null) {
capturedBitmap = bitmap
mealDateTime = System.currentTimeMillis()
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, _ ->
if (data != null) {
currentMealData = data
showBottomSheet = true
} else {
Toast.makeText(context, "Analyse échouée", Toast.LENGTH_LONG).show()
}
}, coroutineScope)
}
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) cameraLauncher.launch(null)
else Toast.makeText(context, "Permission caméra requise", Toast.LENGTH_SHORT).show()
}
val barcodePermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
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 ->
uri?.let {
try {
val inputStream = context.contentResolver.openInputStream(it)
val bitmap = BitmapFactory.decodeStream(inputStream)
capturedBitmap = bitmap
val exifStream = context.contentResolver.openInputStream(it)
if (exifStream != null) {
val exif = ExifInterface(exifStream)
val dateStr = exif.getAttribute(ExifInterface.TAG_DATETIME)
mealDateTime = if (dateStr != null) {
SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.getDefault()).parse(dateStr)?.time ?: System.currentTimeMillis()
} else {
System.currentTimeMillis()
}
exifStream.close()
}
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, _ ->
if (data != null) {
currentMealData = data
showBottomSheet = true
} else {
Toast.makeText(context, "L'IA n'a pas pu identifier le repas.", Toast.LENGTH_LONG).show()
}
}, coroutineScope)
} catch (e: Exception) {
Toast.makeText(context, "Erreur lors du chargement : ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
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) {
ModalBottomSheet(
onDismissRequest = { showFavoritesSheet = false },
containerColor = MaterialTheme.colorScheme.surface
) {
val favMeals by dao.getAllFavorites().collectAsState(initial = emptyList())
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 (favMeals.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Aucun favori enregistré", color = Color.Gray)
}
} else {
LazyColumn {
items(favMeals) { 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 },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxHeight(0.85f)
) {
var mealType by remember { mutableStateOf("Déjeuner") }
val calendar = Calendar.getInstance().apply { timeInMillis = mealDateTime }
var editableName by remember { mutableStateOf(currentMealData!!.first) }
var editableDesc by remember { mutableStateOf(currentMealData!!.second) }
val mealValues = currentMealData!!.third
val editableCalories = mealValues[0].toString()
val editableCarbs = mealValues[1].toString()
val editableProtein = mealValues[2].toString()
val editableFat = mealValues[3].toString()
LaunchedEffect(currentMealData) {
editableName = currentMealData!!.first
editableDesc = currentMealData!!.second
}
Column(modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp)
.verticalScroll(rememberScrollState())
) {
Text("Résumé du repas", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = editableName,
onValueChange = { editableName = it },
label = { Text("Nom du repas") },
modifier = Modifier.fillMaxWidth()
)
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = editableCalories, onValueChange = { }, label = { Text("Cal") }, modifier = Modifier.weight(1f), readOnly = true)
OutlinedTextField(value = editableCarbs, onValueChange = { }, label = { Text("Glu") }, modifier = Modifier.weight(1f), readOnly = true)
OutlinedTextField(value = editableProtein, onValueChange = { }, label = { Text("Pro") }, modifier = Modifier.weight(1f), readOnly = true)
OutlinedTextField(value = editableFat, onValueChange = { }, label = { Text("Lip") }, modifier = Modifier.weight(1f), readOnly = true)
}
OutlinedTextField(
value = editableDesc,
onValueChange = { editableDesc = it },
label = { Text("Description / Précisions pour l'IA") },
modifier = Modifier.fillMaxWidth(),
minLines = 3
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, _ ->
if (data != null) {
currentMealData = data
} else {
Toast.makeText(context, "Analyse échouée", 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()
}
},
modifier = Modifier.weight(1f),
enabled = !isAnalyzing
) {
Icon(Icons.Default.Favorite, null)
Spacer(Modifier.width(4.dp))
Text("Favori")
}
}
Spacer(Modifier.height(16.dp))
Text("Catégorie :", fontWeight = FontWeight.Bold)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
listOf("Déjeuner", "Dîner", "Souper", "Collation").forEach { type ->
FilterChip(selected = mealType == type, onClick = { mealType = type }, label = { Text(type) })
}
}
Spacer(Modifier.height(16.dp))
Button(onClick = {
DatePickerDialog(context, { _, y, m, d ->
calendar.set(y, m, d)
TimePickerDialog(context, { _, hh, mm ->
calendar.set(Calendar.HOUR_OF_DAY, hh)
calendar.set(Calendar.MINUTE, mm)
mealDateTime = calendar.timeInMillis
}, 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()
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) {
Icon(Icons.Default.DateRange, null)
Spacer(Modifier.width(8.dp))
val formattedDate = SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(mealDateTime))
Text("Date/Heure: $formattedDate")
}
Spacer(Modifier.height(24.dp))
Button(
onClick = {
coroutineScope.launch {
val meal = Meal(
date = mealDateTime,
name = editableName,
analysisText = editableDesc,
totalCalories = editableCalories.toIntOrNull() ?: 0,
carbs = editableCarbs.toIntOrNull() ?: 0,
protein = editableProtein.toIntOrNull() ?: 0,
fat = editableFat.toIntOrNull() ?: 0,
type = mealType
)
dao.insertMeal(meal)
syncMealToFirestore(meal) // Firestore Sync
showBottomSheet = false
capturedBitmap = null
Toast.makeText(context, "Repas enregistré !", Toast.LENGTH_SHORT).show()
}
},
modifier = Modifier.fillMaxWidth().height(56.dp),
enabled = !isAnalyzing
) {
Icon(Icons.Default.Check, null)
Spacer(Modifier.width(8.dp))
Text("Confirmer et Enregistrer")
}
}
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
Text("Scan-Wich Analysis", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(null)
} else {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Add, null); Text(" Caméra") }
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 {
Spacer(Modifier.height(16.dp))
Text("Image sélectionnée :", style = MaterialTheme.typography.labelMedium)
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(250.dp).clip(MaterialTheme.shapes.medium).background(Color.Gray)
)
}
if (isAnalyzing) {
Spacer(Modifier.height(32.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
CircularProgressIndicator()
Text("Analyse en cours...", modifier = Modifier.padding(top = 8.dp))
}
}
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")
}
Spacer(Modifier.height(32.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
Text("Analyse par texte", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = manualMealName,
onValueChange = { manualMealName = it },
label = { Text("Qu'avez-vous mangé ?") },
placeholder = { Text("ex: Un sandwich au poulet et une pomme") },
modifier = Modifier.fillMaxWidth()
)
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
analyzeImage(null, manualMealName, { isAnalyzing = it }, { data, _ ->
if (data != null) {
currentMealData = data
showBottomSheet = true
} else {
Toast.makeText(context, "Erreur IA", Toast.LENGTH_LONG).show()
}
}, coroutineScope)
},
enabled = manualMealName.isNotBlank() && !isAnalyzing,
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Refresh, null)
Spacer(Modifier.width(4.dp))
Text("Analyser via IA")
}
OutlinedButton(
onClick = {
currentMealData = Triple(manualMealName, "Ajout manuel", listOf(0, 0, 0, 0))
mealDateTime = System.currentTimeMillis()
showBottomSheet = true
},
enabled = manualMealName.isNotBlank() && !isAnalyzing,
modifier = Modifier.weight(1f)
) {
Text("Direct (0 kcal)")
}
}
}
}

View File

@@ -0,0 +1,101 @@
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")
data class Meal(
@PrimaryKey val date: Long = 0, // La date devient la clé unique pour éviter les doublons
val name: String = "Repas",
val analysisText: String = "",
val totalCalories: Int = 0,
val carbs: Int = 0,
val protein: Int = 0,
val fat: Int = 0,
val type: String = "Collation",
val id: Int = 0 // On garde le champ id pour la compatibilité mais il n'est plus PrimaryKey
)
@Entity(tableName = "glycemia")
data class Glycemia(
@PrimaryKey val date: Long = 0, // La date devient la clé unique
val value: Double = 0.0,
val moment: String = "",
val id: Int = 0
)
@Entity(tableName = "sports")
data class SportActivity(
@PrimaryKey val id: Long = 0, // Strava fournit déjà un ID unique
val name: String = "",
val type: String = "",
val distance: Float = 0f,
val movingTime: Int = 0,
val calories: Float? = null,
val date: Long = 0
)
@Entity(tableName = "favorite_meals")
data class FavoriteMeal(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String = "",
val analysisText: String = "",
val calories: Int = 0,
val carbs: Int = 0,
val protein: Int = 0,
val fat: Int = 0
)
@Dao
interface AppDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMeal(meal: Meal): Long
@Delete suspend fun deleteMeal(meal: 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(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGlycemia(glycemia: Glycemia): Long
@Delete suspend fun deleteGlycemia(glycemia: 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)
@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 = 8) // Version incrémentée
abstract class AppDatabase : RoomDatabase() {
abstract fun appDao(): AppDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
.fallbackToDestructiveMigration() // Ceci va vider la base locale une seule fois pour appliquer le changement
.build().also { INSTANCE = it }
}
}
}

View File

@@ -0,0 +1,109 @@
package com.example.scanwich
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.tasks.await
object FirebaseUtils {
private fun getDb(): FirebaseFirestore {
return try {
FirebaseFirestore.getInstance("scan-wich")
} catch (e: Exception) {
FirebaseFirestore.getInstance()
}
}
fun syncMealToFirestore(meal: Meal) {
val user = FirebaseAuth.getInstance().currentUser
if (user != null) {
getDb().collection("users").document(user.uid).collection("meals")
.document(meal.date.toString())
.set(meal, SetOptions.merge())
.addOnSuccessListener { Log.d("Firestore", "Meal synced") }
.addOnFailureListener { e -> Log.e("Firestore", "Error meal: ${e.message}") }
}
}
fun syncGlycemiaToFirestore(glycemia: Glycemia) {
val user = FirebaseAuth.getInstance().currentUser
if (user != null) {
getDb().collection("users").document(user.uid).collection("glycemia")
.document(glycemia.date.toString())
.set(glycemia, SetOptions.merge())
.addOnSuccessListener { Log.d("Firestore", "Glycemia synced") }
.addOnFailureListener { e -> Log.e("Firestore", "Error glycemia: ${e.message}") }
}
}
fun syncSportToFirestore(sport: SportActivity) {
val user = FirebaseAuth.getInstance().currentUser
if (user != null) {
getDb().collection("users").document(user.uid).collection("sports")
.document(sport.id.toString())
.set(sport, SetOptions.merge())
.addOnSuccessListener { Log.d("Firestore", "Sport synced") }
.addOnFailureListener { e -> Log.e("Firestore", "Error sport: ${e.message}") }
}
}
suspend fun fetchAllDataFromFirestore(dao: AppDao, prefs: SharedPreferences) {
val user = FirebaseAuth.getInstance().currentUser ?: return
val db = getDb()
val userDoc = db.collection("users").document(user.uid)
try {
Log.d("FirestoreSync", "Début de la récupération pour l'UID: ${user.uid}")
// 1. Profil
val profileSnapshot = userDoc.get().await()
if (profileSnapshot.exists()) {
prefs.edit {
profileSnapshot.get("target_calories")?.let { putString("target_calories", it.toString()) }
profileSnapshot.get("target_carbs")?.let { putString("target_carbs", it.toString()) }
profileSnapshot.get("target_protein")?.let { putString("target_protein", it.toString()) }
profileSnapshot.get("target_fat")?.let { putString("target_fat", it.toString()) }
profileSnapshot.get("weight_kg")?.let { putString("weight_kg", it.toString()) }
profileSnapshot.getBoolean("is_lbs")?.let { putBoolean("is_lbs", it) }
profileSnapshot.get("height_cm")?.let { putString("height_cm", it.toString()) }
profileSnapshot.getBoolean("is_diabetic")?.let { putBoolean("is_diabetic", it) }
(profileSnapshot.get("age") as? Number)?.let { putInt("age", it.toInt()) }
profileSnapshot.getString("gender")?.let { putString("gender", it) }
profileSnapshot.getString("activity_level")?.let { putString("activity_level", it) }
profileSnapshot.getString("goal")?.let { putString("goal", it) }
}
Log.d("FirestoreSync", "Profil récupéré avec succès")
}
// 2. Repas
val mealsSnapshot = userDoc.collection("meals").get().await()
if (!mealsSnapshot.isEmpty) {
val meals = mealsSnapshot.toObjects(Meal::class.java)
meals.forEach { dao.insertMeal(it) }
Log.d("FirestoreSync", "${meals.size} repas insérés dans la base locale")
}
// 3. Glycémie
val glycemiaSnapshot = userDoc.collection("glycemia").get().await()
if (!glycemiaSnapshot.isEmpty) {
val glycemia = glycemiaSnapshot.toObjects(Glycemia::class.java)
glycemia.forEach { dao.insertGlycemia(it) }
Log.d("FirestoreSync", "${glycemia.size} relevés de glycémie insérés")
}
// 4. Sport
val sportsSnapshot = userDoc.collection("sports").get().await()
if (!sportsSnapshot.isEmpty) {
val sports = sportsSnapshot.toObjects(SportActivity::class.java)
dao.insertSports(sports)
Log.d("FirestoreSync", "${sports.size} activités sportives insérées")
}
} catch (e: Exception) {
Log.e("FirestoreSync", "ERREUR CRITIQUE lors du rapatriement: ${e.message}", e)
}
}
}

View File

@@ -0,0 +1,93 @@
package com.example.scanwich
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.example.scanwich.FirebaseUtils.syncGlycemiaToFirestore
import java.text.SimpleDateFormat
import java.util.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.platform.LocalContext
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.widget.Toast
import kotlinx.coroutines.launch
@Composable
fun GlycemiaScreen(dao: AppDao) {
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
var glycemiaValue by remember { mutableStateOf("") }
var moment by remember { mutableStateOf("Avant Déjeuner") }
var selectedDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
val calendar = Calendar.getInstance().apply { timeInMillis = selectedDateTime }
val moments = listOf(
"Avant Déjeuner", "Après Déjeuner",
"Avant Dîner", "Après Dîner",
"Avant Souper", "Après Souper"
)
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
Text("Suivi de Glycémie", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = glycemiaValue,
onValueChange = { glycemiaValue = it },
label = { Text("Valeur (mmol/L)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
Text("Moment :", style = MaterialTheme.typography.titleMedium)
moments.forEach { m ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { moment = m }) {
RadioButton(selected = moment == m, onClick = { moment = m })
Text(m)
}
}
Spacer(Modifier.height(16.dp))
Button(onClick = {
DatePickerDialog(context, { _, y, m, d ->
calendar.set(y, m, d)
TimePickerDialog(context, { _, hh, mm ->
calendar.set(Calendar.HOUR_OF_DAY, hh)
calendar.set(Calendar.MINUTE, mm)
selectedDateTime = calendar.timeInMillis
}, 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()
}, modifier = Modifier.fillMaxWidth()) {
Text("Date/Heure: " + SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(selectedDateTime)))
}
Spacer(Modifier.height(24.dp))
Button(
onClick = {
val value = glycemiaValue.toDoubleOrNull()
if (value != null) {
coroutineScope.launch {
val glycemia = Glycemia(date = selectedDateTime, value = value, moment = moment)
dao.insertGlycemia(glycemia)
syncGlycemiaToFirestore(glycemia) // Firestore Sync
glycemiaValue = ""
Toast.makeText(context, "Glycémie enregistrée !", Toast.LENGTH_SHORT).show()
}
}
},
enabled = glycemiaValue.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Text("Enregistrer")
}
}
}

View File

@@ -0,0 +1,543 @@
package com.example.scanwich
import android.app.DatePickerDialog
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.scanwich.ui.theme.ReadableAmber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) {
val progress = if (target > 0) current.toFloat() / target else 0f
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(80.dp)) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(60.dp)) {
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.fillMaxSize(),
color = color.copy(alpha = 0.2f),
strokeWidth = 6.dp,
strokeCap = StrokeCap.Round,
)
CircularProgressIndicator(
progress = { progress.coerceAtMost(1f) },
modifier = Modifier.fillMaxSize(),
color = color,
strokeWidth = 6.dp,
strokeCap = StrokeCap.Round,
)
Text(
text = "${(progress * 100).toInt()}%",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(4.dp))
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = "$current / $target", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
var selectedDate by remember { mutableStateOf(Calendar.getInstance()) }
val datesWithData by dao.getAllDatesWithData().collectAsState(initial = emptyList())
var showMonthPicker by remember { mutableStateOf(false) }
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
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
endOfDay.add(Calendar.DAY_OF_MONTH, 1)
val meals by dao.getMealsForDay(startOfDay.timeInMillis, endOfDay.timeInMillis).collectAsState(initial = emptyList())
val sports by dao.getSportsForDay(startOfDay.timeInMillis, endOfDay.timeInMillis).collectAsState(initial = emptyList())
val glycemiaList by dao.getGlycemiaForDay(startOfDay.timeInMillis, endOfDay.timeInMillis).collectAsState(initial = emptyList())
val isDiabetic = prefs.getBoolean("is_diabetic", false)
var selectedMealForDetail by remember { mutableStateOf<Meal?>(null) }
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val tCal = prefs.getString("target_calories", "2000")?.toIntOrNull() ?: 2000
val tCarb = prefs.getString("target_carbs", "250")?.toIntOrNull() ?: 250
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)
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 (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.Default.ArrowBack, null) }
Text(SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(selectedDate.time))
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.MONTH, 1)
selectedDate = newDate
}) { Icon(Icons.Default.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) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
title = { Text("Exporter l'historique") },
text = {
Column {
Text("Sélectionnez la plage de dates :")
Spacer(Modifier.height(16.dp))
Button(onClick = {
DatePickerDialog(context, { _, y, m, d ->
val newDate = Calendar.getInstance()
newDate.set(y, m, d)
exportStartDate = newDate
}, 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 ->
val newDate = Calendar.getInstance()
newDate.set(y, m, d)
exportEndDate = newDate
}, 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 },
title = { Text(selectedMealForDetail!!.name) },
text = {
Column(modifier = Modifier
.fillMaxWidth()
.heightIn(max = 450.dp)
.verticalScroll(rememberScrollState())
) {
Text("Type: ${selectedMealForDetail!!.type}", fontWeight = FontWeight.Bold)
Text("Calories: ${selectedMealForDetail!!.totalCalories} kcal")
Text("Macro: G ${selectedMealForDetail!!.carbs}g | P ${selectedMealForDetail!!.protein}g | L ${selectedMealForDetail!!.fat}g")
Text("Heure: ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(selectedMealForDetail!!.date))}")
Spacer(Modifier.height(8.dp))
Text("Analyse complète :", style = MaterialTheme.typography.labelMedium)
Text(selectedMealForDetail!!.analysisText)
}
},
confirmButton = {
TextButton(onClick = { selectedMealForDetail = null }) { Text("Fermer") }
}
)
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, -1)
selectedDate = newDate
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, null) }
Column(modifier = Modifier.weight(1f).clickable { showMonthPicker = true }) {
Text(
text = SimpleDateFormat("EEEE d MMMM", Locale.getDefault()).format(selectedDate.time).replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Cliquer pour voir le calendrier",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
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)
selectedDate = newDate
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) }
}
Spacer(Modifier.height(16.dp))
val totalIn = meals.sumOf { it.totalCalories }
val totalOut = sports.sumOf { it.calories?.toInt() ?: 0 }
val netTotal = totalIn - totalOut
val totalCarbs = meals.sumOf { it.carbs }
val totalProt = meals.sumOf { it.protein }
val totalFat = meals.sumOf { it.fat }
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Résumé Calorique", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Mangé", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
Text("$totalIn", style = MaterialTheme.typography.titleLarge, color = calorieColor, fontWeight = FontWeight.Bold)
}
Text("-", style = MaterialTheme.typography.headlineMedium, color = Color.Gray)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Dépensé", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
Text("$totalOut", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
}
Text("=", style = MaterialTheme.typography.headlineMedium, color = Color.Gray)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Total Net", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
Text("$netTotal", style = MaterialTheme.typography.titleLarge, color = if(netTotal <= tCal) calorieColor else Color.Red, fontWeight = FontWeight.ExtraBold)
}
}
Text("kcal", style = MaterialTheme.typography.labelSmall, modifier = Modifier.align(Alignment.End), color = Color.Gray)
Spacer(Modifier.height(12.dp))
HorizontalDivider()
Spacer(Modifier.height(12.dp))
Text("Objectifs Quotidiens", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
DailyGoalChart("Calories", totalIn, tCal, calorieColor)
DailyGoalChart("Glucides", totalCarbs, tCarb, carbsColor)
DailyGoalChart("Protéines", totalProt, tProt, proteinColor)
DailyGoalChart("Lipides", totalFat, tFat, fatColor)
}
}
}
Spacer(Modifier.height(16.dp))
LazyColumn(Modifier.weight(1f)) {
val mealCategories = listOf("Déjeuner", "Dîner", "Souper", "Collation")
mealCategories.forEach { category ->
val categoryMeals = meals.filter { it.type == category }
val beforeGly = glycemiaList.find { it.moment == "Avant $category" }
val afterGly = glycemiaList.find { it.moment == "Après $category" }
if (categoryMeals.isNotEmpty() || beforeGly != null || afterGly != null) {
item {
Text(category, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
}
if (isDiabetic && beforeGly != null) {
item {
Surface(color = Color.Blue.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)) {
Text("🩸 Glycémie Avant: ${beforeGly.value} mmol/L", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = {
coroutineScope.launch {
dao.deleteGlycemia(beforeGly)
Toast.makeText(context, "Glycémie supprimée", Toast.LENGTH_SHORT).show()
}
}) { Icon(Icons.Default.Delete, null, modifier = Modifier.size(20.dp)) }
}
}
}
}
items(categoryMeals) { meal ->
ListItem(
headlineContent = { Text(meal.name) },
supportingContent = { Text("${meal.totalCalories} kcal - G:${meal.carbs}g P:${meal.protein}g L:${meal.fat}g") },
trailingContent = {
IconButton(onClick = {
coroutineScope.launch {
dao.deleteMeal(meal)
Toast.makeText(context, "Repas supprimé", Toast.LENGTH_SHORT).show()
}
}) { Icon(Icons.Default.Delete, null) }
},
modifier = Modifier.clickable { selectedMealForDetail = meal }
)
}
if (isDiabetic && afterGly != null) {
item {
Surface(color = Color.Green.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)) {
Text("🩸 Glycémie Après: ${afterGly.value} mmol/L", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = {
coroutineScope.launch {
dao.deleteGlycemia(afterGly)
Toast.makeText(context, "Glycémie supprimée", Toast.LENGTH_SHORT).show()
}
}) { Icon(Icons.Default.Delete, null, modifier = Modifier.size(20.dp)) }
}
}
}
}
}
}
if (sports.isNotEmpty()) {
item {
Text("Activités Sportives", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 16.dp, bottom = 4.dp))
}
items(sports) { sport ->
ListItem(
headlineContent = { Text(sport.name) },
supportingContent = { Text("${sport.type} - ${sport.calories?.toInt()} kcal brûlées") },
trailingContent = { Icon(Icons.Default.Check, tint = Color.Green, contentDescription = null) }
)
}
}
}
}
}
private fun generatePdfReport(
outputStream: java.io.OutputStream,
meals: List<Meal>,
sports: List<SportActivity>,
glycemia: List<Glycemia>,
startDate: Date,
endDate: Date
) {
val writer = com.itextpdf.kernel.pdf.PdfWriter(outputStream)
val pdf = com.itextpdf.kernel.pdf.PdfDocument(writer)
val document = com.itextpdf.layout.Document(pdf)
val df = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
val tf = SimpleDateFormat("HH:mm", Locale.getDefault())
// Header
document.add(com.itextpdf.layout.element.Paragraph("Rapport d'Historique Scan-Wich")
.setTextAlignment(com.itextpdf.layout.properties.TextAlignment.CENTER)
.setFontSize(20f)
.setBold())
document.add(com.itextpdf.layout.element.Paragraph("Période: ${df.format(startDate)} au ${df.format(endDate)}")
.setTextAlignment(com.itextpdf.layout.properties.TextAlignment.CENTER)
.setFontSize(12f))
document.add(com.itextpdf.layout.element.Paragraph("\n"))
// Meals Table
if (meals.isNotEmpty()) {
document.add(com.itextpdf.layout.element.Paragraph("Repas").setBold().setFontSize(14f))
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.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(com.itextpdf.layout.element.Paragraph("\n"))
}
// Glycemia Table
if (glycemia.isNotEmpty()) {
document.add(com.itextpdf.layout.element.Paragraph("Glycémie").setBold().setFontSize(14f))
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.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(com.itextpdf.layout.element.Paragraph("\n"))
}
// Sports Table
if (sports.isNotEmpty()) {
document.add(com.itextpdf.layout.element.Paragraph("Activités Sportives").setBold().setFontSize(14f))
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.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()
}

View File

@@ -0,0 +1,31 @@
package com.example.scanwich
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LoginScreen(onLoginClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Scan-Wich", style = MaterialTheme.typography.displayLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(32.dp))
Button(onClick = onLoginClick, modifier = Modifier
.fillMaxWidth()
.height(56.dp)) {
Icon(Icons.Default.AccountCircle, null)
Spacer(Modifier.width(8.dp))
Text("Se connecter avec Google")
}
}
}

View File

@@ -0,0 +1,123 @@
package com.example.scanwich
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.example.scanwich.ui.theme.ScanwichTheme
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.functions.functions
import com.google.firebase.initialize
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import androidx.core.content.edit
class MainActivity : ComponentActivity() {
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
NotificationHelper.scheduleReminders(this)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkNotificationPermission()
NotificationHelper.scheduleReminders(this)
try {
Firebase.initialize(this)
// On utilise explicitement le package name pour BuildConfig car l'import peut échouer
val appCheckFactory = if (com.example.scanwich.BuildConfig.DEBUG) {
DebugAppCheckProviderFactory.getInstance()
} else {
PlayIntegrityAppCheckProviderFactory.getInstance()
}
Firebase.appCheck.installAppCheckProviderFactory(appCheckFactory)
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}") }
handleStravaCallback(intent)
setContent {
ScanwichTheme {
val dao = AppDatabase.getDatabase(LocalContext.current).appDao()
AuthWrapper(dao)
}
}
}
private fun checkNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleStravaCallback(intent)
}
private fun handleStravaCallback(intent: Intent) {
val data: Uri? = intent.data
if (data != null && data.toString().startsWith("coloricam://localhost")) {
val code = data.getQueryParameter("code")
if (code != null) {
val prefs = ApiClient.getEncryptedPrefs(this)
val functions = Firebase.functions
val requestData = hashMapOf(
"code" to code
)
functions.getHttpsCallable("exchangeStravaToken")
.call(requestData)
.addOnSuccessListener { result ->
val res = result.data as? Map<*, *>
if (res != null) {
val accessToken = res["access_token"] as? String
val refreshToken = res["refresh_token"] as? String
val expiresAt = (res["expires_at"] as? Number)?.toLong() ?: 0L
if (accessToken != null && refreshToken != null) {
prefs.edit {
putString("strava_token", accessToken)
putString("strava_refresh_token", refreshToken)
putLong("strava_expires_at", expiresAt)
}
Log.d("StravaAuth", "Token exchange successful")
}
}
}
.addOnFailureListener { e ->
Log.e("StravaAuth", "Cloud Function exchange failed: ${e.message}")
}
}
}
}
}

View File

@@ -0,0 +1,80 @@
package com.example.scanwich
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@Composable
fun MainApp(dao: AppDao, onLogout: () -> Unit, userId: String) {
val context = LocalContext.current
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)) }
if (showSetup) {
SetupScreen(prefs, onComplete = { showSetup = false })
} else {
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = "Scan") },
label = { Text("Scan") },
selected = false,
onClick = { navController.navigate("capture") }
)
NavigationBarItem(
icon = { Icon(Icons.Default.DateRange, contentDescription = "Historique") },
label = { Text("Historique") },
selected = false,
onClick = { navController.navigate("history") }
)
NavigationBarItem(
icon = { Icon(Icons.Default.Add, contentDescription = "Sport") },
label = { Text("Sport") },
selected = false,
onClick = { navController.navigate("sport") }
)
if (isDiabetic) {
NavigationBarItem(
icon = { Icon(Icons.Default.Favorite, contentDescription = "Glycémie") },
label = { Text("Glycémie") },
selected = false,
onClick = { navController.navigate("glycemia") }
)
}
NavigationBarItem(
icon = { Icon(Icons.Default.Settings, contentDescription = "Paramètres") },
label = { Text("Paramètres") },
selected = false,
onClick = { navController.navigate("settings") }
)
}
}
) { innerPadding ->
NavHost(navController, "capture", Modifier.padding(innerPadding)) {
composable("capture") { CaptureScreen(dao) }
composable("history") { HistoryScreen(dao, prefs) }
composable("sport") { SportScreen(dao, prefs) }
composable("glycemia") { GlycemiaScreen(dao) }
composable("settings") {
SettingsScreen(prefs, onLogout) {
isDiabetic = prefs.getBoolean("is_diabetic", false)
}
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
package com.example.scanwich
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
class MealReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
NotificationHelper.scheduleReminders(context)
return
}
val mealType = intent.getStringExtra("meal_type") ?: "repas"
showNotification(context, mealType)
}
private fun showNotification(context: Context, mealType: String) {
val channelId = "meal_reminders"
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"Rappels de repas",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Notifications pour ne pas oublier d'entrer vos repas"
}
notificationManager.createNotificationChannel(channel)
}
val activityIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context, 0, activityIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info) // À remplacer par l'icône de l'app si dispo
.setContentTitle("N'oubliez pas votre $mealType !")
.setContentText("Prenez un moment pour enregistrer ce que vous avez mangé.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
val notificationId = when(mealType.lowercase()) {
"déjeuner" -> 1
"dîner" -> 2
"souper" -> 3
else -> 0
}
notificationManager.notify(notificationId, notification)
}
}

View File

@@ -0,0 +1,190 @@
package com.example.scanwich
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.google.firebase.Firebase
import com.google.firebase.functions.functions
import com.google.gson.annotations.SerializedName
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.tasks.await
import java.io.File
// --- OPEN FOOD FACTS API ---
data class OffProductResponse(val status: Int, val product: OffProduct?)
data class OffProduct(@SerializedName("product_name") val productName: String?, val nutriments: OffNutriments?)
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 ---
data class StravaActivity(
val id: Long, val name: String, val type: String, val distance: Float,
@SerializedName("moving_time") val movingTime: Int, val calories: Float?,
@SerializedName("start_date") val startDate: String
)
data class StravaTokenResponse(
@SerializedName("access_token") val accessToken: String,
@SerializedName("refresh_token") val refreshToken: String,
@SerializedName("expires_at") val expiresAt: Long
)
interface StravaApi {
@GET("athlete/activities")
suspend fun getActivities(@Header("Authorization") token: String): List<StravaActivity>
@POST("oauth/token")
suspend fun exchangeToken(@Query("client_id") clientId: String, @Query("client_secret") clientSecret: String, @Query("code") code: String, @Query("grant_type") grantType: String = "authorization_code"): StravaTokenResponse
@POST("oauth/token")
suspend fun refreshToken(@Query("client_id") clientId: String, @Query("client_secret") clientSecret: String, @Query("refresh_token") refreshToken: String, @Query("grant_type") grantType: String = "refresh_token"): StravaTokenResponse
}
object ApiClient {
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
val stravaApi: StravaApi = Retrofit.Builder()
.baseUrl("https://www.strava.com/api/v3/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(StravaApi::class.java)
val offApi: OffApi = Retrofit.Builder()
.baseUrl("https://fr.openfoodfacts.org/api/v2/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(OffApi::class.java)
private const val STRAVA_CLIENT_ID = "203805"
private const val PREFS_FILENAME = "secure_user_prefs"
fun getEncryptedPrefs(context: Context): SharedPreferences {
return try {
createPrefs(context)
} catch (e: Exception) {
Log.e("Security", "Erreur Keystore: ${e.message}. Réinitialisation...")
try {
// Version plus sûre pour effacer les prefs corrompues
val prefsFile = File(context.filesDir.parent, "shared_prefs/$PREFS_FILENAME.xml")
if (prefsFile.exists()) {
prefsFile.delete()
}
createPrefs(context)
} catch (e2: Exception) {
context.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE)
}
}
}
private fun createPrefs(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return EncryptedSharedPreferences.create(
context,
PREFS_FILENAME,
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)
if (System.currentTimeMillis() / 1000 >= (expiresAt - 300)) {
val refreshToken = prefs.getString("strava_refresh_token", null) ?: return null
return try {
val functions = Firebase.functions
val data = hashMapOf("refreshToken" to refreshToken)
val result = functions.getHttpsCallable("refreshStravaToken")
.call(data)
.await()
val res = result.data as? Map<*, *>
if (res != null) {
val newAccessToken = res["access_token"] as? String
val newRefreshToken = res["refresh_token"] as? String
val newExpiresAt = (res["expires_at"] as? Number)?.toLong() ?: 0L
if (newAccessToken != null && newRefreshToken != null) {
prefs.edit {
putString("strava_token", newAccessToken)
putString("strava_refresh_token", newRefreshToken)
putLong("strava_expires_at", newExpiresAt)
}
return newAccessToken
}
}
null
} catch (e: Exception) {
Log.e("StravaAuth", "Refresh failed: ${e.message}")
null
}
}
return stravaToken
}
fun parseStravaDate(dateString: String): Long {
return try {
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault())
format.timeZone = TimeZone.getTimeZone("UTC")
format.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
System.currentTimeMillis()
}
}
fun estimateCaloriesFromDb(activity: SportActivity, weightKg: Double): Double {
val met = when (activity.type.lowercase(Locale.ROOT)) {
"run" -> 9.8
"walk" -> 3.5
"ride", "ebikeride" -> 7.5
"swim" -> 8.0
"workout" -> 5.0
"hike" -> 5.3
"yoga" -> 2.5
else -> 4.0
}
val durationHours = activity.movingTime / 3600.0
return met * weightKg * durationHours
}
fun launchStravaAuth(context: Context) {
val intentUri = Uri.parse("https://www.strava.com/oauth/mobile/authorize")
.buildUpon()
.appendQueryParameter("client_id", STRAVA_CLIENT_ID)
.appendQueryParameter("redirect_uri", "coloricam://localhost")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("approval_prompt", "auto")
.appendQueryParameter("scope", "activity:read_all")
.build()
val intent = Intent(Intent.ACTION_VIEW, intentUri)
context.startActivity(intent)
}
}

View File

@@ -0,0 +1,48 @@
package com.example.scanwich
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import java.util.*
object NotificationHelper {
fun scheduleReminders(context: Context) {
scheduleMealAlarm(context, 8, 30, "Déjeuner", 101)
scheduleMealAlarm(context, 12, 30, "Dîner", 102)
scheduleMealAlarm(context, 19, 30, "Souper", 103)
}
private fun scheduleMealAlarm(context: Context, hour: Int, minute: Int, mealType: String, requestCode: Int) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, MealReminderReceiver::class.java).apply {
putExtra("meal_type", mealType)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val calendar = Calendar.getInstance().apply {
timeInMillis = System.currentTimeMillis()
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
// Si l'heure est déjà passée, on programme pour demain
if (before(Calendar.getInstance())) {
add(Calendar.DATE, 1)
}
}
alarmManager.setInexactRepeating(
AlarmManager.RTC_WAKEUP,
calendar.timeInMillis,
AlarmManager.INTERVAL_DAY,
pendingIntent
)
}
}

View File

@@ -0,0 +1,92 @@
package com.example.scanwich
import android.content.SharedPreferences
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
@Composable
fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) {
var isEditing by remember { mutableStateOf(false) }
val context = LocalContext.current
// État réactif pour la connexion Strava
var isStravaConnected by remember { mutableStateOf(prefs.contains("strava_token")) }
// Écouteur pour mettre à jour l'état si le token change
DisposableEffect(prefs) {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { p, key ->
if (key == "strava_token") {
isStravaConnected = p.contains("strava_token")
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
onDispose {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
}
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)
Spacer(Modifier.height(16.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
ProfileItem("Objectif", prefs.getString("goal", "") ?: "")
ProfileItem("Cible Calorique", "$targetCals kcal")
ProfileItem("Diabétique", if (prefs.getBoolean("is_diabetic", false)) "Oui" else "Non")
}
}
Spacer(Modifier.height(8.dp))
Button(onClick = { isEditing = true }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Edit, null); Text(" Modifier le profil") }
Spacer(Modifier.height(32.dp))
Text("Intégrations", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
if (isStravaConnected) {
OutlinedButton(
onClick = {
prefs.edit {
remove("strava_token")
remove("strava_refresh_token")
}
// L'écouteur mettra `isStravaConnected` à jour automatiquement
Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.fillMaxWidth()
) {
Text("Déconnecter Strava (Connecté)")
}
} else {
Text("Aucune intégration active. Allez dans l'onglet 'Sport' pour connecter Strava.", style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(32.dp))
Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { Text("Déconnexion") }
}
}
}
@Composable
fun ProfileItem(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, fontWeight = FontWeight.Bold); Text(value)
}
}

View File

@@ -0,0 +1,222 @@
package com.example.scanwich
import android.content.SharedPreferences
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
@Composable
fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
Surface(modifier = Modifier.fillMaxSize()) {
var age by remember { mutableStateOf(prefs.getInt("age", 25).toString()) }
var heightCm by remember { mutableStateOf(prefs.getString("height_cm", "170") ?: "170") }
var weight by remember { mutableStateOf(prefs.getString("weight_display", "70") ?: "70") }
var isLbs by remember { mutableStateOf(prefs.getBoolean("is_lbs", false)) }
var gender by remember { mutableStateOf(prefs.getString("gender", "Homme") ?: "Homme") }
var activityLevel by remember { mutableStateOf(prefs.getString("activity_level", "Sédentaire") ?: "Sédentaire") }
var goal by remember { mutableStateOf(prefs.getString("goal", "Maintenir le poids") ?: "Maintenir le poids") }
var isDiabetic by remember { mutableStateOf(prefs.getBoolean("is_diabetic", false)) }
val context = LocalContext.current
val activityLevels = listOf("Sédentaire", "Légèrement actif", "Modérément actif", "Très actif", "Extrêmement actif")
val goals = listOf("Maintenir le poids", "Perdre du poids")
val activityMultipliers = mapOf(
"Sédentaire" to 1.2,
"Légèrement actif" to 1.375,
"Modérément actif" to 1.55,
"Très actif" to 1.725,
"Extrêmement actif" to 1.9
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Configuration du profil", style = MaterialTheme.typography.headlineLarge)
Spacer(Modifier.height(24.dp))
Text("Votre objectif :", style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Start))
goals.forEach { g ->
Row(
Modifier
.fillMaxWidth()
.clickable { goal = g }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = goal == g, onClick = { goal = g })
Text(g)
}
}
Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Genre : ", Modifier.width(80.dp))
RadioButton(selected = gender == "Homme", onClick = { gender = "Homme" })
Text("Homme")
Spacer(Modifier.width(16.dp))
RadioButton(selected = gender == "Femme", onClick = { gender = "Femme" })
Text("Femme")
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = age,
onValueChange = { age = it },
label = { Text("Âge") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = heightCm,
onValueChange = { heightCm = it },
label = { Text("Taille (cm)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = weight,
onValueChange = { weight = it },
label = { Text(if (isLbs) "Poids (lbs)" else "Poids (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(16.dp))
Switch(checked = isLbs, onCheckedChange = { isLbs = it })
Text(if (isLbs) "lbs" else "kg")
}
Spacer(Modifier.height(16.dp))
Text("Niveau d'activité :", style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Start))
activityLevels.forEach { level ->
Row(
Modifier
.fillMaxWidth()
.clickable { activityLevel = level }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = activityLevel == level, onClick = { activityLevel = level })
Text(level)
}
}
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(checked = isDiabetic, onCheckedChange = { isDiabetic = it })
Text("Je suis diabétique")
}
Spacer(Modifier.height(32.dp))
Button(
onClick = {
val currentUser = FirebaseAuth.getInstance().currentUser
if (currentUser == null) {
Toast.makeText(context, "Erreur : Vous devez être connecté pour sauvegarder.", Toast.LENGTH_LONG).show()
return@Button
}
val ageInt = age.toIntOrNull() ?: 0
val height = heightCm.toDoubleOrNull() ?: 0.0
var weightKg = weight.toDoubleOrNull() ?: 0.0
val weightDisplay = weight
if (isLbs) weightKg *= 0.453592
val bmr = if (gender == "Homme") {
(10 * weightKg) + (6.25 * height) - (5 * ageInt) + 5
} else {
(10 * weightKg) + (6.25 * height) - (5 * ageInt) - 161
}
val multiplier = activityMultipliers[activityLevel] ?: 1.2
var targetCals = (bmr * multiplier).toInt()
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()
val userProfile = hashMapOf(
"age" to ageInt,
"height_cm" to height,
"weight_kg" to weightKg,
"is_lbs" to isLbs,
"gender" to gender,
"activity_level" to activityLevel,
"goal" to goal,
"is_diabetic" to isDiabetic,
"target_calories" to targetCals,
"target_carbs" to targetCarbs,
"target_protein" to targetProtein,
"target_fat" to targetFat
)
// On spécifie explicitement la base de données "scan-wich"
FirebaseFirestore.getInstance("scan-wich").collection("users").document(currentUser.uid)
.set(userProfile)
.addOnSuccessListener {
Log.d("SetupScreen", "User profile saved to Firestore.")
prefs.edit {
putString("target_calories", targetCals.toString())
putString("target_carbs", targetCarbs.toString())
putString("target_protein", targetProtein.toString())
putString("target_fat", targetFat.toString())
putString("weight_kg", weightKg.toString())
putString("weight_display", weightDisplay)
putBoolean("is_lbs", isLbs)
putString("height_cm", heightCm)
putBoolean("is_diabetic", isDiabetic)
putInt("age", ageInt)
putString("gender", gender)
putString("activity_level", activityLevel)
putString("goal", goal)
}
Toast.makeText(context, "Profil sauvegardé sur votre compte !", Toast.LENGTH_SHORT).show()
onComplete()
}
.addOnFailureListener { e ->
Log.w("SetupScreen", "Error writing user profile to Firestore", e)
Toast.makeText(context, "Erreur de sauvegarde du profil: ${e.message}", Toast.LENGTH_LONG).show()
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = age.isNotBlank() && heightCm.isNotBlank() && weight.isNotBlank()
) {
Text("Sauvegarder le profil")
}
}
}
}

View File

@@ -0,0 +1,129 @@
package com.example.scanwich
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.scanwich.FirebaseUtils.syncSportToFirestore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
val sports by dao.getAllSports().collectAsState(initial = emptyList())
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
// État réactif pour la connexion Strava
var isConnectedToStrava by remember {
mutableStateOf(prefs.getString("strava_token", null) != null)
}
// Écouteur de changements pour les SharedPreferences
DisposableEffect(prefs) {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { p, key ->
if (key == "strava_token") {
isConnectedToStrava = p.getString("strava_token", null) != null
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
onDispose {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium)
if (!isConnectedToStrava) {
Button(
onClick = { ApiClient.launchStravaAuth(context) },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFC6100)) // Strava orange
) {
Icon(Icons.Default.Sync, null)
Spacer(Modifier.width(8.dp))
Text("Se connecter à Strava")
}
} else {
Button(
onClick = { syncStravaActivities(dao, prefs, coroutineScope, context) },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Icon(Icons.Default.Refresh, null)
Spacer(Modifier.width(8.dp))
Text("Synchroniser les activités")
}
}
Spacer(Modifier.height(8.dp))
LazyColumn(Modifier.weight(1f)) {
items(sports) { activity ->
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
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 distanceInKm = activity.distance / 1000
Text("${activity.type} - ${String.format(Locale.getDefault(), "%.2f", distanceInKm)} km")
Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
private fun syncStravaActivities(dao: AppDao, prefs: SharedPreferences, scope: CoroutineScope, context: Context) {
scope.launch {
val token = ApiClient.getValidStravaToken(prefs)
if (token == null) {
Toast.makeText(context, "Erreur de connexion Strava", Toast.LENGTH_LONG).show()
return@launch
}
try {
val activities = ApiClient.stravaApi.getActivities("Bearer $token")
val weightKg = prefs.getString("weight_kg", "70")?.toDoubleOrNull() ?: 70.0
val sportActivities = activities.map {
val activity = SportActivity(
id = it.id,
name = it.name,
type = it.type,
distance = it.distance,
movingTime = it.movingTime,
calories = it.calories ?: ApiClient.estimateCaloriesFromDb(
SportActivity(it.id, it.name, it.type, it.distance, it.movingTime, null, 0L),
weightKg
).toFloat(),
date = ApiClient.parseStravaDate(it.startDate)
)
syncSportToFirestore(activity)
activity
}
dao.insertSports(sportActivities)
Toast.makeText(context, "${activities.size} activités synchronisées !", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Log.e("StravaSync", "Error: ${e.message}")
Toast.makeText(context, "Erreur de synchronisation Strava", Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -0,0 +1,115 @@
package com.example.scanwich
import android.graphics.Bitmap
import android.util.Base64
import com.google.firebase.Firebase
import com.google.firebase.functions.functions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.*
fun Float.format(digits: Int) = "%.${digits}f".format(this)
fun getOptimizedImageBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
val width = bitmap.width
val height = bitmap.height
val maxSize = 1024
val (newWidth, newHeight) = if (width > maxSize || height > maxSize) {
val ratio = width.toFloat() / height.toFloat()
if (width > height) {
maxSize to (maxSize / ratio).toInt()
} else {
(maxSize * ratio).toInt() to maxSize
}
} else {
width to height
}
val resized = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
resized.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}
fun parseStravaDate(dateStr: String): Long {
return try {
val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
inputFormat.timeZone = TimeZone.getTimeZone("UTC")
inputFormat.parse(dateStr)?.time ?: 0L
} catch (_: Exception) { 0L }
}
fun estimateCaloriesFromDb(activity: SportActivity, weightKg: Double): Int {
if (activity.calories != null && activity.calories > 0) return activity.calories.toInt()
val met = when (activity.type.lowercase()) {
"run" -> 10.0
"ride" -> 8.0
"walk" -> 3.5
"hike" -> 6.0
"swim" -> 7.0
"weighttraining" -> 5.0
"workout" -> 4.5
else -> 5.0
}
val durationHours = activity.movingTime / 3600.0
return (met * weightKg * durationHours).toInt()
}
fun analyzeImage(
bitmap: Bitmap?,
textDescription: String?,
setAnalyzing: (Boolean) -> Unit,
onResult: (Triple<String, String, List<Int>>?, String?) -> Unit,
scope: CoroutineScope
) {
setAnalyzing(true)
scope.launch {
try {
val base64 = withContext(Dispatchers.Default) {
bitmap?.let { getOptimizedImageBase64(it) }
}
// Instruction pour que l'IA se concentre uniquement sur la nourriture (sans qualificatifs ni environnement)
val aiInstruction = "Focus seulement sur la nourriture, pas de qualificatif, pas son environnement, seulement la nourriture."
val mealDescriptionForAI = if (textDescription.isNullOrBlank()) {
aiInstruction
} else {
"$textDescription. $aiInstruction"
}
val data = hashMapOf("imageBase64" to base64, "mealName" to mealDescriptionForAI)
Firebase.functions("us-central1")
.getHttpsCallable("analyzeMealProxy")
.call(data)
.addOnSuccessListener { result ->
try {
val responseData = result.data as? Map<*, *>
if (responseData != null) {
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 { onResult(null, "Format invalide") }
} catch (e: Exception) { onResult(null, e.message) }
setAnalyzing(false)
}
.addOnFailureListener { e ->
onResult(null, e.message)
setAnalyzing(false)
}
} catch (e: Exception) {
onResult(null, e.localizedMessage)
setAnalyzing(false)
}
}
}

View File

@@ -0,0 +1,20 @@
package com.example.scanwich.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// Couleurs personnalisées pour une meilleure lisibilité
val DarkGreen = Color(0xFF2E7D32)
val DarkBlue = Color(0xFF1565C0)
val DeepOrange = Color(0xFFE64A19)
val DeepPink = Color(0xFFC2185B)
// Couleur pour remplacer le jaune illisible sur fond clair
val ReadableAmber = Color(0xFFB45F04)

View File

@@ -1,6 +1,5 @@
package com.example.coloricam.ui.theme package com.example.scanwich.ui.theme
import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -21,20 +20,10 @@ private val LightColorScheme = lightColorScheme(
primary = Purple40, primary = Purple40,
secondary = PurpleGrey40, secondary = PurpleGrey40,
tertiary = Pink40 tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
) )
@Composable @Composable
fun ColoricamTheme( fun ScanwichTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ // Dynamic color is available on Android 12+
dynamicColor: Boolean = true, dynamicColor: Boolean = true,

View File

@@ -1,4 +1,4 @@
package com.example.coloricam.ui.theme package com.example.scanwich.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -15,20 +15,4 @@ val Typography = Typography(
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp
) )
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
) )

View File

@@ -1,170 +1,74 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector
android:width="108dp"
android:height="108dp" android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> xmlns:android="http://schemas.android.com/apk/res/android">
<path <path android:fillColor="#3DDC84"
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/> android:pathData="M0,0h108v108h-108z"/>
<path <path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M9,0L9,108" <path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M29,0L29,108"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:pathData="M19,0L19,108" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M29,0L29,108" <path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M79,0L79,108"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:pathData="M39,0L39,108" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M49,0L49,108" <path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M0,29L108,29"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:pathData="M59,0L59,108" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M69,0L69,108" <path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M0,79L108,79"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:pathData="M79,0L79,108" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M89,0L89,108" <path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M19,49L89,49"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:pathData="M99,0L99,108" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M0,9L108,9" <path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" /> <path android:fillColor="#00000000" android:pathData="M39,19L39,89"
<path android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:fillColor="#00000000" <path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:pathData="M0,19L108,19" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeWidth="0.8" <path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" /> android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path <path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:fillColor="#00000000" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:pathData="M0,29L108,29" <path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeWidth="0.8" android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector> </vector>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">coloricam</string> <string name="app_name">Scan-Wich</string>
</resources> </resources>

View File

@@ -4,4 +4,6 @@ plugins {
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.secrets) apply false alias(libs.plugins.secrets) apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.firebase.appdistribution) apply false
} }

View File

@@ -1,24 +1,33 @@
[versions] [versions]
agp = "9.0.1" agp = "9.0.1"
coreKtx = "1.10.1" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5" junitVersion = "1.3.0"
espressoCore = "3.5.1" espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.8.0" activityCompose = "1.12.4"
kotlin = "2.0.21" kotlin = "2.0.21"
composeBom = "2024.09.00" composeBom = "2026.02.00"
generativeai = "0.9.0" generativeai = "0.9.0"
coil = "2.7.0" coil = "2.7.0"
room = "2.8.4" room = "2.8.4"
navigation = "2.7.7" navigation = "2.9.7"
ksp = "2.0.21-1.0.27" ksp = "2.0.21-1.0.28"
retrofit = "2.9.0" retrofit = "3.0.0"
okhttp = "4.12.0" okhttp = "5.3.2"
browser = "1.8.0" browser = "1.9.0"
exifinterface = "1.3.7" exifinterface = "1.4.2"
secretsPlugin = "2.0.1" secretsPlugin = "2.0.1"
playServicesAuth = "21.2.0" playServicesAuth = "21.5.1"
googleServices = "4.4.4"
firebaseBom = "34.9.0"
firebaseAppDistribution = "5.2.1"
firebaseAppDistributionSdk = "16.0.0-beta17"
securityCrypto = "1.1.0"
kotlinxCoroutinesPlayServices = "1.10.2"
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" }
@@ -35,6 +44,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
google-generativeai = { group = "com.google.ai.client.generativeai", name = "generativeai", version.ref = "generativeai" } google-generativeai = { group = "com.google.ai.client.generativeai", name = "generativeai", version.ref = "generativeai" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
@@ -47,9 +57,29 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor",
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" } androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" } play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-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-firestore = { group = "com.google.firebase", name = "firebase-firestore" }
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" }
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcodeScanning" }
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
itext7-core = { group = "com.itextpdf", name = "itext7-core", version.ref = "itext" }
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 = "kotlin" }
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" }
firebase-appdistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }

42
release-notes.txt Normal file
View File

@@ -0,0 +1,42 @@
📝 Notes de version - Scan-Wich
**Nouveautés de la version actuelle :**
☁️ **Synchronisation Cloud Totale (Zéro Re-saisie) :**
- **Mémoire Cloud :** Votre profil (poids, objectifs, calories cibles) est désormais entièrement sauvegardé dans le Cloud. Plus besoin de retaper vos informations lors d'une mise à jour ou d'une réinstallation !
- **Rapatriement Automatique :** L'application récupère instantanément tout votre historique (repas, sport, glycémie) et vos paramètres dès la connexion.
- **Modèles de données optimisés :** Mise à jour des structures internes pour garantir une compatibilité parfaite avec Firebase lors de la récupération des données.
- **Résilience Keystore :** Ajout d'un système d'auto-réparation en cas de corruption des clés de sécurité locales, évitant ainsi les fermetures inattendues de l'application.
🍲 **Focus Nourriture Pur (IA) :**
- **Analyse sélective :** L'intelligence artificielle se concentre désormais exclusivement sur la nourriture. Les qualificatifs, les descriptions de l'environnement ou les éléments de décor sont ignorés pour ne garder que l'essentiel nutritionnel.
🔔 **Rappels de Repas :**
- **Notifications intelligentes :** Ne manquez plus un enregistrement ! Des rappels automatiques ont été ajoutés pour le déjeuner (08h30), le dîner (12h30) et le souper (19h30).
---
**Changements majeurs précédents :**
🇫🇷 **Expérience 100% en Français :**
- **IA Francophone :** Les analyses de repas (caméra et manuel) sont désormais exclusivement en français.
- **Scanner Localisé :** Utilisation de la base de données française d'Open Food Facts.
🤖 **Analyse IA plus Robuste :**
- **Fiabilité accrue :** Nettoyage automatique des données nutritionnelles côté serveur.
🚀 **Connexion Strava 100% Automatique :**
- **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques.
🛡️ **Architecture Cloud & Sécurité :**
- **Gestion des accès :** Contrôle dynamique des utilisateurs autorisés via Firestore.
- **Secret Manager :** Protection maximale des clés API via Google Cloud.
---
🛠️ **Historique technique :**
- Export PDF Professionnel (Repas, sport, glycémie).
- Suivi Diabétique complet.
- Intégration Firebase App Check (Play Integrity).
- Algorithme d'estimation MET pour le sport.
- Optimisation pour Android 36.

View File

@@ -22,5 +22,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "coloricam" rootProject.name = "scan-wich"
include(":app") include(":app")