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" }