Compare commits

..

9 Commits

Author SHA1 Message Date
mac
f1379e7cc3 test 2026-03-09 21:29:55 -04:00
2bec3bc681 Merge pull request 'changes' (#8) from dev into master
Reviewed-on: #8
2026-03-09 20:57:17 -04:00
9b87930e9a changes 2026-03-09 20:55:48 -04:00
c9d05be4b1 Merge pull request 'test' (#7) from changes into master
Reviewed-on: #7
2026-03-09 20:26:02 -04:00
mac
73a7f46509 test 2026-03-09 20:25:10 -04:00
7699ebcf2e Merge pull request 'changes' (#6) from changes into master
Reviewed-on: http://192.168.2.134:3010/macharest/calorie/pulls/6
2026-02-24 12:57:43 -05:00
mac
c191394ee6 test 2026-02-24 12:46:27 -05:00
mac
93c8814b84 test 2026-02-24 12:04:00 -05:00
mac
1bb637ae62 test 2026-02-24 10:03:35 -05:00
26 changed files with 2602 additions and 1935 deletions

View File

@@ -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>

2
.idea/misc.xml generated
View File

@@ -1,7 +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
View File

@@ -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>

View File

@@ -16,7 +16,7 @@ android {
defaultConfig {
applicationId = "com.example.scanwich"
minSdk = 24
targetSdk = 35
targetSdk = 36
// Incrémentation automatique du versionCode basé sur le temps
versionCode = (System.currentTimeMillis() / 60000).toInt()
@@ -41,7 +41,8 @@ android {
}
create("release") {
storeFile = file("C:\\Users\\mac\\keys\\keys")
// 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")
@@ -130,8 +131,13 @@ dependencies {
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)
@@ -152,5 +158,4 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.firebase.appcheck.debug)
}

View File

@@ -29,6 +29,14 @@
"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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
package com.example.scanwich
import android.util.Log
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
@Composable
fun BarcodeScannerDialog(onBarcodeScanned: (String) -> Unit, onDismiss: () -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = { },
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
text = {
Box(modifier = Modifier.size(300.dp).clip(MaterialTheme.shapes.medium)) {
AndroidView(
factory = { ctx ->
val previewView = PreviewView(ctx)
val executor = ContextCompat.getMainExecutor(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val barcodeScanner = BarcodeScanning.getClient()
val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(executor) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
barcodeScanner.process(image)
.addOnSuccessListener { barcodes ->
if (barcodes.isNotEmpty()) {
barcodes[0].rawValue?.let { barcode ->
onBarcodeScanned(barcode)
cameraProvider.unbindAll()
}
}
}
.addOnCompleteListener { imageProxy.close() }
} else {
imageProxy.close()
}
}
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis)
} catch (e: Exception) {
Log.e("BarcodeScanner", "Camera binding failed", e)
}
}, executor)
previewView
},
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.fillMaxSize()
.border(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), MaterialTheme.shapes.medium)
)
}
}
)
}

View File

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

View File

@@ -8,64 +8,70 @@ 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,
val carbs: Int,
val protein: Int,
val fat: Int
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 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 suspend fun insertGlycemia(glycemia: Glycemia): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGlycemia(glycemia: Glycemia): Long
@Delete suspend fun deleteGlycemia(glycemia: Glycemia)
@Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>>
@Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC")
suspend fun getGlycemiaInRangeSync(start: Long, end: Long): List<Glycemia>
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List<SportActivity>)
@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>>
@@ -79,23 +85,16 @@ interface AppDao {
fun getAllDatesWithData(): Flow<List<Long>>
}
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 7)
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 8) // Version incrémentée
abstract class AppDatabase : RoomDatabase() {
abstract fun appDao(): AppDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
private val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `favorite_meals` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `analysisText` TEXT NOT NULL, `calories` INTEGER NOT NULL, `carbs` INTEGER NOT NULL, `protein` INTEGER NOT NULL, `fat` INTEGER NOT NULL)")
}
}
fun getDatabase(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db")
.addMigrations(MIGRATION_6_7)
.fallbackToDestructiveMigration(dropAllTables = false)
.fallbackToDestructiveMigration() // Ceci va vider la base locale une seule fois pour appliquer le changement
.build().also { INSTANCE = it }
}
}

View File

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

View File

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

View File

@@ -0,0 +1,543 @@
package com.example.scanwich
import android.app.DatePickerDialog
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.scanwich.ui.theme.ReadableAmber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun DailyGoalChart(label: String, current: Int, target: Int, color: Color) {
val progress = if (target > 0) current.toFloat() / target else 0f
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(80.dp)) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(60.dp)) {
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.fillMaxSize(),
color = color.copy(alpha = 0.2f),
strokeWidth = 6.dp,
strokeCap = StrokeCap.Round,
)
CircularProgressIndicator(
progress = { progress.coerceAtMost(1f) },
modifier = Modifier.fillMaxSize(),
color = color,
strokeWidth = 6.dp,
strokeCap = StrokeCap.Round,
)
Text(
text = "${(progress * 100).toInt()}%",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(4.dp))
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = "$current / $target", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HistoryScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
var selectedDate by remember { mutableStateOf(Calendar.getInstance()) }
val datesWithData by dao.getAllDatesWithData().collectAsState(initial = emptyList())
var showMonthPicker by remember { mutableStateOf(false) }
val normalizedDatesWithData = remember(datesWithData) {
datesWithData.map { timestamp ->
val cal = Calendar.getInstance().apply { timeInMillis = timestamp }
cal.set(Calendar.HOUR_OF_DAY, 0)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
cal.set(Calendar.MILLISECOND, 0)
cal.timeInMillis
}.toSet()
}
val startOfDay = selectedDate.clone() as Calendar
startOfDay.set(Calendar.HOUR_OF_DAY, 0); startOfDay.set(Calendar.MINUTE, 0); startOfDay.set(Calendar.SECOND, 0); startOfDay.set(Calendar.MILLISECOND, 0)
val endOfDay = startOfDay.clone() as Calendar
endOfDay.add(Calendar.DAY_OF_MONTH, 1)
val meals by dao.getMealsForDay(startOfDay.timeInMillis, endOfDay.timeInMillis).collectAsState(initial = emptyList())
val sports by dao.getSportsForDay(startOfDay.timeInMillis, endOfDay.timeInMillis).collectAsState(initial = emptyList())
val glycemiaList by dao.getGlycemiaForDay(startOfDay.timeInMillis, endOfDay.timeInMillis).collectAsState(initial = emptyList())
val isDiabetic = prefs.getBoolean("is_diabetic", false)
var selectedMealForDetail by remember { mutableStateOf<Meal?>(null) }
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val tCal = prefs.getString("target_calories", "2000")?.toIntOrNull() ?: 2000
val tCarb = prefs.getString("target_carbs", "250")?.toIntOrNull() ?: 250
val tProt = prefs.getString("target_protein", "100")?.toIntOrNull() ?: 100
val tFat = prefs.getString("target_fat", "60")?.toIntOrNull() ?: 60
val isDark = isSystemInDarkTheme()
val calorieColor = if (isDark) Color(0xFF4CAF50) else Color(0xFF2E7D32)
val carbsColor = if (isDark) Color(0xFF2196F3) else Color(0xFF1565C0)
val proteinColor = if (isDark) Color(0xFFFF9800) else ReadableAmber
val fatColor = if (isDark) Color(0xFFE91E63) else Color(0xFFC2185B)
var showExportDialog by remember { mutableStateOf(false) }
var exportStartDate by remember { mutableStateOf(Calendar.getInstance()) }
var exportEndDate by remember { mutableStateOf(Calendar.getInstance()) }
val createPdfLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri ->
uri?.let {
coroutineScope.launch {
val start = exportStartDate.clone() as Calendar
start.set(Calendar.HOUR_OF_DAY, 0); start.set(Calendar.MINUTE, 0); start.set(Calendar.SECOND, 0)
val end = exportEndDate.clone() as Calendar
end.set(Calendar.HOUR_OF_DAY, 23); end.set(Calendar.MINUTE, 59); end.set(Calendar.SECOND, 59)
val mealsToExport = dao.getMealsInRangeSync(start.timeInMillis, end.timeInMillis)
val sportsToExport = dao.getSportsInRangeSync(start.timeInMillis, end.timeInMillis)
val glycemiaToExport = dao.getGlycemiaInRangeSync(start.timeInMillis, end.timeInMillis)
withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(it)?.use { os ->
generatePdfReport(os, mealsToExport, sportsToExport, glycemiaToExport, start.time, end.time)
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "PDF Exporté avec succès !", Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, "Erreur PDF: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
}
}
}
if (showMonthPicker) {
AlertDialog(
onDismissRequest = { showMonthPicker = false },
title = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.MONTH, -1)
selectedDate = newDate
}) { Icon(Icons.Default.ArrowBack, null) }
Text(SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(selectedDate.time))
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.MONTH, 1)
selectedDate = newDate
}) { Icon(Icons.Default.ArrowForward, null) }
}
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
val daysOfWeek = listOf("L", "M", "M", "J", "V", "S", "D")
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
daysOfWeek.forEach { day ->
Text(day, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(32.dp), textAlign = TextAlign.Center)
}
}
val cal = selectedDate.clone() as Calendar
cal.set(Calendar.DAY_OF_MONTH, 1)
val firstDayIdx = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7
val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
val gridItems = mutableListOf<Int?>()
repeat(firstDayIdx) { gridItems.add(null) }
for (i in 1..daysInMonth) { gridItems.add(i) }
LazyVerticalGrid(
columns = GridCells.Fixed(7),
modifier = Modifier.height(250.dp).padding(top = 8.dp)
) {
items(gridItems) { day ->
if (day != null) {
val dayCal = selectedDate.clone() as Calendar
dayCal.set(Calendar.DAY_OF_MONTH, day)
dayCal.set(Calendar.HOUR_OF_DAY, 0); dayCal.set(Calendar.MINUTE, 0); dayCal.set(Calendar.SECOND, 0); dayCal.set(Calendar.MILLISECOND, 0)
val hasData = normalizedDatesWithData.contains(dayCal.timeInMillis)
val isSelected = day == selectedDate.get(Calendar.DAY_OF_MONTH)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable {
selectedDate = dayCal
showMonthPicker = false
}
.padding(4.dp)
) {
Text(
text = day.toString(),
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
if (hasData) {
Box(Modifier.size(4.dp).clip(CircleShape).background(if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary))
}
}
} else {
Spacer(Modifier.size(40.dp))
}
}
}
}
},
confirmButton = { TextButton(onClick = { showMonthPicker = false }) { Text("Fermer") } }
)
}
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
title = { Text("Exporter l'historique") },
text = {
Column {
Text("Sélectionnez la plage de dates :")
Spacer(Modifier.height(16.dp))
Button(onClick = {
DatePickerDialog(context, { _, y, m, d ->
val newDate = Calendar.getInstance()
newDate.set(y, m, d)
exportStartDate = newDate
}, exportStartDate.get(Calendar.YEAR), exportStartDate.get(Calendar.MONTH), exportStartDate.get(Calendar.DAY_OF_MONTH)).show()
}, modifier = Modifier.fillMaxWidth()) {
Text("Du: " + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportStartDate.time))
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
DatePickerDialog(context, { _, y, m, d ->
val newDate = Calendar.getInstance()
newDate.set(y, m, d)
exportEndDate = newDate
}, exportEndDate.get(Calendar.YEAR), exportEndDate.get(Calendar.MONTH), exportEndDate.get(Calendar.DAY_OF_MONTH)).show()
}, modifier = Modifier.fillMaxWidth()) {
Text("Au: " + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportEndDate.time))
}
}
},
confirmButton = {
TextButton(onClick = {
showExportDialog = false
val fileName = "ScanWich_Rapport_${SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportStartDate.time)}_au_${SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportEndDate.time)}.pdf"
createPdfLauncher.launch(fileName)
}) { Text("Exporter") }
},
dismissButton = {
TextButton(onClick = { showExportDialog = false }) { Text("Annuler") }
}
)
}
if (selectedMealForDetail != null) {
AlertDialog(
onDismissRequest = { selectedMealForDetail = null },
title = { Text(selectedMealForDetail!!.name) },
text = {
Column(modifier = Modifier
.fillMaxWidth()
.heightIn(max = 450.dp)
.verticalScroll(rememberScrollState())
) {
Text("Type: ${selectedMealForDetail!!.type}", fontWeight = FontWeight.Bold)
Text("Calories: ${selectedMealForDetail!!.totalCalories} kcal")
Text("Macro: G ${selectedMealForDetail!!.carbs}g | P ${selectedMealForDetail!!.protein}g | L ${selectedMealForDetail!!.fat}g")
Text("Heure: ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(selectedMealForDetail!!.date))}")
Spacer(Modifier.height(8.dp))
Text("Analyse complète :", style = MaterialTheme.typography.labelMedium)
Text(selectedMealForDetail!!.analysisText)
}
},
confirmButton = {
TextButton(onClick = { selectedMealForDetail = null }) { Text("Fermer") }
}
)
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, -1)
selectedDate = newDate
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, null) }
Column(modifier = Modifier.weight(1f).clickable { showMonthPicker = true }) {
Text(
text = SimpleDateFormat("EEEE d MMMM", Locale.getDefault()).format(selectedDate.time).replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Cliquer pour voir le calendrier",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = { showExportDialog = true }) {
Icon(Icons.Default.PictureAsPdf, contentDescription = "Export PDF", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, 1)
selectedDate = newDate
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) }
}
Spacer(Modifier.height(16.dp))
val totalIn = meals.sumOf { it.totalCalories }
val totalOut = sports.sumOf { it.calories?.toInt() ?: 0 }
val netTotal = totalIn - totalOut
val totalCarbs = meals.sumOf { it.carbs }
val totalProt = meals.sumOf { it.protein }
val totalFat = meals.sumOf { it.fat }
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Résumé Calorique", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Mangé", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
Text("$totalIn", style = MaterialTheme.typography.titleLarge, color = calorieColor, fontWeight = FontWeight.Bold)
}
Text("-", style = MaterialTheme.typography.headlineMedium, color = Color.Gray)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Dépensé", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
Text("$totalOut", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
}
Text("=", style = MaterialTheme.typography.headlineMedium, color = Color.Gray)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Total Net", style = MaterialTheme.typography.labelMedium, color = Color.Gray)
Text("$netTotal", style = MaterialTheme.typography.titleLarge, color = if(netTotal <= tCal) calorieColor else Color.Red, fontWeight = FontWeight.ExtraBold)
}
}
Text("kcal", style = MaterialTheme.typography.labelSmall, modifier = Modifier.align(Alignment.End), color = Color.Gray)
Spacer(Modifier.height(12.dp))
HorizontalDivider()
Spacer(Modifier.height(12.dp))
Text("Objectifs Quotidiens", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(12.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
DailyGoalChart("Calories", totalIn, tCal, calorieColor)
DailyGoalChart("Glucides", totalCarbs, tCarb, carbsColor)
DailyGoalChart("Protéines", totalProt, tProt, proteinColor)
DailyGoalChart("Lipides", totalFat, tFat, fatColor)
}
}
}
Spacer(Modifier.height(16.dp))
LazyColumn(Modifier.weight(1f)) {
val mealCategories = listOf("Déjeuner", "Dîner", "Souper", "Collation")
mealCategories.forEach { category ->
val categoryMeals = meals.filter { it.type == category }
val beforeGly = glycemiaList.find { it.moment == "Avant $category" }
val afterGly = glycemiaList.find { it.moment == "Après $category" }
if (categoryMeals.isNotEmpty() || beforeGly != null || afterGly != null) {
item {
Text(category, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
}
if (isDiabetic && beforeGly != null) {
item {
Surface(color = Color.Blue.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)) {
Text("🩸 Glycémie Avant: ${beforeGly.value} mmol/L", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = {
coroutineScope.launch {
dao.deleteGlycemia(beforeGly)
Toast.makeText(context, "Glycémie supprimée", Toast.LENGTH_SHORT).show()
}
}) { Icon(Icons.Default.Delete, null, modifier = Modifier.size(20.dp)) }
}
}
}
}
items(categoryMeals) { meal ->
ListItem(
headlineContent = { Text(meal.name) },
supportingContent = { Text("${meal.totalCalories} kcal - G:${meal.carbs}g P:${meal.protein}g L:${meal.fat}g") },
trailingContent = {
IconButton(onClick = {
coroutineScope.launch {
dao.deleteMeal(meal)
Toast.makeText(context, "Repas supprimé", Toast.LENGTH_SHORT).show()
}
}) { Icon(Icons.Default.Delete, null) }
},
modifier = Modifier.clickable { selectedMealForDetail = meal }
)
}
if (isDiabetic && afterGly != null) {
item {
Surface(color = Color.Green.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp)) {
Text("🩸 Glycémie Après: ${afterGly.value} mmol/L", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
IconButton(onClick = {
coroutineScope.launch {
dao.deleteGlycemia(afterGly)
Toast.makeText(context, "Glycémie supprimée", Toast.LENGTH_SHORT).show()
}
}) { Icon(Icons.Default.Delete, null, modifier = Modifier.size(20.dp)) }
}
}
}
}
}
}
if (sports.isNotEmpty()) {
item {
Text("Activités Sportives", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 16.dp, bottom = 4.dp))
}
items(sports) { sport ->
ListItem(
headlineContent = { Text(sport.name) },
supportingContent = { Text("${sport.type} - ${sport.calories?.toInt()} kcal brûlées") },
trailingContent = { Icon(Icons.Default.Check, tint = Color.Green, contentDescription = null) }
)
}
}
}
}
}
private fun generatePdfReport(
outputStream: java.io.OutputStream,
meals: List<Meal>,
sports: List<SportActivity>,
glycemia: List<Glycemia>,
startDate: Date,
endDate: Date
) {
val writer = com.itextpdf.kernel.pdf.PdfWriter(outputStream)
val pdf = com.itextpdf.kernel.pdf.PdfDocument(writer)
val document = com.itextpdf.layout.Document(pdf)
val df = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
val tf = SimpleDateFormat("HH:mm", Locale.getDefault())
// Header
document.add(com.itextpdf.layout.element.Paragraph("Rapport d'Historique Scan-Wich")
.setTextAlignment(com.itextpdf.layout.properties.TextAlignment.CENTER)
.setFontSize(20f)
.setBold())
document.add(com.itextpdf.layout.element.Paragraph("Période: ${df.format(startDate)} au ${df.format(endDate)}")
.setTextAlignment(com.itextpdf.layout.properties.TextAlignment.CENTER)
.setFontSize(12f))
document.add(com.itextpdf.layout.element.Paragraph("\n"))
// Meals Table
if (meals.isNotEmpty()) {
document.add(com.itextpdf.layout.element.Paragraph("Repas").setBold().setFontSize(14f))
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f, 1f, 1f, 1f, 1f))).useAllAvailableWidth()
table.addHeaderCell("Date")
table.addHeaderCell("Nom")
table.addHeaderCell("Type")
table.addHeaderCell("Kcal")
table.addHeaderCell("Glu")
table.addHeaderCell("Pro")
table.addHeaderCell("Lip")
meals.forEach { meal ->
table.addCell(df.format(Date(meal.date)) + " " + tf.format(Date(meal.date)))
table.addCell(meal.name)
table.addCell(meal.type)
table.addCell(meal.totalCalories.toString())
table.addCell(meal.carbs.toString())
table.addCell(meal.protein.toString())
table.addCell(meal.fat.toString())
}
document.add(table)
document.add(com.itextpdf.layout.element.Paragraph("\n"))
}
// Glycemia Table
if (glycemia.isNotEmpty()) {
document.add(com.itextpdf.layout.element.Paragraph("Glycémie").setBold().setFontSize(14f))
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f))).useAllAvailableWidth()
table.addHeaderCell("Date/Heure")
table.addHeaderCell("Moment")
table.addHeaderCell("Valeur (mmol/L)")
glycemia.forEach { gly ->
table.addCell(df.format(Date(gly.date)) + " " + tf.format(Date(gly.date)))
table.addCell(gly.moment)
table.addCell(gly.value.toString())
}
document.add(table)
document.add(com.itextpdf.layout.element.Paragraph("\n"))
}
// Sports Table
if (sports.isNotEmpty()) {
document.add(com.itextpdf.layout.element.Paragraph("Activités Sportives").setBold().setFontSize(14f))
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f, 2f))).useAllAvailableWidth()
table.addHeaderCell("Date")
table.addHeaderCell("Activité")
table.addHeaderCell("Type")
table.addHeaderCell("Calories")
sports.forEach { sport ->
table.addCell(df.format(Date(sport.date)))
table.addCell(sport.name)
table.addCell(sport.type)
table.addCell(sport.calories?.toInt()?.toString() ?: "0")
}
document.add(table)
}
document.close()
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,13 +6,13 @@ junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.4"
kotlinComposePlugin = "2.3.10"
kotlin = "2.0.21"
composeBom = "2026.02.00"
generativeai = "0.9.0"
coil = "2.7.0"
room = "2.8.4"
navigation = "2.9.7"
ksp = "2.0.21-1.0.27"
ksp = "2.0.21-1.0.28"
retrofit = "3.0.0"
okhttp = "5.3.2"
browser = "1.9.0"
@@ -24,6 +24,7 @@ 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"
@@ -61,9 +62,11 @@ 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" }
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version = "17.3.0" }
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" }
@@ -75,7 +78,7 @@ androidx-security-crypto = { group = "androidx.security", name = "security-crypt
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinComposePlugin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secretsPlugin" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }

View File

@@ -1,32 +1,42 @@
📝 Notes de version - Scan-Wich
Nouveautés et Améliorations :
**Nouveautés de la version actuelle :**
🛡 Sécurité renforcée :
- Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les accès non autorisés.
- Migration de la clé API vers Google Cloud Secret Manager, supprimant toute information sensible du code source.
**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.
⚡ Analyse Ultra-Rapide :
- Nouveau moteur de compression d'image intelligent (réduction de ~2.2 Mo à 150 Ko par scan), accélérant drastiquement l'analyse IA.
🍲 **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.
🤖 IA Sécurisée :
- Migration de la logique d'analyse (prompts) vers des Cloud Functions pour garantir des résultats plus fiables et protégés.
🔔 **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).
📄 Export PDF Professionnel :
- Nouvelle fonctionnalité d'exportation de l'historique. Générez un rapport PDF complet incluant vos repas, activités sportives et suivis de glycémie.
---
🩸 Suivi Diabétique complet :
- Possibilité d'enregistrer et de visualiser sa glycémie avant/après chaque catégorie de repas directement dans l'historique.
**Changements majeurs précédents :**
🚴 Synchronisation Strava :
- Connexion directe à Strava pour importer vos activités et calculer précisément les calories brûlées.
🇫🇷 **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.
⭐ Gestion des Favoris :
- Refonte de l'interface des favoris avec une nouvelle fenêtre modale pour un ajout rapide de vos repas récurrents.
🤖 **Analyse IA plus Robuste :**
- **Fiabilité accrue :** Nettoyage automatique des données nutritionnelles côté serveur.
🔍 Scanner de Code-barres :
- Intégration d'Open Food Facts pour identifier instantanément les produits industriels via leur code-barres.
🚀 **Connexion Strava 100% Automatique :**
- **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques.
🔧 Stabilité et Modernisation :
🛡️ **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.
- Correction de bugs majeurs sur le stockage sécurisé des préférences (MasterKey) et la synchronisation Firebase (collectAsState).