Compare commits
19 Commits
4712dae04a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1379e7cc3 | ||
| 2bec3bc681 | |||
| 9b87930e9a | |||
| c9d05be4b1 | |||
|
|
73a7f46509 | ||
| 7699ebcf2e | |||
|
|
c191394ee6 | ||
|
|
93c8814b84 | ||
|
|
1bb637ae62 | ||
| f8c702398f | |||
|
|
e9c586adcd | ||
|
|
75300292ec | ||
| f8dfa9af63 | |||
|
|
374b773443 | ||
|
|
f76b684d49 | ||
| 7c7041b7bc | |||
|
|
df1188fc02 | ||
|
|
26330349c6 | ||
|
|
670880d197 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,3 +13,5 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
app/firebase-key.json
|
||||
firebase-key.json
|
||||
2
.idea/.name
generated
2
.idea/.name
generated
@@ -1 +1 @@
|
||||
coloricam
|
||||
scan-wich
|
||||
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-02-20T21:41:35.128842100Z">
|
||||
<DropdownSelection timestamp="2026-03-10T00:31:28.813056900Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\marca\.android\avd\Medium_Phone.avd" />
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=58051FDCG0038S" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,43 +1,90 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.secrets)
|
||||
alias(libs.plugins.google.services)
|
||||
alias(libs.plugins.firebase.appdistribution)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.scanwich"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.scanwich"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
targetSdk = 36
|
||||
|
||||
// Incrémentation automatique du versionCode basé sur le temps
|
||||
versionCode = (System.currentTimeMillis() / 60000).toInt()
|
||||
versionName = "1.0." + (System.currentTimeMillis() / 3600000).toString().takeLast(3)
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// On configure la release pour utiliser la même clé que le debug pour l'instant
|
||||
// Chargement des propriétés depuis local.properties
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("local.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||
}
|
||||
|
||||
getByName("debug") {
|
||||
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
|
||||
storePassword = "android"
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
getByName("debug").storePassword = "android"
|
||||
getByName("debug").keyAlias = "androiddebugkey"
|
||||
getByName("debug").keyPassword = "android"
|
||||
}
|
||||
|
||||
create("release") {
|
||||
// Utilisation d'un chemin relatif au dossier Utilisateur pour fonctionner sur tous les PC
|
||||
storeFile = file("${System.getProperty("user.home")}/keys/keys")
|
||||
storePassword = keystoreProperties.getProperty("RELEASE_STORE_PASSWORD")
|
||||
keyAlias = keystoreProperties.getProperty("RELEASE_KEY_ALIAS") ?: "key0"
|
||||
keyPassword = keystoreProperties.getProperty("RELEASE_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
|
||||
val keyFile = project.file("firebase-key.json")
|
||||
val releaseNotesFile = rootProject.file("release-notes.txt")
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isMinifyEnabled = true // Activer l'offuscation
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
|
||||
configure<com.google.firebase.appdistribution.gradle.AppDistributionExtension> {
|
||||
artifactType = "APK"
|
||||
if (keyFile.exists()) {
|
||||
serviceCredentialsFile = keyFile.absolutePath
|
||||
}
|
||||
if (releaseNotesFile.exists()) {
|
||||
releaseNotes = releaseNotesFile.readText()
|
||||
}
|
||||
groups = "internal-user"
|
||||
}
|
||||
}
|
||||
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
configure<com.google.firebase.appdistribution.gradle.AppDistributionExtension> {
|
||||
artifactType = "APK"
|
||||
if (keyFile.exists()) {
|
||||
serviceCredentialsFile = keyFile.absolutePath
|
||||
}
|
||||
if (releaseNotesFile.exists()) {
|
||||
releaseNotes = releaseNotesFile.readText()
|
||||
}
|
||||
groups = "internal-user"
|
||||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -51,7 +98,6 @@ android {
|
||||
}
|
||||
|
||||
secrets {
|
||||
// A list of keys that should be ignored by the plugin by default.
|
||||
ignoreList.add("properties")
|
||||
}
|
||||
|
||||
@@ -64,30 +110,46 @@ dependencies {
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
// SDK Firebase App Distribution COMPLET (API + Implémentation)
|
||||
implementation(libs.firebase.appdistribution)
|
||||
|
||||
implementation(libs.google.generativeai)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// Room
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
// Network & Strava Auth
|
||||
implementation(libs.retrofit.core)
|
||||
implementation(libs.retrofit.gson)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.androidx.browser)
|
||||
|
||||
// Google Sign-In
|
||||
implementation(libs.play.services.auth)
|
||||
|
||||
// Firebase
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.analytics)
|
||||
implementation(libs.firebase.functions)
|
||||
implementation(libs.firebase.auth)
|
||||
implementation(libs.firebase.firestore)
|
||||
implementation(libs.firebase.appcheck.playintegrity)
|
||||
|
||||
// On met le debug provider en implementation pour qu'il soit disponible à la compilation en Release
|
||||
// (le code MainActivity utilise un check BuildConfig.DEBUG pour ne pas l'utiliser en prod)
|
||||
implementation(libs.firebase.appcheck.debug)
|
||||
|
||||
// Barcode Scanning & Camera
|
||||
implementation(libs.mlkit.barcode.scanning)
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
|
||||
// PDF generation
|
||||
implementation(libs.itext7.core)
|
||||
|
||||
// Security
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -21,6 +21,22 @@
|
||||
"certificate_hash": "2c39e4131dcac8a1d4257b804718ac113f855b04"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "652626507041-i6ne7rt1b711gfpbc5f5hd1q60kd0ntv.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.example.scanwich",
|
||||
"certificate_hash": "ebcc060f9a1fdeb1186536d3828574b42cefa03c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "652626507041-i928hstoseh72dta5d0lokm9c55tma2p.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.example.scanwich",
|
||||
"certificate_hash": "6f363d957ca44b3ca607c29f58f575d0ae71571d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "652626507041-5n42q37adh1guuv9gibfcf5uvekgunbe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
|
||||
76
app/proguard-rules.pro
vendored
76
app/proguard-rules.pro
vendored
@@ -1,21 +1,61 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
# --- SCAN-WICH SECURITY RULES ---
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
# Optimisations agressives
|
||||
-optimizationpasses 5
|
||||
-allowaccessmodification
|
||||
-mergeinterfacesaggressively
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
# Supprimer les informations de débogage
|
||||
-renamesourcefileattribute SourceFile
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
# Offusquer plus profondément
|
||||
-repackageclasses ''
|
||||
|
||||
# --- FIREBASE / GOOGLE ---
|
||||
# Les bibliothèques Google fournissent leurs propres règles optimisées.
|
||||
-dontwarn com.google.firebase.**
|
||||
-dontwarn com.google.android.gms.**
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
|
||||
# --- ITEXT ---
|
||||
# On cible plus précisément les packages iText utilisés pour le rapport PDF
|
||||
-dontwarn com.itextpdf.**
|
||||
-keep class com.itextpdf.kernel.** { public protected *; }
|
||||
-keep class com.itextpdf.layout.** { public protected *; }
|
||||
-keep class com.itextpdf.io.** { public protected *; }
|
||||
|
||||
# --- RETROFIT / OKHTTP ---
|
||||
-keepattributes Signature, InnerClasses, EnclosingMethod
|
||||
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
|
||||
|
||||
# Garder uniquement les annotations Retrofit
|
||||
-keep @interface retrofit2.http.*
|
||||
-dontwarn retrofit2.**
|
||||
# Note: On laisse Retrofit gérer ses propres règles internes (incluses dans l'AAR)
|
||||
|
||||
# --- ROOM ---
|
||||
# On ne garde que les classes liées à la base de données
|
||||
-keep class * extends androidx.room.RoomDatabase
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# --- DATA MODELS ---
|
||||
# Crucial : On garde tout ce qui est nécessaire au parsing JSON et à Room
|
||||
-keepclassmembers class com.example.scanwich.** {
|
||||
@com.google.gson.annotations.SerializedName <fields>;
|
||||
@androidx.room.PrimaryKey <fields>;
|
||||
}
|
||||
|
||||
-keep @androidx.room.Entity class com.example.scanwich.** { *; }
|
||||
|
||||
# On liste explicitement les modèles pour plus de précision
|
||||
-keep class com.example.scanwich.Meal { *; }
|
||||
-keep class com.example.scanwich.SportActivity { *; }
|
||||
-keep class com.example.scanwich.Glycemia { *; }
|
||||
-keep class com.example.scanwich.FavoriteMeal { *; }
|
||||
-keep class com.example.scanwich.N8nMealRequest { *; }
|
||||
-keep class com.example.scanwich.StravaActivity { *; }
|
||||
-keep class com.example.scanwich.StravaTokenResponse { *; }
|
||||
|
||||
# --- CONFIGURATION ---
|
||||
-keep class com.example.scanwich.BuildConfig { *; }
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,6 +4,9 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -14,6 +17,17 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Coloricam">
|
||||
|
||||
<receiver
|
||||
android:name=".MealReminderReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
// Ce fichier est obsolète. Utilisez celui dans le package com.example.scanwich.
|
||||
@@ -1 +0,0 @@
|
||||
// Ce fichier est obsolète. Utilisez celui dans le package com.example.scanwich.
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.example.scanwich.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
29
app/src/main/java/com/example/scanwich/AccessDeniedScreen.kt
Normal file
29
app/src/main/java/com/example/scanwich/AccessDeniedScreen.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun AccessDeniedScreen(onLogout: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(Icons.Default.Warning, null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Accès Refusé", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Votre compte n'est pas autorisé à utiliser cette application.", style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(onClick = onLogout) { Text("Changer de compte") }
|
||||
}
|
||||
}
|
||||
156
app/src/main/java/com/example/scanwich/AuthWrapper.kt
Normal file
156
app/src/main/java/com/example/scanwich/AuthWrapper.kt
Normal file
@@ -0,0 +1,156 @@
|
||||
package com.example.scanwich
|
||||
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
||||
import com.google.android.gms.common.api.ApiException
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.auth.FirebaseUser
|
||||
import com.google.firebase.auth.GoogleAuthProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import android.widget.Toast
|
||||
import android.util.Log
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.example.scanwich.LoginScreen
|
||||
import com.example.scanwich.MainApp
|
||||
import com.example.scanwich.AccessDeniedScreen
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
|
||||
@Composable
|
||||
@Suppress("DEPRECATION")
|
||||
fun AuthWrapper(dao: AppDao) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val auth = remember { FirebaseAuth.getInstance() }
|
||||
val gso = remember {
|
||||
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
|
||||
.requestEmail()
|
||||
.requestIdToken("652626507041-5n42q37adh1guuv9gibfcf5uvekgunbe.apps.googleusercontent.com")
|
||||
.build()
|
||||
}
|
||||
val googleSignInClient = remember { GoogleSignIn.getClient(context, gso) }
|
||||
var firebaseUser by remember { mutableStateOf<FirebaseUser?>(auth.currentUser) }
|
||||
|
||||
var isAuthorized by remember { mutableStateOf<Boolean?>(null) }
|
||||
|
||||
// État pour savoir si la synchro initiale est terminée
|
||||
var syncCompleted by remember { mutableStateOf(false) }
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
|
||||
try {
|
||||
val account = task.getResult(ApiException::class.java)
|
||||
val credential = GoogleAuthProvider.getCredential(account.idToken, null)
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val authResult = auth.signInWithCredential(credential).await()
|
||||
firebaseUser = authResult.user
|
||||
} catch (e: Exception) {
|
||||
Log.e("Auth", "Erreur Firebase Auth : ${e.message}")
|
||||
Toast.makeText(context, "Erreur de synchronisation Firebase.", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}")
|
||||
val msg = when (e.statusCode) {
|
||||
10 -> "Erreur 10 : SHA-1 non reconnu."
|
||||
7 -> "Erreur 7 : Réseau."
|
||||
else -> "Erreur Google (Code ${e.statusCode})."
|
||||
}
|
||||
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
val onLogout: () -> Unit = {
|
||||
auth.signOut()
|
||||
googleSignInClient.signOut().addOnCompleteListener {
|
||||
firebaseUser = null
|
||||
isAuthorized = null
|
||||
syncCompleted = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(firebaseUser) {
|
||||
isAuthorized = null
|
||||
val email = firebaseUser?.email?.trim()?.lowercase()
|
||||
|
||||
if (email != null && email.isNotEmpty()) {
|
||||
try {
|
||||
val db = FirebaseFirestore.getInstance("scan-wich")
|
||||
val docRef = db.collection("authorized_users").document(email)
|
||||
val document = docRef.get().await()
|
||||
|
||||
if (document.exists()) {
|
||||
Log.d("Auth", "Accès autorisé. Lancement de la synchronisation...")
|
||||
|
||||
val prefs = ApiClient.getEncryptedPrefs(context)
|
||||
// On attend que la synchro soit finie pour afficher l'app
|
||||
FirebaseUtils.fetchAllDataFromFirestore(dao, prefs)
|
||||
syncCompleted = true
|
||||
isAuthorized = true
|
||||
} else {
|
||||
isAuthorized = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Auth", "Erreur critique Firestore.", e)
|
||||
isAuthorized = false
|
||||
}
|
||||
} else if (firebaseUser != null) {
|
||||
isAuthorized = false
|
||||
}
|
||||
}
|
||||
|
||||
if (firebaseUser == null) {
|
||||
LoginScreen { launcher.launch(googleSignInClient.signInIntent) }
|
||||
} else {
|
||||
when (isAuthorized) {
|
||||
true -> {
|
||||
if (syncCompleted) {
|
||||
MainApp(dao = dao, onLogout = onLogout, userId = firebaseUser!!.uid)
|
||||
} else {
|
||||
LoadingBox("Synchronisation de votre profil...")
|
||||
}
|
||||
}
|
||||
false -> AccessDeniedScreen(onLogout)
|
||||
null -> LoadingBox("Vérification de l'accès...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingBox(text: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.util.Log
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
|
||||
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
||||
@Composable
|
||||
fun BarcodeScannerDialog(onBarcodeScanned: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = { },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
|
||||
text = {
|
||||
Box(modifier = Modifier.size(300.dp).clip(MaterialTheme.shapes.medium)) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
val previewView = PreviewView(ctx)
|
||||
val executor = ContextCompat.getMainExecutor(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
|
||||
val barcodeScanner = BarcodeScanning.getClient()
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also {
|
||||
it.setAnalyzer(executor) { imageProxy ->
|
||||
val mediaImage = imageProxy.image
|
||||
if (mediaImage != null) {
|
||||
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||
barcodeScanner.process(image)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
if (barcodes.isNotEmpty()) {
|
||||
barcodes[0].rawValue?.let { barcode ->
|
||||
onBarcodeScanned(barcode)
|
||||
cameraProvider.unbindAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnCompleteListener { imageProxy.close() }
|
||||
} else {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis)
|
||||
} catch (e: Exception) {
|
||||
Log.e("BarcodeScanner", "Camera binding failed", e)
|
||||
}
|
||||
}, executor)
|
||||
previewView
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.border(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
449
app/src/main/java/com/example/scanwich/CaptureScreen.kt
Normal file
449
app/src/main/java/com/example/scanwich/CaptureScreen.kt
Normal file
@@ -0,0 +1,449 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import com.example.scanwich.FirebaseUtils.syncMealToFirestore
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CaptureScreen(dao: AppDao) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
var capturedBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var isAnalyzing by remember { mutableStateOf(false) }
|
||||
var currentMealData by remember { mutableStateOf<Triple<String, String, List<Int>>?>(null) }
|
||||
var mealDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
var showBarcodeScanner by remember { mutableStateOf(false) }
|
||||
|
||||
var manualMealName by remember { mutableStateOf("") }
|
||||
var showFavoritesSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
|
||||
if (bitmap != null) {
|
||||
capturedBitmap = bitmap
|
||||
mealDateTime = System.currentTimeMillis()
|
||||
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, _ ->
|
||||
if (data != null) {
|
||||
currentMealData = data
|
||||
showBottomSheet = true
|
||||
} else {
|
||||
Toast.makeText(context, "Analyse échouée", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) cameraLauncher.launch(null)
|
||||
else Toast.makeText(context, "Permission caméra requise", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
val barcodePermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) showBarcodeScanner = true
|
||||
else Toast.makeText(context, "Permission caméra requise pour le scan", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(it)
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||
capturedBitmap = bitmap
|
||||
|
||||
val exifStream = context.contentResolver.openInputStream(it)
|
||||
if (exifStream != null) {
|
||||
val exif = ExifInterface(exifStream)
|
||||
val dateStr = exif.getAttribute(ExifInterface.TAG_DATETIME)
|
||||
mealDateTime = if (dateStr != null) {
|
||||
SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.getDefault()).parse(dateStr)?.time ?: System.currentTimeMillis()
|
||||
} else {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
exifStream.close()
|
||||
}
|
||||
|
||||
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, _ ->
|
||||
if (data != null) {
|
||||
currentMealData = data
|
||||
showBottomSheet = true
|
||||
} else {
|
||||
Toast.makeText(context, "L'IA n'a pas pu identifier le repas.", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, coroutineScope)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "Erreur lors du chargement : ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBarcodeScanner) {
|
||||
BarcodeScannerDialog(
|
||||
onBarcodeScanned = { barcode ->
|
||||
showBarcodeScanner = false
|
||||
isAnalyzing = true
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
val response = ApiClient.offApi.getProduct(barcode)
|
||||
if (response.status == 1 && response.product != null) {
|
||||
val p = response.product
|
||||
val nut = p.nutriments
|
||||
currentMealData = Triple(
|
||||
p.productName ?: "Produit inconnu",
|
||||
"Scanné via OpenFoodFacts",
|
||||
listOf(
|
||||
nut?.energyKcal?.toInt() ?: 0,
|
||||
nut?.carbs?.toInt() ?: 0,
|
||||
nut?.proteins?.toInt() ?: 0,
|
||||
nut?.fat?.toInt() ?: 0
|
||||
)
|
||||
)
|
||||
mealDateTime = System.currentTimeMillis()
|
||||
showBottomSheet = true
|
||||
} else {
|
||||
Toast.makeText(context, "Produit non trouvé", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "Erreur API: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||
} finally {
|
||||
isAnalyzing = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = { showBarcodeScanner = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showFavoritesSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showFavoritesSheet = false },
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
val favMeals by dao.getAllFavorites().collectAsState(initial = emptyList())
|
||||
Column(modifier = Modifier.padding(16.dp).fillMaxHeight(0.6f)) {
|
||||
Text("Mes Favoris", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (favMeals.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Aucun favori enregistré", color = Color.Gray)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(favMeals) { fav ->
|
||||
ListItem(
|
||||
headlineContent = { Text(fav.name) },
|
||||
supportingContent = { Text("${fav.calories} kcal - G:${fav.carbs} P:${fav.protein} L:${fav.fat}") },
|
||||
trailingContent = {
|
||||
IconButton(onClick = {
|
||||
currentMealData = Triple(fav.name, fav.analysisText, listOf(fav.calories, fav.carbs, fav.protein, fav.fat))
|
||||
mealDateTime = System.currentTimeMillis()
|
||||
showFavoritesSheet = false
|
||||
showBottomSheet = true
|
||||
}) { Icon(Icons.Default.Add, null) }
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
currentMealData = Triple(fav.name, fav.analysisText, listOf(fav.calories, fav.carbs, fav.protein, fav.fat))
|
||||
mealDateTime = System.currentTimeMillis()
|
||||
showFavoritesSheet = false
|
||||
showBottomSheet = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet && currentMealData != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxHeight(0.85f)
|
||||
) {
|
||||
var mealType by remember { mutableStateOf("Déjeuner") }
|
||||
val calendar = Calendar.getInstance().apply { timeInMillis = mealDateTime }
|
||||
|
||||
var editableName by remember { mutableStateOf(currentMealData!!.first) }
|
||||
var editableDesc by remember { mutableStateOf(currentMealData!!.second) }
|
||||
|
||||
val mealValues = currentMealData!!.third
|
||||
val editableCalories = mealValues[0].toString()
|
||||
val editableCarbs = mealValues[1].toString()
|
||||
val editableProtein = mealValues[2].toString()
|
||||
val editableFat = mealValues[3].toString()
|
||||
|
||||
LaunchedEffect(currentMealData) {
|
||||
editableName = currentMealData!!.first
|
||||
editableDesc = currentMealData!!.second
|
||||
}
|
||||
|
||||
Column(modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text("Résumé du repas", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = editableName,
|
||||
onValueChange = { editableName = it },
|
||||
label = { Text("Nom du repas") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = editableCalories, onValueChange = { }, label = { Text("Cal") }, modifier = Modifier.weight(1f), readOnly = true)
|
||||
OutlinedTextField(value = editableCarbs, onValueChange = { }, label = { Text("Glu") }, modifier = Modifier.weight(1f), readOnly = true)
|
||||
OutlinedTextField(value = editableProtein, onValueChange = { }, label = { Text("Pro") }, modifier = Modifier.weight(1f), readOnly = true)
|
||||
OutlinedTextField(value = editableFat, onValueChange = { }, label = { Text("Lip") }, modifier = Modifier.weight(1f), readOnly = true)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = editableDesc,
|
||||
onValueChange = { editableDesc = it },
|
||||
label = { Text("Description / Précisions pour l'IA") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, _ ->
|
||||
if (data != null) {
|
||||
currentMealData = data
|
||||
} else {
|
||||
Toast.makeText(context, "Analyse échouée", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, coroutineScope)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
|
||||
enabled = !isAnalyzing
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Ressoumettre")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
dao.insertFavorite(FavoriteMeal(
|
||||
name = editableName,
|
||||
analysisText = editableDesc,
|
||||
calories = editableCalories.toIntOrNull() ?: 0,
|
||||
carbs = editableCarbs.toIntOrNull() ?: 0,
|
||||
protein = editableProtein.toIntOrNull() ?: 0,
|
||||
fat = editableFat.toIntOrNull() ?: 0
|
||||
))
|
||||
Toast.makeText(context, "Ajouté aux favoris !", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !isAnalyzing
|
||||
) {
|
||||
Icon(Icons.Default.Favorite, null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Favori")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Catégorie :", fontWeight = FontWeight.Bold)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
listOf("Déjeuner", "Dîner", "Souper", "Collation").forEach { type ->
|
||||
FilterChip(selected = mealType == type, onClick = { mealType = type }, label = { Text(type) })
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = {
|
||||
DatePickerDialog(context, { _, y, m, d ->
|
||||
calendar.set(y, m, d)
|
||||
TimePickerDialog(context, { _, hh, mm ->
|
||||
calendar.set(Calendar.HOUR_OF_DAY, hh)
|
||||
calendar.set(Calendar.MINUTE, mm)
|
||||
mealDateTime = calendar.timeInMillis
|
||||
}, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true).show()
|
||||
}, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show()
|
||||
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) {
|
||||
Icon(Icons.Default.DateRange, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
val formattedDate = SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(mealDateTime))
|
||||
Text("Date/Heure: $formattedDate")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
val meal = Meal(
|
||||
date = mealDateTime,
|
||||
name = editableName,
|
||||
analysisText = editableDesc,
|
||||
totalCalories = editableCalories.toIntOrNull() ?: 0,
|
||||
carbs = editableCarbs.toIntOrNull() ?: 0,
|
||||
protein = editableProtein.toIntOrNull() ?: 0,
|
||||
fat = editableFat.toIntOrNull() ?: 0,
|
||||
type = mealType
|
||||
)
|
||||
dao.insertMeal(meal)
|
||||
syncMealToFirestore(meal) // Firestore Sync
|
||||
showBottomSheet = false
|
||||
capturedBitmap = null
|
||||
Toast.makeText(context, "Repas enregistré !", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
enabled = !isAnalyzing
|
||||
) {
|
||||
Icon(Icons.Default.Check, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Confirmer et Enregistrer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
|
||||
Text("Scan-Wich Analysis", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
cameraLauncher.launch(null)
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Add, null); Text(" Caméra") }
|
||||
|
||||
Button(onClick = {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
showBarcodeScanner = true
|
||||
} else {
|
||||
barcodePermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}, modifier = Modifier.weight(1f)) { Icon(Icons.Default.QrCodeScanner, null); Text(" Barcode") }
|
||||
|
||||
Button(onClick = { galleryLauncher.launch("image/*") }, modifier = Modifier.weight(1f)) { Icon(Icons.Default.Share, null); Text(" Galerie") }
|
||||
}
|
||||
|
||||
capturedBitmap?.let {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Image sélectionnée :", style = MaterialTheme.typography.labelMedium)
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().height(250.dp).clip(MaterialTheme.shapes.medium).background(Color.Gray)
|
||||
)
|
||||
}
|
||||
|
||||
if (isAnalyzing) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||
CircularProgressIndicator()
|
||||
Text("Analyse en cours...", modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = { showFavoritesSheet = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||
) {
|
||||
Icon(Icons.Default.Favorite, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Utiliser un Favori")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Analyse par texte", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
OutlinedTextField(
|
||||
value = manualMealName,
|
||||
onValueChange = { manualMealName = it },
|
||||
label = { Text("Qu'avez-vous mangé ?") },
|
||||
placeholder = { Text("ex: Un sandwich au poulet et une pomme") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
analyzeImage(null, manualMealName, { isAnalyzing = it }, { data, _ ->
|
||||
if (data != null) {
|
||||
currentMealData = data
|
||||
showBottomSheet = true
|
||||
} else {
|
||||
Toast.makeText(context, "Erreur IA", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, coroutineScope)
|
||||
},
|
||||
enabled = manualMealName.isNotBlank() && !isAnalyzing,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, null)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Analyser via IA")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
currentMealData = Triple(manualMealName, "Ajout manuel", listOf(0, 0, 0, 0))
|
||||
mealDateTime = System.currentTimeMillis()
|
||||
showBottomSheet = true
|
||||
},
|
||||
enabled = manualMealName.isNotBlank() && !isAnalyzing,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Direct (0 kcal)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,69 +2,99 @@ package com.example.scanwich
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.*
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Entity(tableName = "meals")
|
||||
data class Meal(
|
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0,
|
||||
val date: Long,
|
||||
@PrimaryKey val date: Long = 0, // La date devient la clé unique pour éviter les doublons
|
||||
val name: String = "Repas",
|
||||
val analysisText: String,
|
||||
val totalCalories: Int,
|
||||
val analysisText: String = "",
|
||||
val totalCalories: Int = 0,
|
||||
val carbs: Int = 0,
|
||||
val protein: Int = 0,
|
||||
val fat: Int = 0,
|
||||
val type: String = "Collation"
|
||||
val type: String = "Collation",
|
||||
val id: Int = 0 // On garde le champ id pour la compatibilité mais il n'est plus PrimaryKey
|
||||
)
|
||||
|
||||
@Entity(tableName = "glycemia")
|
||||
data class Glycemia(
|
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0,
|
||||
val date: Long,
|
||||
val value: Double,
|
||||
val moment: String
|
||||
@PrimaryKey val date: Long = 0, // La date devient la clé unique
|
||||
val value: Double = 0.0,
|
||||
val moment: String = "",
|
||||
val id: Int = 0
|
||||
)
|
||||
|
||||
@Entity(tableName = "sports")
|
||||
data class SportActivity(
|
||||
@PrimaryKey val id: Long,
|
||||
val name: String,
|
||||
val type: String,
|
||||
val distance: Float,
|
||||
val movingTime: Int,
|
||||
val calories: Float?,
|
||||
val date: Long // timestamp
|
||||
@PrimaryKey val id: Long = 0, // Strava fournit déjà un ID unique
|
||||
val name: String = "",
|
||||
val type: String = "",
|
||||
val distance: Float = 0f,
|
||||
val movingTime: Int = 0,
|
||||
val calories: Float? = null,
|
||||
val date: Long = 0
|
||||
)
|
||||
|
||||
@Entity(tableName = "favorite_meals")
|
||||
data class FavoriteMeal(
|
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0,
|
||||
val name: String = "",
|
||||
val analysisText: String = "",
|
||||
val calories: Int = 0,
|
||||
val carbs: Int = 0,
|
||||
val protein: Int = 0,
|
||||
val fat: Int = 0
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface AppDao {
|
||||
@Insert suspend fun insertMeal(meal: Meal): Long
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMeal(meal: Meal): Long
|
||||
|
||||
@Delete suspend fun deleteMeal(meal: Meal)
|
||||
@Query("SELECT * FROM meals ORDER BY date DESC") fun getAllMeals(): Flow<List<Meal>>
|
||||
@Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
||||
fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>>
|
||||
@Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
||||
suspend fun getMealsInRangeSync(start: Long, end: Long): List<Meal>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertGlycemia(glycemia: Glycemia): Long
|
||||
|
||||
@Insert suspend fun insertGlycemia(glycemia: Glycemia): Long
|
||||
@Delete suspend fun deleteGlycemia(glycemia: Glycemia)
|
||||
@Query("SELECT * FROM glycemia ORDER BY date DESC") fun getAllGlycemia(): Flow<List<Glycemia>>
|
||||
@Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
||||
fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>>
|
||||
@Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
||||
suspend fun getGlycemiaInRangeSync(start: Long, end: Long): List<Glycemia>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertSports(sports: List<SportActivity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List<SportActivity>)
|
||||
@Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow<List<SportActivity>>
|
||||
@Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
|
||||
fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>>
|
||||
@Query("SELECT * FROM sports WHERE date >= :start AND date <= :end ORDER BY date ASC")
|
||||
suspend fun getSportsInRangeSync(start: Long, end: Long): List<SportActivity>
|
||||
|
||||
@Insert suspend fun insertFavorite(meal: FavoriteMeal)
|
||||
@Query("SELECT * FROM favorite_meals ORDER BY name ASC") fun getAllFavorites(): Flow<List<FavoriteMeal>>
|
||||
|
||||
@Query("SELECT date FROM meals UNION SELECT date FROM sports UNION SELECT date FROM glycemia")
|
||||
fun getAllDatesWithData(): Flow<List<Long>>
|
||||
}
|
||||
|
||||
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class], version = 6)
|
||||
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 8) // Version incrémentée
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun appDao(): AppDao
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): AppDatabase =
|
||||
INSTANCE ?: synchronized(this) {
|
||||
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
|
||||
.fallbackToDestructiveMigration()
|
||||
.fallbackToDestructiveMigration() // Ceci va vider la base locale une seule fois pour appliquer le changement
|
||||
.build().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
|
||||
109
app/src/main/java/com/example/scanwich/FirebaseUtils.kt
Normal file
109
app/src/main/java/com/example/scanwich/FirebaseUtils.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import com.google.firebase.firestore.SetOptions
|
||||
import kotlinx.coroutines.tasks.await
|
||||
|
||||
object FirebaseUtils {
|
||||
private fun getDb(): FirebaseFirestore {
|
||||
return try {
|
||||
FirebaseFirestore.getInstance("scan-wich")
|
||||
} catch (e: Exception) {
|
||||
FirebaseFirestore.getInstance()
|
||||
}
|
||||
}
|
||||
|
||||
fun syncMealToFirestore(meal: Meal) {
|
||||
val user = FirebaseAuth.getInstance().currentUser
|
||||
if (user != null) {
|
||||
getDb().collection("users").document(user.uid).collection("meals")
|
||||
.document(meal.date.toString())
|
||||
.set(meal, SetOptions.merge())
|
||||
.addOnSuccessListener { Log.d("Firestore", "Meal synced") }
|
||||
.addOnFailureListener { e -> Log.e("Firestore", "Error meal: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun syncGlycemiaToFirestore(glycemia: Glycemia) {
|
||||
val user = FirebaseAuth.getInstance().currentUser
|
||||
if (user != null) {
|
||||
getDb().collection("users").document(user.uid).collection("glycemia")
|
||||
.document(glycemia.date.toString())
|
||||
.set(glycemia, SetOptions.merge())
|
||||
.addOnSuccessListener { Log.d("Firestore", "Glycemia synced") }
|
||||
.addOnFailureListener { e -> Log.e("Firestore", "Error glycemia: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
fun syncSportToFirestore(sport: SportActivity) {
|
||||
val user = FirebaseAuth.getInstance().currentUser
|
||||
if (user != null) {
|
||||
getDb().collection("users").document(user.uid).collection("sports")
|
||||
.document(sport.id.toString())
|
||||
.set(sport, SetOptions.merge())
|
||||
.addOnSuccessListener { Log.d("Firestore", "Sport synced") }
|
||||
.addOnFailureListener { e -> Log.e("Firestore", "Error sport: ${e.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAllDataFromFirestore(dao: AppDao, prefs: SharedPreferences) {
|
||||
val user = FirebaseAuth.getInstance().currentUser ?: return
|
||||
val db = getDb()
|
||||
val userDoc = db.collection("users").document(user.uid)
|
||||
|
||||
try {
|
||||
Log.d("FirestoreSync", "Début de la récupération pour l'UID: ${user.uid}")
|
||||
|
||||
// 1. Profil
|
||||
val profileSnapshot = userDoc.get().await()
|
||||
if (profileSnapshot.exists()) {
|
||||
prefs.edit {
|
||||
profileSnapshot.get("target_calories")?.let { putString("target_calories", it.toString()) }
|
||||
profileSnapshot.get("target_carbs")?.let { putString("target_carbs", it.toString()) }
|
||||
profileSnapshot.get("target_protein")?.let { putString("target_protein", it.toString()) }
|
||||
profileSnapshot.get("target_fat")?.let { putString("target_fat", it.toString()) }
|
||||
profileSnapshot.get("weight_kg")?.let { putString("weight_kg", it.toString()) }
|
||||
profileSnapshot.getBoolean("is_lbs")?.let { putBoolean("is_lbs", it) }
|
||||
profileSnapshot.get("height_cm")?.let { putString("height_cm", it.toString()) }
|
||||
profileSnapshot.getBoolean("is_diabetic")?.let { putBoolean("is_diabetic", it) }
|
||||
(profileSnapshot.get("age") as? Number)?.let { putInt("age", it.toInt()) }
|
||||
profileSnapshot.getString("gender")?.let { putString("gender", it) }
|
||||
profileSnapshot.getString("activity_level")?.let { putString("activity_level", it) }
|
||||
profileSnapshot.getString("goal")?.let { putString("goal", it) }
|
||||
}
|
||||
Log.d("FirestoreSync", "Profil récupéré avec succès")
|
||||
}
|
||||
|
||||
// 2. Repas
|
||||
val mealsSnapshot = userDoc.collection("meals").get().await()
|
||||
if (!mealsSnapshot.isEmpty) {
|
||||
val meals = mealsSnapshot.toObjects(Meal::class.java)
|
||||
meals.forEach { dao.insertMeal(it) }
|
||||
Log.d("FirestoreSync", "${meals.size} repas insérés dans la base locale")
|
||||
}
|
||||
|
||||
// 3. Glycémie
|
||||
val glycemiaSnapshot = userDoc.collection("glycemia").get().await()
|
||||
if (!glycemiaSnapshot.isEmpty) {
|
||||
val glycemia = glycemiaSnapshot.toObjects(Glycemia::class.java)
|
||||
glycemia.forEach { dao.insertGlycemia(it) }
|
||||
Log.d("FirestoreSync", "${glycemia.size} relevés de glycémie insérés")
|
||||
}
|
||||
|
||||
// 4. Sport
|
||||
val sportsSnapshot = userDoc.collection("sports").get().await()
|
||||
if (!sportsSnapshot.isEmpty) {
|
||||
val sports = sportsSnapshot.toObjects(SportActivity::class.java)
|
||||
dao.insertSports(sports)
|
||||
Log.d("FirestoreSync", "${sports.size} activités sportives insérées")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("FirestoreSync", "ERREUR CRITIQUE lors du rapatriement: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
93
app/src/main/java/com/example/scanwich/GlycemiaScreen.kt
Normal file
93
app/src/main/java/com/example/scanwich/GlycemiaScreen.kt
Normal file
@@ -0,0 +1,93 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.scanwich.FirebaseUtils.syncGlycemiaToFirestore
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import android.app.DatePickerDialog
|
||||
import android.app.TimePickerDialog
|
||||
import android.widget.Toast
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun GlycemiaScreen(dao: AppDao) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var glycemiaValue by remember { mutableStateOf("") }
|
||||
var moment by remember { mutableStateOf("Avant Déjeuner") }
|
||||
var selectedDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
val calendar = Calendar.getInstance().apply { timeInMillis = selectedDateTime }
|
||||
|
||||
val moments = listOf(
|
||||
"Avant Déjeuner", "Après Déjeuner",
|
||||
"Avant Dîner", "Après Dîner",
|
||||
"Avant Souper", "Après Souper"
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
|
||||
Text("Suivi de Glycémie", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = glycemiaValue,
|
||||
onValueChange = { glycemiaValue = it },
|
||||
label = { Text("Valeur (mmol/L)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Moment :", style = MaterialTheme.typography.titleMedium)
|
||||
moments.forEach { m ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { moment = m }) {
|
||||
RadioButton(selected = moment == m, onClick = { moment = m })
|
||||
Text(m)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = {
|
||||
DatePickerDialog(context, { _, y, m, d ->
|
||||
calendar.set(y, m, d)
|
||||
TimePickerDialog(context, { _, hh, mm ->
|
||||
calendar.set(Calendar.HOUR_OF_DAY, hh)
|
||||
calendar.set(Calendar.MINUTE, mm)
|
||||
selectedDateTime = calendar.timeInMillis
|
||||
}, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true).show()
|
||||
}, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show()
|
||||
}, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Date/Heure: " + SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(selectedDateTime)))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val value = glycemiaValue.toDoubleOrNull()
|
||||
if (value != null) {
|
||||
coroutineScope.launch {
|
||||
val glycemia = Glycemia(date = selectedDateTime, value = value, moment = moment)
|
||||
dao.insertGlycemia(glycemia)
|
||||
syncGlycemiaToFirestore(glycemia) // Firestore Sync
|
||||
glycemiaValue = ""
|
||||
Toast.makeText(context, "Glycémie enregistrée !", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = glycemiaValue.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Enregistrer")
|
||||
}
|
||||
}
|
||||
}
|
||||
543
app/src/main/java/com/example/scanwich/HistoryScreen.kt
Normal file
543
app/src/main/java/com/example/scanwich/HistoryScreen.kt
Normal file
@@ -0,0 +1,543 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.scanwich.ui.theme.ReadableAmber
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) {
|
||||
val progress = if (target > 0) current.toFloat() / target else 0f
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(80.dp)) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(60.dp)) {
|
||||
CircularProgressIndicator(
|
||||
progress = { 1f },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = color.copy(alpha = 0.2f),
|
||||
strokeWidth = 6.dp,
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
CircularProgressIndicator(
|
||||
progress = { progress.coerceAtMost(1f) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = color,
|
||||
strokeWidth = 6.dp,
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
Text(
|
||||
text = "${(progress * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(text = label, style = MaterialTheme.typography.labelMedium)
|
||||
Text(text = "$current / $target", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
|
||||
var selectedDate by remember { mutableStateOf(Calendar.getInstance()) }
|
||||
val datesWithData by dao.getAllDatesWithData().collectAsState(initial = emptyList())
|
||||
var showMonthPicker by remember { mutableStateOf(false) }
|
||||
|
||||
val normalizedDatesWithData = remember(datesWithData) {
|
||||
datesWithData.map { timestamp ->
|
||||
val cal = Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||
cal.set(Calendar.HOUR_OF_DAY, 0)
|
||||
cal.set(Calendar.MINUTE, 0)
|
||||
cal.set(Calendar.SECOND, 0)
|
||||
cal.set(Calendar.MILLISECOND, 0)
|
||||
cal.timeInMillis
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
val startOfDay = selectedDate.clone() as Calendar
|
||||
startOfDay.set(Calendar.HOUR_OF_DAY, 0); startOfDay.set(Calendar.MINUTE, 0); startOfDay.set(Calendar.SECOND, 0); startOfDay.set(Calendar.MILLISECOND, 0)
|
||||
val endOfDay = startOfDay.clone() as Calendar
|
||||
endOfDay.add(Calendar.DAY_OF_MONTH, 1)
|
||||
|
||||
val meals by dao.getMealsForDay(startOfDay.timeInMillis, endOfDay.timeInMillis).collectAsState(initial = emptyList())
|
||||
val sports by dao.getSportsForDay(startOfDay.timeInMillis, endOfDay.timeInMillis).collectAsState(initial = emptyList())
|
||||
val glycemiaList by dao.getGlycemiaForDay(startOfDay.timeInMillis, endOfDay.timeInMillis).collectAsState(initial = emptyList())
|
||||
|
||||
val isDiabetic = prefs.getBoolean("is_diabetic", false)
|
||||
var selectedMealForDetail by remember { mutableStateOf<Meal?>(null) }
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val tCal = prefs.getString("target_calories", "2000")?.toIntOrNull() ?: 2000
|
||||
val tCarb = prefs.getString("target_carbs", "250")?.toIntOrNull() ?: 250
|
||||
val tProt = prefs.getString("target_protein", "100")?.toIntOrNull() ?: 100
|
||||
val tFat = prefs.getString("target_fat", "60")?.toIntOrNull() ?: 60
|
||||
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val calorieColor = if (isDark) Color(0xFF4CAF50) else Color(0xFF2E7D32)
|
||||
val carbsColor = if (isDark) Color(0xFF2196F3) else Color(0xFF1565C0)
|
||||
val proteinColor = if (isDark) Color(0xFFFF9800) else ReadableAmber
|
||||
val fatColor = if (isDark) Color(0xFFE91E63) else Color(0xFFC2185B)
|
||||
|
||||
var showExportDialog by remember { mutableStateOf(false) }
|
||||
var exportStartDate by remember { mutableStateOf(Calendar.getInstance()) }
|
||||
var exportEndDate by remember { mutableStateOf(Calendar.getInstance()) }
|
||||
|
||||
val createPdfLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri ->
|
||||
uri?.let {
|
||||
coroutineScope.launch {
|
||||
val start = exportStartDate.clone() as Calendar
|
||||
start.set(Calendar.HOUR_OF_DAY, 0); start.set(Calendar.MINUTE, 0); start.set(Calendar.SECOND, 0)
|
||||
val end = exportEndDate.clone() as Calendar
|
||||
end.set(Calendar.HOUR_OF_DAY, 23); end.set(Calendar.MINUTE, 59); end.set(Calendar.SECOND, 59)
|
||||
|
||||
val mealsToExport = dao.getMealsInRangeSync(start.timeInMillis, end.timeInMillis)
|
||||
val sportsToExport = dao.getSportsInRangeSync(start.timeInMillis, end.timeInMillis)
|
||||
val glycemiaToExport = dao.getGlycemiaInRangeSync(start.timeInMillis, end.timeInMillis)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(it)?.use { os ->
|
||||
generatePdfReport(os, mealsToExport, sportsToExport, glycemiaToExport, start.time, end.time)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "PDF Exporté avec succès !", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "Erreur PDF: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showMonthPicker) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showMonthPicker = false },
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
IconButton(onClick = {
|
||||
val newDate = selectedDate.clone() as Calendar
|
||||
newDate.add(Calendar.MONTH, -1)
|
||||
selectedDate = newDate
|
||||
}) { Icon(Icons.Default.ArrowBack, null) }
|
||||
Text(SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(selectedDate.time))
|
||||
IconButton(onClick = {
|
||||
val newDate = selectedDate.clone() as Calendar
|
||||
newDate.add(Calendar.MONTH, 1)
|
||||
selectedDate = newDate
|
||||
}) { Icon(Icons.Default.ArrowForward, null) }
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val daysOfWeek = listOf("L", "M", "M", "J", "V", "S", "D")
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
daysOfWeek.forEach { day ->
|
||||
Text(day, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(32.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
val cal = selectedDate.clone() as Calendar
|
||||
cal.set(Calendar.DAY_OF_MONTH, 1)
|
||||
val firstDayIdx = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7
|
||||
val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
|
||||
|
||||
val gridItems = mutableListOf<Int?>()
|
||||
repeat(firstDayIdx) { gridItems.add(null) }
|
||||
for (i in 1..daysInMonth) { gridItems.add(i) }
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(7),
|
||||
modifier = Modifier.height(250.dp).padding(top = 8.dp)
|
||||
) {
|
||||
items(gridItems) { day ->
|
||||
if (day != null) {
|
||||
val dayCal = selectedDate.clone() as Calendar
|
||||
dayCal.set(Calendar.DAY_OF_MONTH, day)
|
||||
dayCal.set(Calendar.HOUR_OF_DAY, 0); dayCal.set(Calendar.MINUTE, 0); dayCal.set(Calendar.SECOND, 0); dayCal.set(Calendar.MILLISECOND, 0)
|
||||
val hasData = normalizedDatesWithData.contains(dayCal.timeInMillis)
|
||||
val isSelected = day == selectedDate.get(Calendar.DAY_OF_MONTH)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||
.clickable {
|
||||
selectedDate = dayCal
|
||||
showMonthPicker = false
|
||||
}
|
||||
.padding(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = day.toString(),
|
||||
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
if (hasData) {
|
||||
Box(Modifier.size(4.dp).clip(CircleShape).background(if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { showMonthPicker = false }) { Text("Fermer") } }
|
||||
)
|
||||
}
|
||||
|
||||
if (showExportDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExportDialog = false },
|
||||
title = { Text("Exporter l'historique") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Sélectionnez la plage de dates :")
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = {
|
||||
DatePickerDialog(context, { _, y, m, d ->
|
||||
val newDate = Calendar.getInstance()
|
||||
newDate.set(y, m, d)
|
||||
exportStartDate = newDate
|
||||
}, exportStartDate.get(Calendar.YEAR), exportStartDate.get(Calendar.MONTH), exportStartDate.get(Calendar.DAY_OF_MONTH)).show()
|
||||
}, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Du: " + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportStartDate.time))
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
DatePickerDialog(context, { _, y, m, d ->
|
||||
val newDate = Calendar.getInstance()
|
||||
newDate.set(y, m, d)
|
||||
exportEndDate = newDate
|
||||
}, exportEndDate.get(Calendar.YEAR), exportEndDate.get(Calendar.MONTH), exportEndDate.get(Calendar.DAY_OF_MONTH)).show()
|
||||
}, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Au: " + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportEndDate.time))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showExportDialog = false
|
||||
val fileName = "ScanWich_Rapport_${SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportStartDate.time)}_au_${SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportEndDate.time)}.pdf"
|
||||
createPdfLauncher.launch(fileName)
|
||||
}) { Text("Exporter") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExportDialog = false }) { Text("Annuler") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedMealForDetail != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { selectedMealForDetail = null },
|
||||
title = { Text(selectedMealForDetail!!.name) },
|
||||
text = {
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 450.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text("Type: ${selectedMealForDetail!!.type}", fontWeight = FontWeight.Bold)
|
||||
Text("Calories: ${selectedMealForDetail!!.totalCalories} kcal")
|
||||
Text("Macro: G ${selectedMealForDetail!!.carbs}g | P ${selectedMealForDetail!!.protein}g | L ${selectedMealForDetail!!.fat}g")
|
||||
Text("Heure: ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(selectedMealForDetail!!.date))}")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Analyse complète :", style = MaterialTheme.typography.labelMedium)
|
||||
Text(selectedMealForDetail!!.analysisText)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { selectedMealForDetail = null }) { Text("Fermer") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
IconButton(onClick = {
|
||||
val newDate = selectedDate.clone() as Calendar
|
||||
newDate.add(Calendar.DAY_OF_MONTH, -1)
|
||||
selectedDate = newDate
|
||||
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, null) }
|
||||
|
||||
Column(modifier = Modifier.weight(1f).clickable { showMonthPicker = true }) {
|
||||
Text(
|
||||
text = SimpleDateFormat("EEEE d MMMM", Locale.getDefault()).format(selectedDate.time).replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "Cliquer pour voir le calendrier",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { showExportDialog = true }) {
|
||||
Icon(Icons.Default.PictureAsPdf, contentDescription = "Export PDF", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
val newDate = selectedDate.clone() as Calendar
|
||||
newDate.add(Calendar.DAY_OF_MONTH, 1)
|
||||
selectedDate = newDate
|
||||
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) }
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val totalIn = meals.sumOf { it.totalCalories }
|
||||
val totalOut = sports.sumOf { it.calories?.toInt() ?: 0 }
|
||||
val netTotal = totalIn - totalOut
|
||||
val totalCarbs = meals.sumOf { it.carbs }
|
||||
val totalProt = meals.sumOf { it.protein }
|
||||
val totalFat = meals.sumOf { it.fat }
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Résumé Calorique", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Mangé", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
|
||||
Text("$totalIn", style = MaterialTheme.typography.titleLarge, color = calorieColor, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("-", style = MaterialTheme.typography.headlineMedium, color = Color.Gray)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Dépensé", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
|
||||
Text("$totalOut", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("=", style = MaterialTheme.typography.headlineMedium, color = Color.Gray)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Total Net", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
|
||||
Text("$netTotal", style = MaterialTheme.typography.titleLarge, color = if(netTotal <= tCal) calorieColor else Color.Red, fontWeight = FontWeight.ExtraBold)
|
||||
}
|
||||
}
|
||||
Text("kcal", style = MaterialTheme.typography.labelSmall, modifier = Modifier.align(Alignment.End), color = Color.Gray)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text("Objectifs Quotidiens", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||
DailyGoalChart("Calories", totalIn, tCal, calorieColor)
|
||||
DailyGoalChart("Glucides", totalCarbs, tCarb, carbsColor)
|
||||
DailyGoalChart("Protéines", totalProt, tProt, proteinColor)
|
||||
DailyGoalChart("Lipides", totalFat, tFat, fatColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
val mealCategories = listOf("Déjeuner", "Dîner", "Souper", "Collation")
|
||||
mealCategories.forEach { category ->
|
||||
val categoryMeals = meals.filter { it.type == category }
|
||||
val beforeGly = glycemiaList.find { it.moment == "Avant $category" }
|
||||
val afterGly = glycemiaList.find { it.moment == "Après $category" }
|
||||
|
||||
if (categoryMeals.isNotEmpty() || beforeGly != null || afterGly != null) {
|
||||
item {
|
||||
Text(category, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
|
||||
}
|
||||
|
||||
if (isDiabetic && beforeGly != null) {
|
||||
item {
|
||||
Surface(color = Color.Blue.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)) {
|
||||
Text("🩸 Glycémie Avant: ${beforeGly.value} mmol/L", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||
IconButton(onClick = {
|
||||
coroutineScope.launch {
|
||||
dao.deleteGlycemia(beforeGly)
|
||||
Toast.makeText(context, "Glycémie supprimée", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) { Icon(Icons.Default.Delete, null, modifier = Modifier.size(20.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(categoryMeals) { meal ->
|
||||
ListItem(
|
||||
headlineContent = { Text(meal.name) },
|
||||
supportingContent = { Text("${meal.totalCalories} kcal - G:${meal.carbs}g P:${meal.protein}g L:${meal.fat}g") },
|
||||
trailingContent = {
|
||||
IconButton(onClick = {
|
||||
coroutineScope.launch {
|
||||
dao.deleteMeal(meal)
|
||||
Toast.makeText(context, "Repas supprimé", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) { Icon(Icons.Default.Delete, null) }
|
||||
},
|
||||
modifier = Modifier.clickable { selectedMealForDetail = meal }
|
||||
)
|
||||
}
|
||||
|
||||
if (isDiabetic && afterGly != null) {
|
||||
item {
|
||||
Surface(color = Color.Green.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)) {
|
||||
Text("🩸 Glycémie Après: ${afterGly.value} mmol/L", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||
IconButton(onClick = {
|
||||
coroutineScope.launch {
|
||||
dao.deleteGlycemia(afterGly)
|
||||
Toast.makeText(context, "Glycémie supprimée", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) { Icon(Icons.Default.Delete, null, modifier = Modifier.size(20.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sports.isNotEmpty()) {
|
||||
item {
|
||||
Text("Activités Sportives", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 16.dp, bottom = 4.dp))
|
||||
}
|
||||
items(sports) { sport ->
|
||||
ListItem(
|
||||
headlineContent = { Text(sport.name) },
|
||||
supportingContent = { Text("${sport.type} - ${sport.calories?.toInt()} kcal brûlées") },
|
||||
trailingContent = { Icon(Icons.Default.Check, tint = Color.Green, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generatePdfReport(
|
||||
outputStream: java.io.OutputStream,
|
||||
meals: List<Meal>,
|
||||
sports: List<SportActivity>,
|
||||
glycemia: List<Glycemia>,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) {
|
||||
val writer = com.itextpdf.kernel.pdf.PdfWriter(outputStream)
|
||||
val pdf = com.itextpdf.kernel.pdf.PdfDocument(writer)
|
||||
val document = com.itextpdf.layout.Document(pdf)
|
||||
|
||||
val df = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
|
||||
val tf = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
|
||||
// Header
|
||||
document.add(com.itextpdf.layout.element.Paragraph("Rapport d'Historique Scan-Wich")
|
||||
.setTextAlignment(com.itextpdf.layout.properties.TextAlignment.CENTER)
|
||||
.setFontSize(20f)
|
||||
.setBold())
|
||||
|
||||
document.add(com.itextpdf.layout.element.Paragraph("Période: ${df.format(startDate)} au ${df.format(endDate)}")
|
||||
.setTextAlignment(com.itextpdf.layout.properties.TextAlignment.CENTER)
|
||||
.setFontSize(12f))
|
||||
|
||||
document.add(com.itextpdf.layout.element.Paragraph("\n"))
|
||||
|
||||
// Meals Table
|
||||
if (meals.isNotEmpty()) {
|
||||
document.add(com.itextpdf.layout.element.Paragraph("Repas").setBold().setFontSize(14f))
|
||||
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f, 1f, 1f, 1f, 1f))).useAllAvailableWidth()
|
||||
table.addHeaderCell("Date")
|
||||
table.addHeaderCell("Nom")
|
||||
table.addHeaderCell("Type")
|
||||
table.addHeaderCell("Kcal")
|
||||
table.addHeaderCell("Glu")
|
||||
table.addHeaderCell("Pro")
|
||||
table.addHeaderCell("Lip")
|
||||
|
||||
meals.forEach { meal ->
|
||||
table.addCell(df.format(Date(meal.date)) + " " + tf.format(Date(meal.date)))
|
||||
table.addCell(meal.name)
|
||||
table.addCell(meal.type)
|
||||
table.addCell(meal.totalCalories.toString())
|
||||
table.addCell(meal.carbs.toString())
|
||||
table.addCell(meal.protein.toString())
|
||||
table.addCell(meal.fat.toString())
|
||||
}
|
||||
document.add(table)
|
||||
document.add(com.itextpdf.layout.element.Paragraph("\n"))
|
||||
}
|
||||
|
||||
// Glycemia Table
|
||||
if (glycemia.isNotEmpty()) {
|
||||
document.add(com.itextpdf.layout.element.Paragraph("Glycémie").setBold().setFontSize(14f))
|
||||
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f))).useAllAvailableWidth()
|
||||
table.addHeaderCell("Date/Heure")
|
||||
table.addHeaderCell("Moment")
|
||||
table.addHeaderCell("Valeur (mmol/L)")
|
||||
|
||||
glycemia.forEach { gly ->
|
||||
table.addCell(df.format(Date(gly.date)) + " " + tf.format(Date(gly.date)))
|
||||
table.addCell(gly.moment)
|
||||
table.addCell(gly.value.toString())
|
||||
}
|
||||
document.add(table)
|
||||
document.add(com.itextpdf.layout.element.Paragraph("\n"))
|
||||
}
|
||||
|
||||
// Sports Table
|
||||
if (sports.isNotEmpty()) {
|
||||
document.add(com.itextpdf.layout.element.Paragraph("Activités Sportives").setBold().setFontSize(14f))
|
||||
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f, 2f))).useAllAvailableWidth()
|
||||
table.addHeaderCell("Date")
|
||||
table.addHeaderCell("Activité")
|
||||
table.addHeaderCell("Type")
|
||||
table.addHeaderCell("Calories")
|
||||
|
||||
sports.forEach { sport ->
|
||||
table.addCell(df.format(Date(sport.date)))
|
||||
table.addCell(sport.name)
|
||||
table.addCell(sport.type)
|
||||
table.addCell(sport.calories?.toInt()?.toString() ?: "0")
|
||||
}
|
||||
document.add(table)
|
||||
}
|
||||
|
||||
document.close()
|
||||
}
|
||||
31
app/src/main/java/com/example/scanwich/LoginScreen.kt
Normal file
31
app/src/main/java/com/example/scanwich/LoginScreen.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(onLoginClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text("Scan-Wich", style = MaterialTheme.typography.displayLarge, color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(onClick = onLoginClick, modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)) {
|
||||
Icon(Icons.Default.AccountCircle, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Se connecter avec Google")
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
80
app/src/main/java/com/example/scanwich/MainApp.kt
Normal file
80
app/src/main/java/com/example/scanwich/MainApp.kt
Normal file
@@ -0,0 +1,80 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
|
||||
@Composable
|
||||
fun MainApp(dao: AppDao, onLogout: () -> Unit, userId: String) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ApiClient.getEncryptedPrefs(context) }
|
||||
var showSetup by remember { mutableStateOf(!prefs.contains("target_calories")) }
|
||||
var isDiabetic by remember { mutableStateOf(prefs.getBoolean("is_diabetic", false)) }
|
||||
|
||||
if (showSetup) {
|
||||
SetupScreen(prefs, onComplete = { showSetup = false })
|
||||
} else {
|
||||
val navController = rememberNavController()
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Home, contentDescription = "Scan") },
|
||||
label = { Text("Scan") },
|
||||
selected = false,
|
||||
onClick = { navController.navigate("capture") }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.DateRange, contentDescription = "Historique") },
|
||||
label = { Text("Historique") },
|
||||
selected = false,
|
||||
onClick = { navController.navigate("history") }
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Add, contentDescription = "Sport") },
|
||||
label = { Text("Sport") },
|
||||
selected = false,
|
||||
onClick = { navController.navigate("sport") }
|
||||
)
|
||||
if (isDiabetic) {
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Favorite, contentDescription = "Glycémie") },
|
||||
label = { Text("Glycémie") },
|
||||
selected = false,
|
||||
onClick = { navController.navigate("glycemia") }
|
||||
)
|
||||
}
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Paramètres") },
|
||||
label = { Text("Paramètres") },
|
||||
selected = false,
|
||||
onClick = { navController.navigate("settings") }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(navController, "capture", Modifier.padding(innerPadding)) {
|
||||
composable("capture") { CaptureScreen(dao) }
|
||||
composable("history") { HistoryScreen(dao, prefs) }
|
||||
composable("sport") { SportScreen(dao, prefs) }
|
||||
composable("glycemia") { GlycemiaScreen(dao) }
|
||||
composable("settings") {
|
||||
SettingsScreen(prefs, onLogout) {
|
||||
isDiabetic = prefs.getBoolean("is_diabetic", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class MealReminderReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
NotificationHelper.scheduleReminders(context)
|
||||
return
|
||||
}
|
||||
|
||||
val mealType = intent.getStringExtra("meal_type") ?: "repas"
|
||||
showNotification(context, mealType)
|
||||
}
|
||||
|
||||
private fun showNotification(context: Context, mealType: String) {
|
||||
val channelId = "meal_reminders"
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
channelId,
|
||||
"Rappels de repas",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Notifications pour ne pas oublier d'entrer vos repas"
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val activityIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, activityIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info) // À remplacer par l'icône de l'app si dispo
|
||||
.setContentTitle("N'oubliez pas votre $mealType !")
|
||||
.setContentText("Prenez un moment pour enregistrer ce que vous avez mangé.")
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
val notificationId = when(mealType.lowercase()) {
|
||||
"déjeuner" -> 1
|
||||
"dîner" -> 2
|
||||
"souper" -> 3
|
||||
else -> 0
|
||||
}
|
||||
notificationManager.notify(notificationId, notification)
|
||||
}
|
||||
}
|
||||
190
app/src/main/java/com/example/scanwich/Networking.kt
Normal file
190
app/src/main/java/com/example/scanwich/Networking.kt
Normal file
@@ -0,0 +1,190 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.edit
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.google.firebase.Firebase
|
||||
import com.google.firebase.functions.functions
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import java.io.File
|
||||
|
||||
// --- OPEN FOOD FACTS API ---
|
||||
data class OffProductResponse(val status: Int, val product: OffProduct?)
|
||||
data class OffProduct(@SerializedName("product_name") val productName: String?, val nutriments: OffNutriments?)
|
||||
data class OffNutriments(
|
||||
@SerializedName("energy-kcal_100g") val energyKcal: Float?,
|
||||
@SerializedName("carbohydrates_100g") val carbs: Float?,
|
||||
@SerializedName("proteins_100g") val proteins: Float?,
|
||||
@SerializedName("fat_100g") val fat: Float?
|
||||
)
|
||||
|
||||
interface OffApi {
|
||||
@GET("product/{barcode}.json")
|
||||
suspend fun getProduct(@Path("barcode") barcode: String): OffProductResponse
|
||||
}
|
||||
|
||||
// --- STRAVA API ---
|
||||
data class StravaActivity(
|
||||
val id: Long, val name: String, val type: String, val distance: Float,
|
||||
@SerializedName("moving_time") val movingTime: Int, val calories: Float?,
|
||||
@SerializedName("start_date") val startDate: String
|
||||
)
|
||||
data class StravaTokenResponse(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("refresh_token") val refreshToken: String,
|
||||
@SerializedName("expires_at") val expiresAt: Long
|
||||
)
|
||||
|
||||
interface StravaApi {
|
||||
@GET("athlete/activities")
|
||||
suspend fun getActivities(@Header("Authorization") token: String): List<StravaActivity>
|
||||
@POST("oauth/token")
|
||||
suspend fun exchangeToken(@Query("client_id") clientId: String, @Query("client_secret") clientSecret: String, @Query("code") code: String, @Query("grant_type") grantType: String = "authorization_code"): StravaTokenResponse
|
||||
@POST("oauth/token")
|
||||
suspend fun refreshToken(@Query("client_id") clientId: String, @Query("client_secret") clientSecret: String, @Query("refresh_token") refreshToken: String, @Query("grant_type") grantType: String = "refresh_token"): StravaTokenResponse
|
||||
}
|
||||
|
||||
object ApiClient {
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(60, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val stravaApi: StravaApi = Retrofit.Builder()
|
||||
.baseUrl("https://www.strava.com/api/v3/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(StravaApi::class.java)
|
||||
|
||||
val offApi: OffApi = Retrofit.Builder()
|
||||
.baseUrl("https://fr.openfoodfacts.org/api/v2/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(OffApi::class.java)
|
||||
|
||||
private const val STRAVA_CLIENT_ID = "203805"
|
||||
private const val PREFS_FILENAME = "secure_user_prefs"
|
||||
|
||||
fun getEncryptedPrefs(context: Context): SharedPreferences {
|
||||
return try {
|
||||
createPrefs(context)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Security", "Erreur Keystore: ${e.message}. Réinitialisation...")
|
||||
try {
|
||||
// Version plus sûre pour effacer les prefs corrompues
|
||||
val prefsFile = File(context.filesDir.parent, "shared_prefs/$PREFS_FILENAME.xml")
|
||||
if (prefsFile.exists()) {
|
||||
prefsFile.delete()
|
||||
}
|
||||
createPrefs(context)
|
||||
} catch (e2: Exception) {
|
||||
context.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPrefs(context: Context): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_FILENAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getValidStravaToken(prefs: SharedPreferences): String? {
|
||||
val stravaToken = prefs.getString("strava_token", null) ?: return null
|
||||
val expiresAt = prefs.getLong("strava_expires_at", 0)
|
||||
|
||||
if (System.currentTimeMillis() / 1000 >= (expiresAt - 300)) {
|
||||
val refreshToken = prefs.getString("strava_refresh_token", null) ?: return null
|
||||
|
||||
return try {
|
||||
val functions = Firebase.functions
|
||||
val data = hashMapOf("refreshToken" to refreshToken)
|
||||
|
||||
val result = functions.getHttpsCallable("refreshStravaToken")
|
||||
.call(data)
|
||||
.await()
|
||||
|
||||
val res = result.data as? Map<*, *>
|
||||
if (res != null) {
|
||||
val newAccessToken = res["access_token"] as? String
|
||||
val newRefreshToken = res["refresh_token"] as? String
|
||||
val newExpiresAt = (res["expires_at"] as? Number)?.toLong() ?: 0L
|
||||
|
||||
if (newAccessToken != null && newRefreshToken != null) {
|
||||
prefs.edit {
|
||||
putString("strava_token", newAccessToken)
|
||||
putString("strava_refresh_token", newRefreshToken)
|
||||
putLong("strava_expires_at", newExpiresAt)
|
||||
}
|
||||
return newAccessToken
|
||||
}
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e("StravaAuth", "Refresh failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
return stravaToken
|
||||
}
|
||||
|
||||
fun parseStravaDate(dateString: String): Long {
|
||||
return try {
|
||||
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault())
|
||||
format.timeZone = TimeZone.getTimeZone("UTC")
|
||||
format.parse(dateString)?.time ?: System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
fun estimateCaloriesFromDb(activity: SportActivity, weightKg: Double): Double {
|
||||
val met = when (activity.type.lowercase(Locale.ROOT)) {
|
||||
"run" -> 9.8
|
||||
"walk" -> 3.5
|
||||
"ride", "ebikeride" -> 7.5
|
||||
"swim" -> 8.0
|
||||
"workout" -> 5.0
|
||||
"hike" -> 5.3
|
||||
"yoga" -> 2.5
|
||||
else -> 4.0
|
||||
}
|
||||
val durationHours = activity.movingTime / 3600.0
|
||||
return met * weightKg * durationHours
|
||||
}
|
||||
|
||||
fun launchStravaAuth(context: Context) {
|
||||
val intentUri = Uri.parse("https://www.strava.com/oauth/mobile/authorize")
|
||||
.buildUpon()
|
||||
.appendQueryParameter("client_id", STRAVA_CLIENT_ID)
|
||||
.appendQueryParameter("redirect_uri", "coloricam://localhost")
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.appendQueryParameter("approval_prompt", "auto")
|
||||
.appendQueryParameter("scope", "activity:read_all")
|
||||
.build()
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW, intentUri)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
48
app/src/main/java/com/example/scanwich/NotificationHelper.kt
Normal file
48
app/src/main/java/com/example/scanwich/NotificationHelper.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import java.util.*
|
||||
|
||||
object NotificationHelper {
|
||||
fun scheduleReminders(context: Context) {
|
||||
scheduleMealAlarm(context, 8, 30, "Déjeuner", 101)
|
||||
scheduleMealAlarm(context, 12, 30, "Dîner", 102)
|
||||
scheduleMealAlarm(context, 19, 30, "Souper", 103)
|
||||
}
|
||||
|
||||
private fun scheduleMealAlarm(context: Context, hour: Int, minute: Int, mealType: String, requestCode: Int) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, MealReminderReceiver::class.java).apply {
|
||||
putExtra("meal_type", mealType)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val calendar = Calendar.getInstance().apply {
|
||||
timeInMillis = System.currentTimeMillis()
|
||||
set(Calendar.HOUR_OF_DAY, hour)
|
||||
set(Calendar.MINUTE, minute)
|
||||
set(Calendar.SECOND, 0)
|
||||
|
||||
// Si l'heure est déjà passée, on programme pour demain
|
||||
if (before(Calendar.getInstance())) {
|
||||
add(Calendar.DATE, 1)
|
||||
}
|
||||
}
|
||||
|
||||
alarmManager.setInexactRepeating(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
calendar.timeInMillis,
|
||||
AlarmManager.INTERVAL_DAY,
|
||||
pendingIntent
|
||||
)
|
||||
}
|
||||
}
|
||||
92
app/src/main/java/com/example/scanwich/SettingsScreen.kt
Normal file
92
app/src/main/java/com/example/scanwich/SettingsScreen.kt
Normal file
@@ -0,0 +1,92 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) {
|
||||
var isEditing by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
// État réactif pour la connexion Strava
|
||||
var isStravaConnected by remember { mutableStateOf(prefs.contains("strava_token")) }
|
||||
|
||||
// Écouteur pour mettre à jour l'état si le token change
|
||||
DisposableEffect(prefs) {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { p, key ->
|
||||
if (key == "strava_token") {
|
||||
isStravaConnected = p.contains("strava_token")
|
||||
}
|
||||
}
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
onDispose {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
SetupScreen(prefs) {
|
||||
isEditing = false
|
||||
onProfileUpdated()
|
||||
}
|
||||
} else {
|
||||
val targetCals = prefs.getString("target_calories", "0")
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
|
||||
Text("Mon Profil", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
ProfileItem("Objectif", prefs.getString("goal", "") ?: "")
|
||||
ProfileItem("Cible Calorique", "$targetCals kcal")
|
||||
ProfileItem("Diabétique", if (prefs.getBoolean("is_diabetic", false)) "Oui" else "Non")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = { isEditing = true }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Edit, null); Text(" Modifier le profil") }
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text("Intégrations", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (isStravaConnected) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
prefs.edit {
|
||||
remove("strava_token")
|
||||
remove("strava_refresh_token")
|
||||
}
|
||||
// L'écouteur mettra `isStravaConnected` à jour automatiquement
|
||||
Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Déconnecter Strava (Connecté)")
|
||||
}
|
||||
} else {
|
||||
Text("Aucune intégration active. Allez dans l'onglet 'Sport' pour connecter Strava.", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { Text("Déconnexion") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileItem(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, fontWeight = FontWeight.Bold); Text(value)
|
||||
}
|
||||
}
|
||||
222
app/src/main/java/com/example/scanwich/SetupScreen.kt
Normal file
222
app/src/main/java/com/example/scanwich/SetupScreen.kt
Normal file
@@ -0,0 +1,222 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
import com.google.firebase.auth.FirebaseAuth
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
|
||||
@Composable
|
||||
fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
var age by remember { mutableStateOf(prefs.getInt("age", 25).toString()) }
|
||||
var heightCm by remember { mutableStateOf(prefs.getString("height_cm", "170") ?: "170") }
|
||||
var weight by remember { mutableStateOf(prefs.getString("weight_display", "70") ?: "70") }
|
||||
var isLbs by remember { mutableStateOf(prefs.getBoolean("is_lbs", false)) }
|
||||
var gender by remember { mutableStateOf(prefs.getString("gender", "Homme") ?: "Homme") }
|
||||
var activityLevel by remember { mutableStateOf(prefs.getString("activity_level", "Sédentaire") ?: "Sédentaire") }
|
||||
var goal by remember { mutableStateOf(prefs.getString("goal", "Maintenir le poids") ?: "Maintenir le poids") }
|
||||
var isDiabetic by remember { mutableStateOf(prefs.getBoolean("is_diabetic", false)) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val activityLevels = listOf("Sédentaire", "Légèrement actif", "Modérément actif", "Très actif", "Extrêmement actif")
|
||||
val goals = listOf("Maintenir le poids", "Perdre du poids")
|
||||
|
||||
val activityMultipliers = mapOf(
|
||||
"Sédentaire" to 1.2,
|
||||
"Légèrement actif" to 1.375,
|
||||
"Modérément actif" to 1.55,
|
||||
"Très actif" to 1.725,
|
||||
"Extrêmement actif" to 1.9
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Configuration du profil", style = MaterialTheme.typography.headlineLarge)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text("Votre objectif :", style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Start))
|
||||
goals.forEach { g ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { goal = g }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(selected = goal == g, onClick = { goal = g })
|
||||
Text(g)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Genre : ", Modifier.width(80.dp))
|
||||
RadioButton(selected = gender == "Homme", onClick = { gender = "Homme" })
|
||||
Text("Homme")
|
||||
Spacer(Modifier.width(16.dp))
|
||||
RadioButton(selected = gender == "Femme", onClick = { gender = "Femme" })
|
||||
Text("Femme")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = age,
|
||||
onValueChange = { age = it },
|
||||
label = { Text("Âge") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = heightCm,
|
||||
onValueChange = { heightCm = it },
|
||||
label = { Text("Taille (cm)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = weight,
|
||||
onValueChange = { weight = it },
|
||||
label = { Text(if (isLbs) "Poids (lbs)" else "Poids (kg)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Switch(checked = isLbs, onCheckedChange = { isLbs = it })
|
||||
Text(if (isLbs) "lbs" else "kg")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text("Niveau d'activité :", style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Start))
|
||||
activityLevels.forEach { level ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { activityLevel = level }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(selected = activityLevel == level, onClick = { activityLevel = level })
|
||||
Text(level)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Checkbox(checked = isDiabetic, onCheckedChange = { isDiabetic = it })
|
||||
Text("Je suis diabétique")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val currentUser = FirebaseAuth.getInstance().currentUser
|
||||
if (currentUser == null) {
|
||||
Toast.makeText(context, "Erreur : Vous devez être connecté pour sauvegarder.", Toast.LENGTH_LONG).show()
|
||||
return@Button
|
||||
}
|
||||
|
||||
val ageInt = age.toIntOrNull() ?: 0
|
||||
val height = heightCm.toDoubleOrNull() ?: 0.0
|
||||
var weightKg = weight.toDoubleOrNull() ?: 0.0
|
||||
val weightDisplay = weight
|
||||
if (isLbs) weightKg *= 0.453592
|
||||
|
||||
val bmr = if (gender == "Homme") {
|
||||
(10 * weightKg) + (6.25 * height) - (5 * ageInt) + 5
|
||||
} else {
|
||||
(10 * weightKg) + (6.25 * height) - (5 * ageInt) - 161
|
||||
}
|
||||
|
||||
val multiplier = activityMultipliers[activityLevel] ?: 1.2
|
||||
var targetCals = (bmr * multiplier).toInt()
|
||||
|
||||
if (goal == "Perdre du poids") targetCals -= 500
|
||||
|
||||
val targetCarbs = (targetCals * 0.5 / 4).toInt()
|
||||
val targetProtein = (targetCals * 0.2 / 4).toInt()
|
||||
val targetFat = (targetCals * 0.3 / 9).toInt()
|
||||
|
||||
val userProfile = hashMapOf(
|
||||
"age" to ageInt,
|
||||
"height_cm" to height,
|
||||
"weight_kg" to weightKg,
|
||||
"is_lbs" to isLbs,
|
||||
"gender" to gender,
|
||||
"activity_level" to activityLevel,
|
||||
"goal" to goal,
|
||||
"is_diabetic" to isDiabetic,
|
||||
"target_calories" to targetCals,
|
||||
"target_carbs" to targetCarbs,
|
||||
"target_protein" to targetProtein,
|
||||
"target_fat" to targetFat
|
||||
)
|
||||
|
||||
// On spécifie explicitement la base de données "scan-wich"
|
||||
FirebaseFirestore.getInstance("scan-wich").collection("users").document(currentUser.uid)
|
||||
.set(userProfile)
|
||||
.addOnSuccessListener {
|
||||
Log.d("SetupScreen", "User profile saved to Firestore.")
|
||||
prefs.edit {
|
||||
putString("target_calories", targetCals.toString())
|
||||
putString("target_carbs", targetCarbs.toString())
|
||||
putString("target_protein", targetProtein.toString())
|
||||
putString("target_fat", targetFat.toString())
|
||||
putString("weight_kg", weightKg.toString())
|
||||
putString("weight_display", weightDisplay)
|
||||
putBoolean("is_lbs", isLbs)
|
||||
putString("height_cm", heightCm)
|
||||
putBoolean("is_diabetic", isDiabetic)
|
||||
putInt("age", ageInt)
|
||||
putString("gender", gender)
|
||||
putString("activity_level", activityLevel)
|
||||
putString("goal", goal)
|
||||
}
|
||||
Toast.makeText(context, "Profil sauvegardé sur votre compte !", Toast.LENGTH_SHORT).show()
|
||||
onComplete()
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
Log.w("SetupScreen", "Error writing user profile to Firestore", e)
|
||||
Toast.makeText(context, "Erreur de sauvegarde du profil: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
enabled = age.isNotBlank() && heightCm.isNotBlank() && weight.isNotBlank()
|
||||
) {
|
||||
Text("Sauvegarder le profil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
129
app/src/main/java/com/example/scanwich/SportScreen.kt
Normal file
129
app/src/main/java/com/example/scanwich/SportScreen.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.scanwich.FirebaseUtils.syncSportToFirestore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
|
||||
val sports by dao.getAllSports().collectAsState(initial = emptyList())
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// État réactif pour la connexion Strava
|
||||
var isConnectedToStrava by remember {
|
||||
mutableStateOf(prefs.getString("strava_token", null) != null)
|
||||
}
|
||||
|
||||
// Écouteur de changements pour les SharedPreferences
|
||||
DisposableEffect(prefs) {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { p, key ->
|
||||
if (key == "strava_token") {
|
||||
isConnectedToStrava = p.getString("strava_token", null) != null
|
||||
}
|
||||
}
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
onDispose {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
if (!isConnectedToStrava) {
|
||||
Button(
|
||||
onClick = { ApiClient.launchStravaAuth(context) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFC6100)) // Strava orange
|
||||
) {
|
||||
Icon(Icons.Default.Sync, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Se connecter à Strava")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = { syncStravaActivities(dao, prefs, coroutineScope, context) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Synchroniser les activités")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
items(sports) { activity ->
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
|
||||
Text(SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
val distanceInKm = activity.distance / 1000
|
||||
Text("${activity.type} - ${String.format(Locale.getDefault(), "%.2f", distanceInKm)} km")
|
||||
Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncStravaActivities(dao: AppDao, prefs: SharedPreferences, scope: CoroutineScope, context: Context) {
|
||||
scope.launch {
|
||||
val token = ApiClient.getValidStravaToken(prefs)
|
||||
if (token == null) {
|
||||
Toast.makeText(context, "Erreur de connexion Strava", Toast.LENGTH_LONG).show()
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val activities = ApiClient.stravaApi.getActivities("Bearer $token")
|
||||
val weightKg = prefs.getString("weight_kg", "70")?.toDoubleOrNull() ?: 70.0
|
||||
|
||||
val sportActivities = activities.map {
|
||||
val activity = SportActivity(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
type = it.type,
|
||||
distance = it.distance,
|
||||
movingTime = it.movingTime,
|
||||
calories = it.calories ?: ApiClient.estimateCaloriesFromDb(
|
||||
SportActivity(it.id, it.name, it.type, it.distance, it.movingTime, null, 0L),
|
||||
weightKg
|
||||
).toFloat(),
|
||||
date = ApiClient.parseStravaDate(it.startDate)
|
||||
)
|
||||
syncSportToFirestore(activity)
|
||||
activity
|
||||
}
|
||||
dao.insertSports(sportActivities)
|
||||
Toast.makeText(context, "${activities.size} activités synchronisées !", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Log.e("StravaSync", "Error: ${e.message}")
|
||||
Toast.makeText(context, "Erreur de synchronisation Strava", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
115
app/src/main/java/com/example/scanwich/Utils.kt
Normal file
115
app/src/main/java/com/example/scanwich/Utils.kt
Normal file
@@ -0,0 +1,115 @@
|
||||
package com.example.scanwich
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Base64
|
||||
import com.google.firebase.Firebase
|
||||
import com.google.firebase.functions.functions
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
fun Float.format(digits: Int) = "%.${digits}f".format(this)
|
||||
|
||||
fun getOptimizedImageBase64(bitmap: Bitmap): String {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val maxSize = 1024
|
||||
val (newWidth, newHeight) = if (width > maxSize || height > maxSize) {
|
||||
val ratio = width.toFloat() / height.toFloat()
|
||||
if (width > height) {
|
||||
maxSize to (maxSize / ratio).toInt()
|
||||
} else {
|
||||
(maxSize * ratio).toInt() to maxSize
|
||||
}
|
||||
} else {
|
||||
width to height
|
||||
}
|
||||
val resized = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
||||
resized.compress(Bitmap.CompressFormat.JPEG, 70, outputStream)
|
||||
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun parseStravaDate(dateStr: String): Long {
|
||||
return try {
|
||||
val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
inputFormat.timeZone = TimeZone.getTimeZone("UTC")
|
||||
inputFormat.parse(dateStr)?.time ?: 0L
|
||||
} catch (_: Exception) { 0L }
|
||||
}
|
||||
|
||||
fun estimateCaloriesFromDb(activity: SportActivity, weightKg: Double): Int {
|
||||
if (activity.calories != null && activity.calories > 0) return activity.calories.toInt()
|
||||
val met = when (activity.type.lowercase()) {
|
||||
"run" -> 10.0
|
||||
"ride" -> 8.0
|
||||
"walk" -> 3.5
|
||||
"hike" -> 6.0
|
||||
"swim" -> 7.0
|
||||
"weighttraining" -> 5.0
|
||||
"workout" -> 4.5
|
||||
else -> 5.0
|
||||
}
|
||||
val durationHours = activity.movingTime / 3600.0
|
||||
return (met * weightKg * durationHours).toInt()
|
||||
}
|
||||
|
||||
fun analyzeImage(
|
||||
bitmap: Bitmap?,
|
||||
textDescription: String?,
|
||||
setAnalyzing: (Boolean) -> Unit,
|
||||
onResult: (Triple<String, String, List<Int>>?, String?) -> Unit,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
setAnalyzing(true)
|
||||
scope.launch {
|
||||
try {
|
||||
val base64 = withContext(Dispatchers.Default) {
|
||||
bitmap?.let { getOptimizedImageBase64(it) }
|
||||
}
|
||||
|
||||
// Instruction pour que l'IA se concentre uniquement sur la nourriture (sans qualificatifs ni environnement)
|
||||
val aiInstruction = "Focus seulement sur la nourriture, pas de qualificatif, pas son environnement, seulement la nourriture."
|
||||
val mealDescriptionForAI = if (textDescription.isNullOrBlank()) {
|
||||
aiInstruction
|
||||
} else {
|
||||
"$textDescription. $aiInstruction"
|
||||
}
|
||||
|
||||
val data = hashMapOf("imageBase64" to base64, "mealName" to mealDescriptionForAI)
|
||||
|
||||
Firebase.functions("us-central1")
|
||||
.getHttpsCallable("analyzeMealProxy")
|
||||
.call(data)
|
||||
.addOnSuccessListener { result ->
|
||||
try {
|
||||
val responseData = result.data as? Map<*, *>
|
||||
if (responseData != null) {
|
||||
onResult(Triple(
|
||||
(responseData["name"] as? String) ?: textDescription ?: "Repas",
|
||||
(responseData["description"] as? String) ?: "Analyse réussie",
|
||||
listOf(
|
||||
(responseData["calories"] as? Number)?.toInt() ?: 0,
|
||||
(responseData["carbs"] as? Number)?.toInt() ?: 0,
|
||||
(responseData["protein"] as? Number)?.toInt() ?: 0,
|
||||
(responseData["fat"] as? Number)?.toInt() ?: 0
|
||||
)
|
||||
), null)
|
||||
} else { onResult(null, "Format invalide") }
|
||||
} catch (e: Exception) { onResult(null, e.message) }
|
||||
setAnalyzing(false)
|
||||
}
|
||||
.addOnFailureListener { e ->
|
||||
onResult(null, e.message)
|
||||
setAnalyzing(false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onResult(null, e.localizedMessage)
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
20
app/src/main/java/com/example/scanwich/ui/theme/Color.kt
Normal file
20
app/src/main/java/com/example/scanwich/ui/theme/Color.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.example.scanwich.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
|
||||
// Couleurs personnalisées pour une meilleure lisibilité
|
||||
val DarkGreen = Color(0xFF2E7D32)
|
||||
val DarkBlue = Color(0xFF1565C0)
|
||||
val DeepOrange = Color(0xFFE64A19)
|
||||
val DeepPink = Color(0xFFC2185B)
|
||||
|
||||
// Couleur pour remplacer le jaune illisible sur fond clair
|
||||
val ReadableAmber = Color(0xFFB45F04)
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.example.scanwich.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -5,4 +5,5 @@ plugins {
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.secrets) apply false
|
||||
alias(libs.plugins.google.services) apply false
|
||||
alias(libs.plugins.firebase.appdistribution) apply false
|
||||
}
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
[versions]
|
||||
agp = "9.0.1"
|
||||
coreKtx = "1.10.1"
|
||||
coreKtx = "1.17.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.1.5"
|
||||
espressoCore = "3.5.1"
|
||||
lifecycleRuntimeKtx = "2.6.1"
|
||||
activityCompose = "1.8.0"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
activityCompose = "1.12.4"
|
||||
kotlin = "2.0.21"
|
||||
composeBom = "2024.09.00"
|
||||
composeBom = "2026.02.00"
|
||||
generativeai = "0.9.0"
|
||||
coil = "2.7.0"
|
||||
room = "2.8.4"
|
||||
navigation = "2.7.7"
|
||||
ksp = "2.0.21-1.0.27"
|
||||
retrofit = "2.9.0"
|
||||
okhttp = "4.12.0"
|
||||
browser = "1.8.0"
|
||||
exifinterface = "1.3.7"
|
||||
navigation = "2.9.7"
|
||||
ksp = "2.0.21-1.0.28"
|
||||
retrofit = "3.0.0"
|
||||
okhttp = "5.3.2"
|
||||
browser = "1.9.0"
|
||||
exifinterface = "1.4.2"
|
||||
secretsPlugin = "2.0.1"
|
||||
playServicesAuth = "21.2.0"
|
||||
googleServices = "4.4.2"
|
||||
playServicesAuth = "21.5.1"
|
||||
googleServices = "4.4.4"
|
||||
firebaseBom = "34.9.0"
|
||||
firebaseAppDistribution = "5.2.1"
|
||||
firebaseAppDistributionSdk = "16.0.0-beta17"
|
||||
securityCrypto = "1.1.0"
|
||||
kotlinxCoroutinesPlayServices = "1.10.2"
|
||||
mlkitBarcodeScanning = "17.3.0"
|
||||
camerax = "1.5.3"
|
||||
itext = "7.2.6"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -37,6 +44,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
|
||||
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
google-generativeai = { group = "com.google.ai.client.generativeai", name = "generativeai", version.ref = "generativeai" }
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
@@ -51,6 +59,22 @@ androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterfa
|
||||
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
|
||||
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
||||
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
|
||||
firebase-appdistribution = { group = "com.google.firebase", name = "firebase-appdistribution", version.ref = "firebaseAppDistributionSdk" }
|
||||
firebase-functions = { group = "com.google.firebase", name = "firebase-functions" }
|
||||
firebase-auth = { group = "com.google.firebase", name = "firebase-auth" }
|
||||
firebase-firestore = { group = "com.google.firebase", name = "firebase-firestore" }
|
||||
firebase-appcheck-playintegrity = { group = "com.google.firebase", name = "firebase-appcheck-playintegrity" }
|
||||
firebase-appcheck-debug = { group = "com.google.firebase", name = "firebase-appcheck-debug" }
|
||||
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutinesPlayServices" }
|
||||
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcodeScanning" }
|
||||
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
|
||||
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
|
||||
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
||||
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
||||
|
||||
# iText Core is needed for PDF generation
|
||||
itext7-core = { group = "com.itextpdf", name = "itext7-core", version.ref = "itext" }
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
@@ -58,3 +82,4 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko
|
||||
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" }
|
||||
firebase-appdistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
|
||||
|
||||
42
release-notes.txt
Normal file
42
release-notes.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
📝 Notes de version - Scan-Wich
|
||||
|
||||
**Nouveautés de la version actuelle :**
|
||||
|
||||
☁️ **Synchronisation Cloud Totale (Zéro Re-saisie) :**
|
||||
- **Mémoire Cloud :** Votre profil (poids, objectifs, calories cibles) est désormais entièrement sauvegardé dans le Cloud. Plus besoin de retaper vos informations lors d'une mise à jour ou d'une réinstallation !
|
||||
- **Rapatriement Automatique :** L'application récupère instantanément tout votre historique (repas, sport, glycémie) et vos paramètres dès la connexion.
|
||||
- **Modèles de données optimisés :** Mise à jour des structures internes pour garantir une compatibilité parfaite avec Firebase lors de la récupération des données.
|
||||
- **Résilience Keystore :** Ajout d'un système d'auto-réparation en cas de corruption des clés de sécurité locales, évitant ainsi les fermetures inattendues de l'application.
|
||||
|
||||
🍲 **Focus Nourriture Pur (IA) :**
|
||||
- **Analyse sélective :** L'intelligence artificielle se concentre désormais exclusivement sur la nourriture. Les qualificatifs, les descriptions de l'environnement ou les éléments de décor sont ignorés pour ne garder que l'essentiel nutritionnel.
|
||||
|
||||
🔔 **Rappels de Repas :**
|
||||
- **Notifications intelligentes :** Ne manquez plus un enregistrement ! Des rappels automatiques ont été ajoutés pour le déjeuner (08h30), le dîner (12h30) et le souper (19h30).
|
||||
|
||||
---
|
||||
|
||||
**Changements majeurs précédents :**
|
||||
|
||||
🇫🇷 **Expérience 100% en Français :**
|
||||
- **IA Francophone :** Les analyses de repas (caméra et manuel) sont désormais exclusivement en français.
|
||||
- **Scanner Localisé :** Utilisation de la base de données française d'Open Food Facts.
|
||||
|
||||
🤖 **Analyse IA plus Robuste :**
|
||||
- **Fiabilité accrue :** Nettoyage automatique des données nutritionnelles côté serveur.
|
||||
|
||||
🚀 **Connexion Strava 100% Automatique :**
|
||||
- **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques.
|
||||
|
||||
🛡️ **Architecture Cloud & Sécurité :**
|
||||
- **Gestion des accès :** Contrôle dynamique des utilisateurs autorisés via Firestore.
|
||||
- **Secret Manager :** Protection maximale des clés API via Google Cloud.
|
||||
|
||||
---
|
||||
|
||||
🛠️ **Historique technique :**
|
||||
- Export PDF Professionnel (Repas, sport, glycémie).
|
||||
- Suivi Diabétique complet.
|
||||
- Intégration Firebase App Check (Play Integrity).
|
||||
- Algorithme d'estimation MET pour le sport.
|
||||
- Optimisation pour Android 36.
|
||||
@@ -22,5 +22,5 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "coloricam"
|
||||
rootProject.name = "scan-wich"
|
||||
include(":app")
|
||||
|
||||
Reference in New Issue
Block a user