diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 0000000..ceab9ac
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+coloricam
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index 2173449..5be3a9d 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -11,7 +11,20 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8d404a6..39fb6ba 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -3,14 +3,15 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.secrets)
+ alias(libs.plugins.google.services)
}
android {
- namespace = "com.example.coloricam"
+ namespace = "com.example.scanwich"
compileSdk = 35
defaultConfig {
- applicationId = "com.example.coloricam"
+ applicationId = "com.example.scanwich"
minSdk = 24
targetSdk = 35
versionCode = 1
@@ -19,9 +20,20 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+ signingConfigs {
+ // On configure la release pour utiliser la même clé que le debug pour l'instant
+ getByName("debug") {
+ storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
+ storePassword = "android"
+ keyAlias = "androiddebugkey"
+ keyPassword = "android"
+ }
+ }
+
buildTypes {
release {
isMinifyEnabled = false
+ signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -73,6 +85,10 @@ dependencies {
// Google Sign-In
implementation(libs.play.services.auth)
+ // Firebase
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.analytics)
+
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/release/app-release.apk b/app/release/app-release.apk
index 7d9350a..aa61d63 100644
Binary files a/app/release/app-release.apk and b/app/release/app-release.apk differ
diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm
index d5295c6..8867e9e 100644
Binary files a/app/release/baselineProfiles/0/app-release.dm and b/app/release/baselineProfiles/0/app-release.dm differ
diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm
index 63650e7..8af9ce4 100644
Binary files a/app/release/baselineProfiles/1/app-release.dm and b/app/release/baselineProfiles/1/app-release.dm differ
diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json
index 6b9a14a..40c75b3 100644
--- a/app/release/output-metadata.json
+++ b/app/release/output-metadata.json
@@ -4,7 +4,7 @@
"type": "APK",
"kind": "Directory"
},
- "applicationId": "com.example.coloricam",
+ "applicationId": "com.example.scanwich",
"variantName": "release",
"elements": [
{
diff --git a/app/src/main/java/com/example/coloricam/Database.kt b/app/src/main/java/com/example/coloricam/Database.kt
index 6e45f9d..8762c2f 100644
--- a/app/src/main/java/com/example/coloricam/Database.kt
+++ b/app/src/main/java/com/example/coloricam/Database.kt
@@ -1,64 +1 @@
-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>
-
- @Insert suspend fun insertGlycemia(glycemia: Glycemia): Long
- @Delete suspend fun deleteGlycemia(glycemia: Glycemia)
- @Query("SELECT * FROM glycemia ORDER BY date DESC") fun getAllGlycemia(): Flow>
-
- @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List)
- @Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow>
- @Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
- fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow>
-}
-
-@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 }
- }
- }
-}
+// Ce fichier est obsolète. Utilisez celui dans le package com.example.scanwich.
diff --git a/app/src/main/java/com/example/coloricam/MainActivity.kt b/app/src/main/java/com/example/coloricam/MainActivity.kt
index 594735b..8762c2f 100644
--- a/app/src/main/java/com/example/coloricam/MainActivity.kt
+++ b/app/src/main/java/com/example/coloricam/MainActivity.kt
@@ -1,381 +1 @@
-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
-
- @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")
- }
- }
-}
+// Ce fichier est obsolète. Utilisez celui dans le package com.example.scanwich.
diff --git a/app/src/main/java/com/example/coloricam/ui/theme/Color.kt b/app/src/main/java/com/example/coloricam/ui/theme/Color.kt
index ea581a9..ae5a15f 100644
--- a/app/src/main/java/com/example/coloricam/ui/theme/Color.kt
+++ b/app/src/main/java/com/example/coloricam/ui/theme/Color.kt
@@ -1,4 +1,4 @@
-package com.example.coloricam.ui.theme
+package com.example.scanwich.ui.theme
import androidx.compose.ui.graphics.Color
@@ -8,4 +8,4 @@ val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
-val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
+val Pink40 = Color(0xFF7D5260)
diff --git a/app/src/main/java/com/example/coloricam/ui/theme/Theme.kt b/app/src/main/java/com/example/coloricam/ui/theme/Theme.kt
index b409f6b..c598e02 100644
--- a/app/src/main/java/com/example/coloricam/ui/theme/Theme.kt
+++ b/app/src/main/java/com/example/coloricam/ui/theme/Theme.kt
@@ -1,4 +1,4 @@
-package com.example.coloricam.ui.theme
+package com.example.scanwich.ui.theme
import android.app.Activity
import android.os.Build
@@ -21,20 +21,10 @@ private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
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
-fun ColoricamTheme(
+fun ScanwichTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
@@ -55,4 +45,4 @@ fun ColoricamTheme(
typography = Typography,
content = content
)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/example/coloricam/ui/theme/Type.kt b/app/src/main/java/com/example/coloricam/ui/theme/Type.kt
index 26d473e..ee7106d 100644
--- a/app/src/main/java/com/example/coloricam/ui/theme/Type.kt
+++ b/app/src/main/java/com/example/coloricam/ui/theme/Type.kt
@@ -1,4 +1,4 @@
-package com.example.coloricam.ui.theme
+package com.example.scanwich.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
@@ -15,20 +15,4 @@ val Typography = Typography(
lineHeight = 24.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
- )
- */
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/example/scanwich/Database.kt b/app/src/main/java/com/example/scanwich/Database.kt
new file mode 100644
index 0000000..aeb77ce
--- /dev/null
+++ b/app/src/main/java/com/example/scanwich/Database.kt
@@ -0,0 +1,68 @@
+package com.example.scanwich
+
+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>
+ @Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
+ fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow>
+
+ @Insert suspend fun insertGlycemia(glycemia: Glycemia): Long
+ @Delete suspend fun deleteGlycemia(glycemia: Glycemia)
+ @Query("SELECT * FROM glycemia ORDER BY date DESC") fun getAllGlycemia(): Flow>
+ @Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
+ fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List)
+ @Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow>
+ @Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
+ fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow>
+}
+
+@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 }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/scanwich/MainActivity.kt b/app/src/main/java/com/example/scanwich/MainActivity.kt
new file mode 100644
index 0000000..02fe07a
--- /dev/null
+++ b/app/src/main/java/com/example/scanwich/MainActivity.kt
@@ -0,0 +1,1224 @@
+package com.example.scanwich
+
+import android.Manifest
+import android.app.DatePickerDialog
+import android.app.TimePickerDialog
+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 android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+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.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.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.core.content.edit
+import androidx.core.net.toUri
+import androidx.exifinterface.media.ExifInterface
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.example.scanwich.ui.theme.ScanwichTheme
+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.CoroutineScope
+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
+
+// --- 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,
+ @SerializedName("moving_time") val movingTime: Int,
+ @SerializedName("elapsed_time") val elapsedTime: Int,
+ val calories: Float?,
+ @SerializedName("start_date") val startDate: String,
+ @SerializedName("start_date_local") val startDateLocal: 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,
+ @Query("before") before: Long? = null,
+ @Query("after") after: Long? = null,
+ @Query("page") page: Int? = null,
+ @Query("per_page") perPage: Int? = 30
+ ): List
+
+ @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
+}
+
+// Annotation for Gson
+annotation class SerializedName(val value: String)
+
+// 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.accessToken)
+ putString("strava_refresh_token", refreshResponse.refreshToken)
+ putLong("strava_expires_at", refreshResponse.expiresAt)
+ }
+ return refreshResponse.accessToken
+ } catch (_: 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 (_: 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 {
+ ScanwichTheme {
+ 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)
+ val clientId = prefs.getString("strava_client_id", "") ?: ""
+ val clientSecret = prefs.getString("strava_client_secret", "") ?: ""
+
+ if (clientId.isNotEmpty() && clientSecret.isNotEmpty()) {
+ CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
+ try {
+ val response = ApiClient.stravaApi.exchangeToken(clientId, clientSecret, code)
+ prefs.edit {
+ putString("strava_token", response.accessToken)
+ putString("strava_refresh_token", response.refreshToken)
+ putLong("strava_expires_at", response.expiresAt)
+ }
+ } catch (e: Exception) {
+ Log.e("StravaAuth", "Exchange failed: ${e.message}")
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@Suppress("DEPRECATION")
+fun AuthWrapper(dao: AppDao) {
+ val context = LocalContext.current
+ 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 account by remember { mutableStateOf(GoogleSignIn.getLastSignedInAccount(context)) }
+
+ val allowedEmails = listOf("marcandre.charest@gmail.com", "everousseau07@gmail.com")
+
+ val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
+ try {
+ val signedInAccount = task.getResult(ApiException::class.java)
+ account = signedInAccount
+ Log.d("Auth", "Connecté avec : ${signedInAccount.email}")
+ } catch (e: ApiException) {
+ Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}")
+ val msg = when(e.statusCode) {
+ 10 -> "Erreur 10 : SHA-1 non reconnu dans Firebase. Assurez-vous d'avoir ajouté le SHA-1 de TOUTES vos clés de signature."
+ 7 -> "Erreur 7 : Problème de réseau."
+ 12500 -> "Erreur 12500 : Problème de configuration Google Play Services."
+ else -> "Erreur Google (Code ${e.statusCode})."
+ }
+ Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
+ }
+ }
+
+ val onLogout: () -> Unit = {
+ googleSignInClient.signOut().addOnCompleteListener {
+ account = null
+ }
+ }
+
+ if (account == null) {
+ LoginScreen { launcher.launch(googleSignInClient.signInIntent) }
+ } else {
+ val userEmail = account?.email?.lowercase() ?: ""
+ if (allowedEmails.contains(userEmail)) {
+ 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("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")
+ }
+ }
+}
+
+@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, "Scan") }, label = { Text("Scan") }, selected = false, onClick = { navController.navigate("capture") })
+ NavigationBarItem(icon = { Icon(Icons.Default.DateRange, "Historique") }, label = { Text("Historique") }, selected = false, onClick = { navController.navigate("history") })
+ NavigationBarItem(icon = { Icon(Icons.Default.Add, "Sport") }, label = { Text("Sport") }, selected = false, onClick = { navController.navigate("sport") })
+ if (isDiabetic) {
+ NavigationBarItem(icon = { Icon(Icons.Default.Favorite, "Glycémie") }, label = { Text("Glycémie") }, selected = false, onClick = { navController.navigate("glycemia") })
+ }
+ 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("history") { HistoryScreen(dao, prefs) }
+ composable("sport") { SportScreen(dao, prefs) }
+ composable("glycemia") { GlycemiaScreen(dao) }
+ composable("settings") { SettingsScreen(prefs, onLogout) }
+ }
+ }
+ }
+}
+
+@Composable
+fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
+ 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 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) {
+ 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 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()
+
+ prefs.edit {
+ putString("target_calories", targetCals.toString())
+ putString("target_carbs", targetCarbs.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)
+ }
+ onComplete()
+ },
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ enabled = age.isNotBlank() && heightCm.isNotBlank() && weight.isNotBlank()
+ ) {
+ Text("Sauvegarder le profil")
+ }
+ }
+}
+
+@Composable
+fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
+ val coroutineScope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ var capturedBitmap by remember { mutableStateOf(null) }
+ var isAnalyzing by remember { mutableStateOf(false) }
+ var showMealDialog by remember { mutableStateOf(false) }
+ var currentMealData by remember { mutableStateOf?>(null) }
+ var mealDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
+
+ var manualMealName by remember { mutableStateOf("") }
+
+ val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
+ if (bitmap != null) {
+ capturedBitmap = bitmap
+ mealDateTime = System.currentTimeMillis()
+ analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error ->
+ if (data != null) {
+ currentMealData = data
+ showMealDialog = true
+ } else {
+ Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show()
+ }
+ }, coroutineScope)
+ }
+ }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ if (isGranted) {
+ cameraLauncher.launch(null)
+ } else {
+ Toast.makeText(context, "Permission caméra requise", 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
+
+ // Extract EXIF date
+ 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, error ->
+ if (data != null) {
+ currentMealData = data
+ showMealDialog = 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 (showMealDialog && currentMealData != null) {
+ var mealType by remember { mutableStateOf("Déjeuner") }
+ val calendar = Calendar.getInstance().apply { timeInMillis = mealDateTime }
+
+ var editableName by remember { mutableStateOf(currentMealData!!.first) }
+ var editableCalories by remember { mutableStateOf(currentMealData!!.third.toString()) }
+ var editableDesc by remember { mutableStateOf(currentMealData!!.second) }
+
+ AlertDialog(
+ onDismissRequest = { showMealDialog = false },
+ title = { Text("Détails du repas") },
+ text = {
+ // Ensure the content inside the dialog scrolls
+ Column(modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 450.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ OutlinedTextField(
+ value = editableName,
+ onValueChange = { editableName = it },
+ label = { Text("Nom du repas") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ value = editableCalories,
+ onValueChange = { editableCalories = it },
+ label = { Text("Calories (kcal)") },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ value = editableDesc,
+ onValueChange = { editableDesc = it },
+ label = { Text("Description") },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(Modifier.height(12.dp))
+ Text("Type de repas :")
+ listOf("Déjeuner", "Dîner", "Souper", "Collation").forEach { type ->
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { mealType = type }) {
+ RadioButton(selected = mealType == type, onClick = { mealType = type })
+ Text(type)
+ }
+ }
+ Spacer(Modifier.height(8.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()
+ }) {
+ Text("Modifier Date/Heure: " + SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(mealDateTime)))
+ }
+ }
+ },
+ confirmButton = {
+ Button(onClick = {
+ coroutineScope.launch {
+ dao.insertMeal(Meal(
+ date = mealDateTime,
+ name = editableName,
+ analysisText = editableDesc,
+ totalCalories = editableCalories.toIntOrNull() ?: 0,
+ type = mealType
+ ))
+ showMealDialog = false
+ capturedBitmap = null
+ Toast.makeText(context, "Repas enregistré !", Toast.LENGTH_SHORT).show()
+ }
+ }) { Text("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.SpaceEvenly) {
+ Button(onClick = {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ cameraLauncher.launch(null)
+ } else {
+ permissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }) { Icon(Icons.Default.Add, null); Text(" Caméra") }
+ Button(onClick = { galleryLauncher.launch("image/*") }) { 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 par l'IA en cours...", modifier = Modifier.padding(top = 8.dp))
+ }
+ }
+
+ 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, error ->
+ if (data != null) {
+ currentMealData = data
+ showMealDialog = true
+ } else {
+ Toast.makeText(context, "Erreur AI: $error", 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", 0)
+ mealDateTime = System.currentTimeMillis()
+ showMealDialog = true
+ },
+ enabled = manualMealName.isNotBlank() && !isAnalyzing,
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("Direct (0 kcal)")
+ }
+ }
+ }
+}
+
+@Composable
+fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
+ var selectedDate by remember { mutableStateOf(Calendar.getInstance()) }
+ 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(null) }
+ val context = LocalContext.current
+
+ if (selectedMealForDetail != null) {
+ AlertDialog(
+ onDismissRequest = { selectedMealForDetail = null },
+ title = { Text(selectedMealForDetail!!.name) },
+ text = {
+ // Ensure history detail dialog scrolls
+ Column(modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 450.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text("Type: ${selectedMealForDetail!!.type}", fontWeight = FontWeight.Bold)
+ Text("Calories: ${selectedMealForDetail!!.totalCalories} kcal")
+ 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(), horizontalArrangement = Arrangement.SpaceBetween) {
+ IconButton(onClick = {
+ val newDate = selectedDate.clone() as Calendar
+ newDate.add(Calendar.DAY_OF_MONTH, -1)
+ selectedDate = newDate
+ }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, null) }
+
+ Text(
+ text = SimpleDateFormat("dd MMMM yyyy", Locale.getDefault()).format(selectedDate.time),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.clickable {
+ DatePickerDialog(context, { _, y, m, d ->
+ val newDate = Calendar.getInstance()
+ newDate.set(y, m, d)
+ selectedDate = newDate
+ }, selectedDate.get(Calendar.YEAR), selectedDate.get(Calendar.MONTH), selectedDate.get(Calendar.DAY_OF_MONTH)).show()
+ }
+ )
+
+ 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 balance = totalIn - totalOut
+
+ Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text("Calories Consommées:")
+ Text("$totalIn kcal", fontWeight = FontWeight.Bold)
+ }
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text("Calories Brûlées (Sport):")
+ Text("$totalOut kcal", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
+ }
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text("Bilan net:", fontWeight = FontWeight.Bold)
+ Text("$balance kcal", fontWeight = FontWeight.ExtraBold, color = if(balance > 0) Color.Red else Color.Green)
+ }
+ }
+ }
+
+ 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))
+ }
+
+ // Display Before Glycemia
+ if (isDiabetic && beforeGly != null) {
+ item {
+ Surface(color = Color.Blue.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
+ Text("🩸 Glycémie Avant: ${beforeGly.value} mmol/L", modifier = Modifier.padding(8.dp), style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ }
+
+ items(categoryMeals) { meal ->
+ ListItem(
+ headlineContent = { Text(meal.name) },
+ supportingContent = { Text("${meal.totalCalories} kcal - ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(meal.date))}") },
+ trailingContent = {
+ IconButton(onClick = { /* TODO */ }) { Icon(Icons.Default.Delete, null) }
+ },
+ modifier = Modifier.clickable { selectedMealForDetail = meal }
+ )
+ }
+
+ // Display After Glycemia
+ if (isDiabetic && afterGly != null) {
+ item {
+ Surface(color = Color.Green.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
+ Text("🩸 Glycémie Après: ${afterGly.value} mmol/L", modifier = Modifier.padding(8.dp), style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ }
+ }
+ }
+
+ 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 analyzeImage(
+ bitmap: Bitmap?,
+ textDescription: String?,
+ setAnalyzing: (Boolean) -> Unit,
+ onResult: (Triple?, String?) -> Unit,
+ scope: CoroutineScope
+) {
+ setAnalyzing(true)
+
+ var base64: String? = null
+ if (bitmap != null) {
+ val outputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
+ base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
+ }
+
+ val prompt = if (bitmap != null) {
+ "Analyze this food image. Provide ONLY: 1. Name (ex: 'Bol de Ramen'), 2. Summary description, 3. Total calories. Format EXACTLY as: {\"name\": \"...\", \"description\": \"...\", \"calories\": integer}"
+ } else {
+ "Analyze this meal description: '$textDescription'. Estimate the calories. Provide ONLY a JSON object. Format EXACTLY as: {\"name\": \"...\", \"description\": \"...\", \"calories\": integer}"
+ }
+
+ scope.launch {
+ try {
+ val response = ApiClient.n8nApi.analyzeMeal(
+ apiKey = BuildConfig.N8N_API_KEY,
+ request = N8nMealRequest(
+ imageBase64 = base64,
+ mealName = textDescription,
+ prompt = prompt
+ )
+ )
+ val responseStr = response.string()
+
+ // Extract JSON
+ val jsonStartIndex = responseStr.indexOf("{")
+ val jsonEndIndex = responseStr.lastIndexOf("}") + 1
+
+ if (jsonStartIndex != -1 && jsonEndIndex > jsonStartIndex) {
+ try {
+ val jsonPart = responseStr.substring(jsonStartIndex, jsonEndIndex)
+ val json = JSONObject(jsonPart)
+ onResult(Triple(
+ json.optString("name", textDescription ?: "Repas"),
+ json.optString("description", "Analyse réussie"),
+ json.optInt("calories", 0)
+ ), null)
+ return@launch
+ } catch (je: Exception) { }
+ }
+
+ // Fallback for text
+ val nameMatch = "(?:Nom|Name)\\s*[:\\-]?\\s*([^\\n.*]+)".toRegex(RegexOption.IGNORE_CASE).find(responseStr)
+ val calMatch = "(?:Total|Calories|Kcal)\\s*[:\\-]?\\s*(\\d+)".toRegex(RegexOption.IGNORE_CASE).find(responseStr)
+
+ val detectedName = nameMatch?.groupValues?.get(1)?.trim() ?: textDescription ?: "Repas"
+ val calories = calMatch?.groupValues?.get(1)?.toIntOrNull() ?: 0
+ onResult(Triple(detectedName, responseStr.take(1000), calories), null)
+
+ } catch (e: Exception) {
+ onResult(null, e.localizedMessage ?: "Erreur réseau")
+ } finally {
+ setAnalyzing(false)
+ }
+ }
+}
+
+private fun syncStravaActivities(dao: AppDao, prefs: SharedPreferences, scope: CoroutineScope, context: Context) {
+ scope.launch {
+ val token = ApiClient.getValidStravaToken(prefs)
+ if (token == null) {
+ Toast.makeText(context, "Veuillez connecter Strava dans les paramètres", 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 {
+ 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)
+ )
+ }
+ 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()
+ }
+ }
+}
+
+@Composable
+fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
+ val sports by dao.getAllSports().collectAsState(initial = emptyList())
+ val weightKg = prefs.getString("weight_kg", "70")?.toDoubleOrNull() ?: 70.0
+ val coroutineScope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
+ Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium)
+
+ 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 Strava")
+ }
+
+ 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)
+ }
+ Text("${activity.type} - ${(activity.distance / 1000).format(2)} km")
+ Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+ }
+}
+
+// Helper to format float
+fun Float.format(digits: Int) = "%.${digits}f".format(this)
+
+@Composable
+fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit) {
+ var isEditing by remember { mutableStateOf(false) }
+ val context = LocalContext.current
+
+ var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") }
+ var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") }
+ val isStravaConnected = prefs.contains("strava_token")
+
+ if (isEditing) { SetupScreen(prefs) { isEditing = false } } else {
+ 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("Configuration Strava", style = MaterialTheme.typography.titleMedium)
+ Spacer(Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = stravaClientId,
+ onValueChange = { stravaClientId = it; prefs.edit { putString("strava_client_id", it) } },
+ label = { Text("Strava Client ID") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ value = stravaClientSecret,
+ onValueChange = { stravaClientSecret = it; prefs.edit { putString("strava_client_secret", it) } },
+ label = { Text("Strava Client Secret") },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ if (!isStravaConnected) {
+ Button(
+ onClick = {
+ if (stravaClientId.isBlank() || stravaClientSecret.isBlank()) {
+ Toast.makeText(context, "Entrez votre ID et Secret Client", Toast.LENGTH_SHORT).show()
+ } else {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(
+ "https://www.strava.com/oauth/mobile/authorize?client_id=$stravaClientId&redirect_uri=coloricam://localhost&response_type=code&approval_prompt=auto&scope=read,activity:read_all"
+ ))
+ context.startActivity(intent)
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Connecter Strava")
+ }
+ } else {
+ OutlinedButton(
+ onClick = {
+ prefs.edit {
+ remove("strava_token")
+ remove("strava_refresh_token")
+ }
+ Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show()
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Déconnecter Strava (Connecté)")
+ }
+ }
+
+ 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)
+ }
+}
+
+@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 {
+ dao.insertGlycemia(Glycemia(date = selectedDateTime, value = value, moment = moment))
+ glycemiaValue = ""
+ Toast.makeText(context, "Glycémie enregistrée !", Toast.LENGTH_SHORT).show()
+ }
+ }
+ },
+ enabled = glycemiaValue.isNotBlank(),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Enregistrer")
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 07d5da9..ca3826a 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -1,170 +1,74 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ xmlns:android="http://schemas.android.com/apk/res/android">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 6f3b755..c4a603d 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,6 +1,5 @@
-
-
-
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 6f3b755..c4a603d 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,6 +1,5 @@
-
-
-
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp
index c209e78..da1b46a 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
index b2dfe3d..660d856 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp
index 4f0f1d6..45ca00a 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
index 62b611d..c6d4aa5 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
index 948a307..bbdaaa9 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
index 1b9a695..8ea7518 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
index 28d4b77..73bb3cf 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
index 9287f50..141d8aa 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
index aa7d642..d47a715 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
index 9126ae3..91ae230 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7796a25..9635adf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,3 @@
- coloricam
-
\ No newline at end of file
+ Scan-Wich
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 91b3685..0330749 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -4,4 +4,5 @@ plugins {
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.secrets) apply false
+ alias(libs.plugins.google.services) apply false
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 04adab8..9ac1ab9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -19,6 +19,8 @@ browser = "1.8.0"
exifinterface = "1.3.7"
secretsPlugin = "2.0.1"
playServicesAuth = "21.2.0"
+googleServices = "4.4.2"
+firebaseBom = "34.9.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -47,9 +49,12 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor",
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
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" }
+firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
+firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" }
+google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }