From 1bb637ae622dd3842385e41c26f7430da2420ee6 Mon Sep 17 00:00:00 2001 From: mac Date: Tue, 24 Feb 2026 10:03:35 -0500 Subject: [PATCH] test --- .idea/misc.xml | 1 - app/build.gradle.kts | 3 +- .../example/scanwich/AccessDeniedScreen.kt | 29 + .../java/com/example/scanwich/AuthWrapper.kt | 106 + .../example/scanwich/BarcodeScannerDialog.kt | 96 + .../com/example/scanwich/CaptureScreen.kt | 449 ++++ .../com/example/scanwich/FirebaseUtils.kt | 51 + .../com/example/scanwich/GlycemiaScreen.kt | 93 + .../com/example/scanwich/HistoryScreen.kt | 524 +++++ .../java/com/example/scanwich/LoginScreen.kt | 31 + .../java/com/example/scanwich/MainActivity.kt | 1874 +---------------- .../main/java/com/example/scanwich/MainApp.kt | 80 + .../java/com/example/scanwich/Networking.kt | 128 ++ .../com/example/scanwich/SettingsScreen.kt | 109 + .../java/com/example/scanwich/SetupScreen.kt | 188 ++ .../java/com/example/scanwich/SportScreen.kt | 96 + .../main/java/com/example/scanwich/Utils.kt | 107 + gradle/libs.versions.toml | 11 +- release-notes.txt | 12 +- 19 files changed, 2112 insertions(+), 1876 deletions(-) create mode 100644 app/src/main/java/com/example/scanwich/AccessDeniedScreen.kt create mode 100644 app/src/main/java/com/example/scanwich/AuthWrapper.kt create mode 100644 app/src/main/java/com/example/scanwich/BarcodeScannerDialog.kt create mode 100644 app/src/main/java/com/example/scanwich/CaptureScreen.kt create mode 100644 app/src/main/java/com/example/scanwich/FirebaseUtils.kt create mode 100644 app/src/main/java/com/example/scanwich/GlycemiaScreen.kt create mode 100644 app/src/main/java/com/example/scanwich/HistoryScreen.kt create mode 100644 app/src/main/java/com/example/scanwich/LoginScreen.kt create mode 100644 app/src/main/java/com/example/scanwich/MainApp.kt create mode 100644 app/src/main/java/com/example/scanwich/Networking.kt create mode 100644 app/src/main/java/com/example/scanwich/SettingsScreen.kt create mode 100644 app/src/main/java/com/example/scanwich/SetupScreen.kt create mode 100644 app/src/main/java/com/example/scanwich/SportScreen.kt create mode 100644 app/src/main/java/com/example/scanwich/Utils.kt diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0e1548d..40787d6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() @@ -130,6 +130,7 @@ dependencies { implementation(libs.firebase.analytics) implementation(libs.firebase.functions) implementation(libs.firebase.auth) + implementation(libs.firebase.firestore) implementation(libs.firebase.appcheck.playintegrity) // Barcode Scanning & Camera diff --git a/app/src/main/java/com/example/scanwich/AccessDeniedScreen.kt b/app/src/main/java/com/example/scanwich/AccessDeniedScreen.kt new file mode 100644 index 0000000..df899e0 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/AccessDeniedScreen.kt @@ -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") } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/scanwich/AuthWrapper.kt b/app/src/main/java/com/example/scanwich/AuthWrapper.kt new file mode 100644 index 0000000..1b07d63 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/AuthWrapper.kt @@ -0,0 +1,106 @@ +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 + +@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(auth.currentUser) } + + val allowedEmails = listOf("marcandre.charest@gmail.com", "everousseau07@gmail.com") + + 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 + Log.d("Auth", "Connecté à Firebase avec : ${firebaseUser?.email}") + } 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 dans Firebase. Assurez-vous d'avoir ajouté le SHA-1 de TOUTES vos clés de signature." + 7 -> "Erreur 7 : Problème de réseau." + 12500 -> "Erreur 12500 : Problème de configuration Google Play Services." + else -> "Erreur Google (Code ${e.statusCode})." + } + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() + } + } + + val onLogout: () -> Unit = { + auth.signOut() + googleSignInClient.signOut().addOnCompleteListener { + firebaseUser = null + } + } + + if (firebaseUser == null) { + LoginScreen { launcher.launch(googleSignInClient.signInIntent) } + } else { + val userEmail = firebaseUser?.email?.lowercase() ?: "" + if (allowedEmails.contains(userEmail)) { + MainApp(dao = dao, onLogout = onLogout, userId = firebaseUser!!.uid) + } else { + AccessDeniedScreen(onLogout) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/scanwich/BarcodeScannerDialog.kt b/app/src/main/java/com/example/scanwich/BarcodeScannerDialog.kt new file mode 100644 index 0000000..fec75f9 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/BarcodeScannerDialog.kt @@ -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) + ) + } + } + ) +} diff --git a/app/src/main/java/com/example/scanwich/CaptureScreen.kt b/app/src/main/java/com/example/scanwich/CaptureScreen.kt new file mode 100644 index 0000000..21a0ed0 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/CaptureScreen.kt @@ -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(null) } + var isAnalyzing by remember { mutableStateOf(false) } + var currentMealData by remember { mutableStateOf>?>(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)") + } + } + } +} diff --git a/app/src/main/java/com/example/scanwich/FirebaseUtils.kt b/app/src/main/java/com/example/scanwich/FirebaseUtils.kt new file mode 100644 index 0000000..91b76dd --- /dev/null +++ b/app/src/main/java/com/example/scanwich/FirebaseUtils.kt @@ -0,0 +1,51 @@ +package com.example.scanwich + +import android.util.Log +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.SetOptions + +object FirebaseUtils { + private fun getDb(): FirebaseFirestore { + return try { + // Tente d'utiliser la base de données nommée "scan-wich" + FirebaseFirestore.getInstance("scan-wich") + } catch (e: Exception) { + // Repli sur la base par défaut si "scan-wich" n'est pas configurée comme base secondaire + 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}") } + } + } +} diff --git a/app/src/main/java/com/example/scanwich/GlycemiaScreen.kt b/app/src/main/java/com/example/scanwich/GlycemiaScreen.kt new file mode 100644 index 0000000..99548b1 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/GlycemiaScreen.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/example/scanwich/HistoryScreen.kt b/app/src/main/java/com/example/scanwich/HistoryScreen.kt new file mode 100644 index 0000000..c161da8 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/HistoryScreen.kt @@ -0,0 +1,524 @@ +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 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(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() + 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 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("Objectifs Quotidiens", style = MaterialTheme.typography.titleMedium, 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(12.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Sport (Brûlées):") + Text("$totalOut kcal", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + } + } + } + + 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, + sports: List, + glycemia: List, + 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() +} diff --git a/app/src/main/java/com/example/scanwich/LoginScreen.kt b/app/src/main/java/com/example/scanwich/LoginScreen.kt new file mode 100644 index 0000000..71c4bb6 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/LoginScreen.kt @@ -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") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/scanwich/MainActivity.kt b/app/src/main/java/com/example/scanwich/MainActivity.kt index 2b54017..ae897e8 100644 --- a/app/src/main/java/com/example/scanwich/MainActivity.kt +++ b/app/src/main/java/com/example/scanwich/MainActivity.kt @@ -1,292 +1,47 @@ package com.example.scanwich -import android.Manifest -import android.app.DatePickerDialog -import android.app.TimePickerDialog -import android.content.Context import android.content.Intent -import android.content.SharedPreferences -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle -import android.util.Base64 import android.util.Log -import android.widget.Toast import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.* -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -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.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForward -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.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.core.content.edit -import androidx.core.graphics.scale -import androidx.core.net.toUri -import androidx.exifinterface.media.ExifInterface -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey import com.example.scanwich.ui.theme.ScanwichTheme -import com.example.scanwich.ui.theme.ReadableAmber -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.Firebase import com.google.firebase.appcheck.appCheck import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory -import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.auth.GoogleAuthProvider -import com.google.firebase.functions.functions import com.google.firebase.initialize -import com.google.gson.annotations.SerializedName -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.common.InputImage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await -import kotlinx.coroutines.withContext -import retrofit2.Retrofit -import java.text.SimpleDateFormat -import java.util.* -import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.http.* -import java.io.ByteArrayOutputStream -import java.io.OutputStream -import org.json.JSONObject - -// iText Imports -import com.itextpdf.kernel.pdf.PdfDocument -import com.itextpdf.kernel.pdf.PdfWriter -import com.itextpdf.layout.Document -import com.itextpdf.layout.element.Paragraph -import com.itextpdf.layout.element.Table -import com.itextpdf.layout.properties.TextAlignment -import com.itextpdf.layout.properties.UnitValue - -// --- 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, - @SerializedName("elapsed_time") val elapsedTime: Int, - val calories: Float?, - @SerializedName("start_date") val startDate: String, - @SerializedName("start_date_local") val startDateLocal: String -) - -data class StravaTokenResponse( - @SerializedName("access_token") val accessToken: String, - @SerializedName("refresh_token") val refreshToken: String, - @SerializedName("expires_at") val expiresAt: Long -) - -interface StravaApi { - @GET("athlete/activities") - suspend fun getActivities( - @Header("Authorization") token: String, - @Query("before") before: Long? = null, - @Query("after") after: Long? = null, - @Query("page") page: Int? = null, - @Query("per_page") perPage: Int? = 30 - ): List - - @POST("oauth/token") - suspend fun exchangeToken( - @Query("client_id") clientId: String, - @Query("client_secret") clientSecret: String, - @Query("code") code: String, - @Query("grant_type") grantType: String = "authorization_code" - ): StravaTokenResponse - - @POST("oauth/token") - suspend fun refreshToken( - @Query("client_id") clientId: String, - @Query("client_secret") clientSecret: String, - @Query("refresh_token") refreshToken: String, - @Query("grant_type") grantType: String = "refresh_token" - ): StravaTokenResponse -} - -// Helpers -object ApiClient { - private val retrofitStrava = Retrofit.Builder() - .baseUrl("https://www.strava.com/api/v3/") - .addConverterFactory(GsonConverterFactory.create()) - .build() - - private val retrofitOff = Retrofit.Builder() - .baseUrl("https://world.openfoodfacts.org/api/v2/") - .addConverterFactory(GsonConverterFactory.create()) - .build() - - val stravaApi: StravaApi = retrofitStrava.create(StravaApi::class.java) - val offApi: OffApi = retrofitOff.create(OffApi::class.java) - - fun getEncryptedPrefs(context: Context): SharedPreferences { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - return EncryptedSharedPreferences.create( - context, - "secure_user_prefs", - 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) - val refreshToken = prefs.getString("strava_refresh_token", null) - val clientId = prefs.getString("strava_client_id", "") ?: "" - val clientSecret = prefs.getString("strava_client_secret", "") ?: "" - - val currentTime = System.currentTimeMillis() / 1000 - if (currentTime >= expiresAt && refreshToken != null && clientId.isNotBlank()) { - try { - val refreshResponse = stravaApi.refreshToken(clientId, clientSecret, refreshToken) - prefs.edit { - putString("strava_token", refreshResponse.accessToken) - putString("strava_refresh_token", refreshResponse.refreshToken) - putLong("strava_expires_at", refreshResponse.expiresAt) - } - return refreshResponse.accessToken - } catch (_: Exception) { - return null - } - } - return stravaToken - } - - fun parseStravaDate(dateStr: String): Long { - return try { - val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) - inputFormat.timeZone = TimeZone.getTimeZone("UTC") - inputFormat.parse(dateStr)?.time ?: 0L - } catch (_: Exception) { 0L } - } - - fun estimateCaloriesFromDb(activity: SportActivity, weightKg: Double): Int { - if (activity.calories != null && activity.calories > 0) return activity.calories.toInt() - val met = when (activity.type.lowercase()) { - "run" -> 10.0 - "ride" -> 8.0 - "walk" -> 3.5 - "hike" -> 6.0 - "swim" -> 7.0 - "weighttraining" -> 5.0 - "workout" -> 4.5 - else -> 5.0 - } - val durationHours = activity.movingTime / 3600.0 - return (met * weightKg * durationHours).toInt() - } -} - -// --- UI COMPONENTS --- +import androidx.core.content.edit class MainActivity : ComponentActivity() { - private lateinit var dao: AppDao - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - // Initialiser Firebase et App Check AVANT tout try { - Firebase.initialize(context = this) + Firebase.initialize(this) val appCheckFactory = if (BuildConfig.DEBUG) { DebugAppCheckProviderFactory.getInstance() } else { PlayIntegrityAppCheckProviderFactory.getInstance() } Firebase.appCheck.installAppCheckProviderFactory(appCheckFactory) - Log.d("AppCheck", "App Check installed successfully") - // FORCER la génération du jeton pour qu'il apparaisse dans les logs Firebase.appCheck.getAppCheckToken(false).addOnSuccessListener { tokenResult -> Log.d("DEBUG_APP_CHECK", "Token: ${tokenResult.token}") }.addOnFailureListener { e -> Log.e("DEBUG_APP_CHECK", "Erreur: ${e.message}") } - } catch (e: Exception) { - Log.e("AppCheck", "Failed to install App Check: ${e.message}") - } + } catch (e: Exception) { Log.e("AppCheck", "Failed to install App Check: ${e.message}") } - dao = AppDatabase.getDatabase(this).appDao() handleStravaCallback(intent) setContent { ScanwichTheme { + val dao = AppDatabase.getDatabase(LocalContext.current).appDao() AuthWrapper(dao) } } @@ -305,1636 +60,19 @@ class MainActivity : ComponentActivity() { val prefs = ApiClient.getEncryptedPrefs(this) val clientId = prefs.getString("strava_client_id", "") ?: "" val clientSecret = prefs.getString("strava_client_secret", "") ?: "" - if (clientId.isNotEmpty() && clientSecret.isNotEmpty()) { CoroutineScope(Dispatchers.IO).launch { try { val response = ApiClient.stravaApi.exchangeToken(clientId, clientSecret, code) - prefs.edit { + prefs.edit { putString("strava_token", response.accessToken) putString("strava_refresh_token", response.refreshToken) putLong("strava_expires_at", response.expiresAt) } - } catch (e: Exception) { - Log.e("StravaAuth", "Exchange failed: ${e.message}") - } + } catch (e: Exception) { Log.e("StravaAuth", "Exchange failed: ${e.message}") } } } } } } } - -@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(auth.currentUser) } - - val allowedEmails = listOf("marcandre.charest@gmail.com", "everousseau07@gmail.com") - - 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 - Log.d("Auth", "Connecté à Firebase avec : ${firebaseUser?.email}") - } 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 dans Firebase. Assurez-vous d'avoir ajouté le SHA-1 de TOUTES vos clés de signature." - 7 -> "Erreur 7 : Problème de réseau." - 12500 -> "Erreur 12500 : Problème de configuration Google Play Services." - else -> "Erreur Google (Code ${e.statusCode})." - } - Toast.makeText(context, msg, Toast.LENGTH_LONG).show() - } - } - - val onLogout: () -> Unit = { - auth.signOut() - googleSignInClient.signOut().addOnCompleteListener { - firebaseUser = null - } - } - - if (firebaseUser == null) { - LoginScreen { launcher.launch(googleSignInClient.signInIntent) } - } else { - val userEmail = firebaseUser?.email?.lowercase() ?: "" - if (allowedEmails.contains(userEmail)) { - MainApp(dao, onLogout) - } else { - AccessDeniedScreen(onLogout) - } - } -} - -@Composable -fun LoginScreen(onLoginClick: () -> Unit) { - Column( - modifier = Modifier.fillMaxSize().padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text("Scan-Wich", style = MaterialTheme.typography.displayLarge, color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(32.dp)) - Button(onClick = onLoginClick, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Icon(Icons.Default.AccountCircle, null) - Spacer(Modifier.width(8.dp)) - Text("Se connecter avec Google") - } - } -} - -@Composable -fun AccessDeniedScreen(onLogout: () -> Unit) { - Column( - modifier = Modifier.fillMaxSize().padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon(Icons.Default.Warning, null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(16.dp)) - Text("Accès Refusé", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - Text("Votre compte n'est pas autorisé à utiliser cette application.", style = MaterialTheme.typography.bodyLarge) - Spacer(Modifier.height(32.dp)) - Button(onClick = onLogout) { Text("Changer de compte") } - } -} - -@Composable -fun MainApp(dao: AppDao, onLogout: () -> Unit) { - val context = LocalContext.current - val prefs = remember { 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) { - showSetup = false - isDiabetic = prefs.getBoolean("is_diabetic", false) - } - } else { - val navController = rememberNavController() - Scaffold( - bottomBar = { - NavigationBar { - NavigationBarItem(icon = { Icon(Icons.Default.Home, "Scan") }, label = { Text("Scan") }, selected = false, onClick = { navController.navigate("capture") }) - NavigationBarItem(icon = { Icon(Icons.Default.DateRange, "Historique") }, label = { Text("Historique") }, selected = false, onClick = { navController.navigate("history") }) - NavigationBarItem(icon = { Icon(Icons.Default.Add, "Sport") }, label = { Text("Sport") }, selected = false, onClick = { navController.navigate("sport") }) - if (isDiabetic) { - NavigationBarItem(icon = { Icon(Icons.Default.Favorite, "Glycémie") }, label = { Text("Glycémie") }, selected = false, onClick = { navController.navigate("glycemia") }) - } - NavigationBarItem(icon = { Icon(Icons.Default.Settings, "Paramètres") }, label = { Text("Paramètres") }, selected = false, onClick = { navController.navigate("settings") }) - } - } - ) { innerPadding -> - NavHost(navController, "capture", Modifier.padding(innerPadding)) { - composable("capture") { CaptureScreen(dao) } - 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) } } - } - } - } -} - -@Composable -fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) { - var age by remember { mutableStateOf(prefs.getInt("age", 25).toString()) } - var heightCm by remember { mutableStateOf(prefs.getString("height_cm", "170") ?: "170") } - var weight by remember { mutableStateOf(prefs.getString("weight_display", "70") ?: "70") } - var isLbs by remember { mutableStateOf(prefs.getBoolean("is_lbs", false)) } - var gender by remember { mutableStateOf(prefs.getString("gender", "Homme") ?: "Homme") } - var activityLevel by remember { mutableStateOf(prefs.getString("activity_level", "Sédentaire") ?: "Sédentaire") } - var goal by remember { mutableStateOf(prefs.getString("goal", "Maintenir le poids") ?: "Maintenir le poids") } - var isDiabetic by remember { mutableStateOf(prefs.getBoolean("is_diabetic", false)) } - - val activityLevels = listOf("Sédentaire", "Légèrement actif", "Modérément actif", "Très actif", "Extrêmement actif") - val goals = listOf("Maintenir le poids", "Perdre du poids") - - val activityMultipliers = mapOf( - "Sédentaire" to 1.2, - "Légèrement actif" to 1.375, - "Modérément actif" to 1.55, - "Très actif" to 1.725, - "Extrêmement actif" to 1.9 - ) - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Configuration du profil", style = MaterialTheme.typography.headlineLarge) - Spacer(Modifier.height(24.dp)) - - Text("Votre objectif :", style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Start)) - goals.forEach { g -> - Row( - Modifier - .fillMaxWidth() - .clickable { goal = g } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton(selected = goal == g, onClick = { goal = g }) - Text(g) - } - } - - Spacer(Modifier.height(16.dp)) - - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text("Genre : ", Modifier.width(80.dp)) - RadioButton(selected = gender == "Homme", onClick = { gender = "Homme" }) - Text("Homme") - Spacer(Modifier.width(16.dp)) - RadioButton(selected = gender == "Femme", onClick = { gender = "Femme" }) - Text("Femme") - } - - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = age, - onValueChange = { age = it }, - label = { Text("Âge") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = heightCm, - onValueChange = { heightCm = it }, - label = { Text("Taille (cm)") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(16.dp)) - - Row(verticalAlignment = Alignment.CenterVertically, 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 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 - val targetCals = (bmr * multiplier).toInt().let { if (goal == "Perdre du poids") it - 500 else it } - - prefs.edit { - putString("target_calories", targetCals.toString()) - putString("target_carbs", (targetCals * 0.5 / 4).toInt().toString()) - putString("target_protein", (targetCals * 0.2 / 4).toInt().toString()) - putString("target_fat", (targetCals * 0.3 / 9).toInt().toString()) - putString("weight_kg", weightKg.toString()) - putString("weight_display", weightDisplay) - putBoolean("is_lbs", isLbs) - putString("height_cm", heightCm) - putBoolean("is_diabetic", isDiabetic) - putInt("age", ageInt) - putString("gender", gender) - putString("activity_level", activityLevel) - putString("goal", goal) - } - onComplete() - }, - modifier = Modifier.fillMaxWidth().height(56.dp), - enabled = age.isNotBlank() && heightCm.isNotBlank() && weight.isNotBlank() - ) { - Text("Sauvegarder le profil") - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CaptureScreen(dao: AppDao) { - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - - var capturedBitmap by remember { mutableStateOf(null) } - var isAnalyzing by remember { mutableStateOf(false) } - var currentMealData by remember { mutableStateOf>?>(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: Triple>?, _ -> - 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: Boolean -> - if (isGranted) { - cameraLauncher.launch(null) - } else { - Toast.makeText(context, "Permission caméra requise", Toast.LENGTH_SHORT).show() - } - } - - val barcodePermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted: Boolean -> - 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 - - // Extract EXIF date - val exifStream = context.contentResolver.openInputStream(it) - if (exifStream != null) { - val exif = ExifInterface(exifStream) - val dateStr = exif.getAttribute(ExifInterface.TAG_DATETIME) - mealDateTime = if (dateStr != null) { - SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.getDefault()).parse(dateStr)?.time ?: System.currentTimeMillis() - } else { - System.currentTimeMillis() - } - exifStream.close() - } - - analyzeImage(bitmap, null, { isAnalyzing = it }, { data: Triple>?, _ -> - 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() - - // Update local state if currentMealData changes (e.g. after resubmission) - 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: Triple>?, _ -> - 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 { - dao.insertMeal(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 - )) - 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 AI", 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)") - } - } - } -} - -@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, - ) - val percent = (progress * 100).toInt() - Text( - text = "$percent%", - 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: SharedPreferences) { - var selectedDate by remember { mutableStateOf(Calendar.getInstance()) } - val datesWithData by dao.getAllDatesWithData().collectAsState(initial = emptyList()) - var showMonthPicker by remember { mutableStateOf(false) } - - // Normalize datesWithData to a set of "days since epoch" for fast lookup - 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(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) - - // PDF Export states - 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.AutoMirrored.Filled.ArrowBack, null) } - val monthLabel = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(selectedDate.time) - Text(monthLabel) - IconButton(onClick = { - val newDate = selectedDate.clone() as Calendar - newDate.add(Calendar.MONTH, 1) - selectedDate = newDate - }) { Icon(Icons.AutoMirrored.Filled.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() - 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()) { - val dateLabel = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportStartDate.time) - Text("Du: $dateLabel") - } - 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()) { - val dateLabel = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportEndDate.time) - Text("Au: $dateLabel") - } - } - }, - confirmButton = { - TextButton(onClick = { - showExportDialog = false - val startLabel = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportStartDate.time) - val endLabel = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportEndDate.time) - val fileName = "ScanWich_Rapport_${startLabel}_au_${endLabel}.pdf" - createPdfLauncher.launch(fileName) - }) { Text("Exporter") } - }, - dismissButton = { - TextButton(onClick = { showExportDialog = false }) { Text("Annuler") } - } - ) - } - - if (selectedMealForDetail != null) { - val meal = selectedMealForDetail!! - AlertDialog( - onDismissRequest = { selectedMealForDetail = null }, - title = { Text(meal.name) }, - text = { - Column(modifier = Modifier - .fillMaxWidth() - .heightIn(max = 450.dp) - .verticalScroll(rememberScrollState()) - ) { - Text("Type: ${meal.type}", fontWeight = FontWeight.Bold) - Text("Calories: ${meal.totalCalories} kcal") - Text("Macro: G ${meal.carbs}g | P ${meal.protein}g | L ${meal.fat}g") - val timeLabel = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(meal.date)) - Text("Heure: $timeLabel") - Spacer(Modifier.height(8.dp)) - Text("Analyse complète :", style = MaterialTheme.typography.labelMedium) - Text(meal.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 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("Objectifs Quotidiens", style = MaterialTheme.typography.titleMedium, 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(12.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("Sport (Brûlées):") - Text("$totalOut kcal", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) - } - } - } - - 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: OutputStream, - meals: List, - sports: List, - glycemia: List, - startDate: Date, - endDate: Date -) { - val writer = PdfWriter(outputStream) - val pdf = PdfDocument(writer) - val document = Document(pdf) - - val df = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()) - val tf = SimpleDateFormat("HH:mm", Locale.getDefault()) - - // Header - document.add(Paragraph("Rapport d'Historique Scan-Wich") - .setTextAlignment(TextAlignment.CENTER) - .setFontSize(20f) - .setBold()) - - document.add(Paragraph("Période: ${df.format(startDate)} au ${df.format(endDate)}") - .setTextAlignment(TextAlignment.CENTER) - .setFontSize(12f)) - - document.add(Paragraph("\n")) - - // Meals Table - if (meals.isNotEmpty()) { - document.add(Paragraph("Repas").setBold().setFontSize(14f)) - val table = Table(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 -> - val dateLabel = df.format(Date(meal.date)) + " " + tf.format(Date(meal.date)) - table.addCell(dateLabel) - 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(Paragraph("\n")) - } - - // Glycemia Table - if (glycemia.isNotEmpty()) { - document.add(Paragraph("Glycémie").setBold().setFontSize(14f)) - val table = Table(UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f))).useAllAvailableWidth() - table.addHeaderCell("Date/Heure") - table.addHeaderCell("Moment") - table.addHeaderCell("Valeur (mmol/L)") - - glycemia.forEach { gly -> - val dateLabel = df.format(Date(gly.date)) + " " + tf.format(Date(gly.date)) - table.addCell(dateLabel) - table.addCell(gly.moment) - table.addCell(gly.value.toString()) - } - document.add(table) - document.add(Paragraph("\n")) - } - - // Sports Table - if (sports.isNotEmpty()) { - document.add(Paragraph("Activités Sportives").setBold().setFontSize(14f)) - val table = Table(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() -} - -// Fonction pour redimensionner et compresser l'image -private fun getOptimizedImageBase64(bitmap: Bitmap): String { - val maxSize = 1024 - var width = bitmap.width - var height = bitmap.height - - if (width > maxSize || height > maxSize) { - val ratio = width.toFloat() / height.toFloat() - if (width > height) { - width = maxSize - height = (maxSize / ratio).toInt() - } else { - height = maxSize - width = (maxSize * ratio).toInt() - } - } - - val resized = bitmap.scale(width, height, true) - val outputStream = ByteArrayOutputStream() - resized.compress(Bitmap.CompressFormat.JPEG, 70, outputStream) - return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) -} - -private fun analyzeImage( - bitmap: Bitmap?, - textDescription: String?, - setAnalyzing: (Boolean) -> Unit, - onResult: (Triple>?, String?) -> Unit, - scope: CoroutineScope -) { - setAnalyzing(true) - - scope.launch { - try { - val base64 = withContext(Dispatchers.Default) { - bitmap?.let { getOptimizedImageBase64(it) } - } - - // On n'envoie plus le prompt, il est construit côté serveur - val data = hashMapOf( - "imageBase64" to base64, - "mealName" to textDescription - ) - - Firebase.functions("us-central1") - .getHttpsCallable("analyzeMealProxy") - .call(data) - .addOnSuccessListener { result -> - try { - val responseData = result.data - if (responseData is Map<*, *>) { - 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 { - // Fallback pour le parsing JSON manuel si ce n'est pas une Map - val responseStr = responseData.toString() - val jsonStartIndex = responseStr.indexOf("{") - val jsonEndIndex = responseStr.lastIndexOf("}") + 1 - if (jsonStartIndex != -1 && jsonEndIndex > jsonStartIndex) { - val jsonPart = responseStr.substring(jsonStartIndex, jsonEndIndex) - val json = JSONObject(jsonPart) - onResult(Triple( - json.optString("name", textDescription ?: "Repas"), - json.optString("description", "Analyse réussie"), - listOf( - json.optInt("calories", 0), - json.optInt("carbs", 0), - json.optInt("protein", 0), - json.optInt("fat", 0) - ) - ), null) - } else { - onResult(null, "Format de réponse invalide") - } - } - } catch (e: Exception) { - onResult(null, "Erreur parsing: ${e.message}") - } - setAnalyzing(false) - } - .addOnFailureListener { e -> - onResult(null, "Erreur Cloud Function: ${e.message}") - setAnalyzing(false) - } - } catch (e: Exception) { - onResult(null, e.localizedMessage ?: "Erreur réseau") - setAnalyzing(false) - } - } -} - -private fun syncStravaActivities(dao: AppDao, prefs: SharedPreferences, scope: CoroutineScope, context: Context) { - scope.launch { - val token = ApiClient.getValidStravaToken(prefs) - if (token == null) { - Toast.makeText(context, "Veuillez connecter Strava dans les paramètres", Toast.LENGTH_LONG).show() - return@launch - } - try { - val activities = ApiClient.stravaApi.getActivities("Bearer $token") - val weightKg = prefs.getString("weight_kg", "70")?.toDoubleOrNull() ?: 70.0 - - val sportActivities = activities.map { - SportActivity( - id = it.id, - name = it.name, - type = it.type, - distance = it.distance, - movingTime = it.movingTime, - calories = it.calories ?: ApiClient.estimateCaloriesFromDb( - SportActivity(it.id, it.name, it.type, it.distance, it.movingTime, null, 0L), - weightKg - ).toFloat(), - date = ApiClient.parseStravaDate(it.startDate) - ) - } - dao.insertSports(sportActivities) - Toast.makeText(context, "${activities.size} activités synchronisées !", Toast.LENGTH_SHORT).show() - } catch (e: Exception) { - Log.e("StravaSync", "Error: ${e.message}") - Toast.makeText(context, "Erreur de synchronisation Strava", Toast.LENGTH_SHORT).show() - } - } -} - -@Composable -fun SportScreen(dao: AppDao, prefs: SharedPreferences) { - val sports by dao.getAllSports().collectAsState(initial = emptyList()) - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium) - - Button( - onClick = { syncStravaActivities(dao, prefs, coroutineScope, context) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) - ) { - Icon(Icons.Default.Refresh, null) - Spacer(Modifier.width(8.dp)) - Text("Synchroniser Strava") - } - - Spacer(Modifier.height(8.dp)) - - LazyColumn(Modifier.weight(1f)) { - items(sports) { activity -> - Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Column(modifier = Modifier.padding(16.dp)) { - Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) - val dateLabel = SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)) - Text(dateLabel, style = MaterialTheme.typography.bodySmall) - } - Text("${activity.type} - ${(activity.distance / 1000).format(2)} km") - val calLabel = "${activity.calories?.toInt() ?: 0} kcal brûlées" - Text(calLabel, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) - } - } - } - } - } -} - -fun Float.format(digits: Int) = "%.${digits}f".format(this) - -@Composable -fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) { - var isEditing by remember { mutableStateOf(false) } - val context = LocalContext.current - - var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") } - var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") } - val isStravaConnected = prefs.contains("strava_token") - - if (isEditing) { - SetupScreen(prefs) { - isEditing = false - 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("Configuration Strava", style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(8.dp)) - - OutlinedTextField( - value = stravaClientId, - onValueChange = { stravaClientId = it; prefs.edit { putString("strava_client_id", it) } }, - label = { Text("Strava Client ID") }, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = stravaClientSecret, - onValueChange = { stravaClientSecret = it; prefs.edit { putString("strava_client_secret", it) } }, - label = { Text("Strava Client Secret") }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(8.dp)) - - if (!isStravaConnected) { - Button( - onClick = { - if (stravaClientId.isBlank() || stravaClientSecret.isBlank()) { - Toast.makeText(context, "Entrez votre ID et Secret Client", Toast.LENGTH_SHORT).show() - } else { - val intent = Intent(Intent.ACTION_VIEW, "https://www.strava.com/oauth/mobile/authorize?client_id=$stravaClientId&redirect_uri=coloricam://localhost&response_type=code&approval_prompt=auto&scope=read,activity:read_all".toUri()) - context.startActivity(intent) - } - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Connecter Strava") - } - } else { - OutlinedButton( - onClick = { - prefs.edit { - remove("strava_token") - remove("strava_refresh_token") - } - Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show() - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Déconnecter Strava (Connecté)") - } - } - - Spacer(Modifier.height(32.dp)) - Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { Text("Déconnexion") } - } - } -} - -@Composable -fun ProfileItem(label: String, value: String) { - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text(label, fontWeight = FontWeight.Bold); Text(value) - } -} - -@Composable -fun GlycemiaScreen(dao: AppDao) { - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - var glycemiaValue by remember { mutableStateOf("") } - var moment by remember { mutableStateOf("Avant Déjeuner") } - var selectedDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) } - val calendar = Calendar.getInstance().apply { timeInMillis = selectedDateTime } - - val moments = listOf( - "Avant Déjeuner", "Après Déjeuner", - "Avant Dîner", "Après Dîner", - "Avant Souper", "Après Souper" - ) - - Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) { - Text("Suivi de Glycémie", style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = glycemiaValue, - onValueChange = { glycemiaValue = it }, - label = { Text("Valeur (mmol/L)") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(16.dp)) - Text("Moment :", style = MaterialTheme.typography.titleMedium) - moments.forEach { m -> - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { moment = m }) { - RadioButton(selected = moment == m, onClick = { moment = m }) - Text(m) - } - } - - Spacer(Modifier.height(16.dp)) - Button(onClick = { - DatePickerDialog(context, { _, y, m, d -> - calendar.set(y, m, d) - TimePickerDialog(context, { _, hh, mm -> - calendar.set(Calendar.HOUR_OF_DAY, hh) - calendar.set(Calendar.MINUTE, mm) - selectedDateTime = calendar.timeInMillis - }, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true).show() - }, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show() - }, modifier = Modifier.fillMaxWidth()) { - val dateLabel = SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(selectedDateTime)) - Text("Date/Heure: $dateLabel") - } - - Spacer(Modifier.height(24.dp)) - Button( - onClick = { - val value = glycemiaValue.toDoubleOrNull() - if (value != null) { - coroutineScope.launch { - dao.insertGlycemia(Glycemia(date = selectedDateTime, value = value, moment = moment)) - glycemiaValue = "" - Toast.makeText(context, "Glycémie enregistrée !", Toast.LENGTH_SHORT).show() - } - } - }, - enabled = glycemiaValue.isNotBlank(), - modifier = Modifier.fillMaxWidth() - ) { - Text("Enregistrer") - } - } -} - -@OptIn(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() - ) - // Overlay to guide the user - Box( - modifier = Modifier - .fillMaxSize() - .border(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), MaterialTheme.shapes.medium) - ) - } - } - ) -} diff --git a/app/src/main/java/com/example/scanwich/MainApp.kt b/app/src/main/java/com/example/scanwich/MainApp.kt new file mode 100644 index 0000000..48ce0dd --- /dev/null +++ b/app/src/main/java/com/example/scanwich/MainApp.kt @@ -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) + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/scanwich/Networking.kt b/app/src/main/java/com/example/scanwich/Networking.kt new file mode 100644 index 0000000..ee64fc3 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/Networking.kt @@ -0,0 +1,128 @@ +package com.example.scanwich + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +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 + +// --- 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 + @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://world.openfoodfacts.org/api/v2/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(OffApi::class.java) + + fun getEncryptedPrefs(context: Context): SharedPreferences { + val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() + return EncryptedSharedPreferences.create( + context, + "secure_user_prefs", + 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) { + val refreshToken = prefs.getString("strava_refresh_token", null) ?: return null + val clientId = prefs.getString("strava_client_id", "") ?: "" + val clientSecret = prefs.getString("strava_client_secret", "") ?: "" + return try { + val res = stravaApi.refreshToken(clientId, clientSecret, refreshToken) + prefs.edit { + putString("strava_token", res.accessToken) + putString("strava_refresh_token", res.refreshToken) + putLong("strava_expires_at", res.expiresAt) + } + res.accessToken + } catch (e: Exception) { 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 + } +} diff --git a/app/src/main/java/com/example/scanwich/SettingsScreen.kt b/app/src/main/java/com/example/scanwich/SettingsScreen.kt new file mode 100644 index 0000000..323ab59 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/SettingsScreen.kt @@ -0,0 +1,109 @@ +package com.example.scanwich + +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +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 +import androidx.core.net.toUri + +@Composable +fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) { + var isEditing by remember { mutableStateOf(false) } + val context = LocalContext.current + + var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") } + var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") } + val isStravaConnected = prefs.contains("strava_token") + + if (isEditing) { + SetupScreen(prefs) { + isEditing = false + 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("Configuration Strava", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = stravaClientId, + onValueChange = { stravaClientId = it; prefs.edit { putString("strava_client_id", it) } }, + label = { Text("Strava Client ID") }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = stravaClientSecret, + onValueChange = { stravaClientSecret = it; prefs.edit { putString("strava_client_secret", it) } }, + label = { Text("Strava Client Secret") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(8.dp)) + + if (!isStravaConnected) { + Button( + onClick = { + if (stravaClientId.isBlank() || stravaClientSecret.isBlank()) { + Toast.makeText(context, "Entrez votre ID et Secret Client", Toast.LENGTH_SHORT).show() + } else { + val intent = Intent(Intent.ACTION_VIEW, "https://www.strava.com/oauth/mobile/authorize?client_id=$stravaClientId&redirect_uri=coloricam://localhost&response_type=code&approval_prompt=auto&scope=read,activity:read_all".toUri()) + context.startActivity(intent) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Connecter Strava") + } + } else { + OutlinedButton( + onClick = { + prefs.edit { + remove("strava_token") + remove("strava_refresh_token") + } + Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Déconnecter Strava (Connecté)") + } + } + + Spacer(Modifier.height(32.dp)) + Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { Text("Déconnexion") } + } + } +} + +@Composable +fun ProfileItem(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, fontWeight = FontWeight.Bold); Text(value) + } +} diff --git a/app/src/main/java/com/example/scanwich/SetupScreen.kt b/app/src/main/java/com/example/scanwich/SetupScreen.kt new file mode 100644 index 0000000..e993d4f --- /dev/null +++ b/app/src/main/java/com/example/scanwich/SetupScreen.kt @@ -0,0 +1,188 @@ +package com.example.scanwich + +import android.content.SharedPreferences +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.text.style.TextAlign +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.background +import androidx.compose.runtime.Composable +import androidx.core.content.edit + +@Composable +fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) { + var age by remember { mutableStateOf(prefs.getInt("age", 25).toString()) } + var heightCm by remember { mutableStateOf(prefs.getString("height_cm", "170") ?: "170") } + var weight by remember { mutableStateOf(prefs.getString("weight_display", "70") ?: "70") } + var isLbs by remember { mutableStateOf(prefs.getBoolean("is_lbs", false)) } + var gender by remember { mutableStateOf(prefs.getString("gender", "Homme") ?: "Homme") } + var activityLevel by remember { mutableStateOf(prefs.getString("activity_level", "Sédentaire") ?: "Sédentaire") } + var goal by remember { mutableStateOf(prefs.getString("goal", "Maintenir le poids") ?: "Maintenir le poids") } + var isDiabetic by remember { mutableStateOf(prefs.getBoolean("is_diabetic", false)) } + + val activityLevels = listOf("Sédentaire", "Légèrement actif", "Modérément actif", "Très actif", "Extrêmement actif") + val goals = listOf("Maintenir le poids", "Perdre du poids") + + val activityMultipliers = mapOf( + "Sédentaire" to 1.2, + "Légèrement actif" to 1.375, + "Modérément actif" to 1.55, + "Très actif" to 1.725, + "Extrêmement actif" to 1.9 + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Configuration du profil", style = MaterialTheme.typography.headlineLarge) + Spacer(Modifier.height(24.dp)) + + Text("Votre objectif :", style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.Start)) + goals.forEach { g -> + Row( + Modifier + .fillMaxWidth() + .clickable { goal = g } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton(selected = goal == g, onClick = { goal = g }) + Text(g) + } + } + + Spacer(Modifier.height(16.dp)) + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Genre : ", Modifier.width(80.dp)) + RadioButton(selected = gender == "Homme", onClick = { gender = "Homme" }) + Text("Homme") + Spacer(Modifier.width(16.dp)) + RadioButton(selected = gender == "Femme", onClick = { gender = "Femme" }) + Text("Femme") + } + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = age, + onValueChange = { age = it }, + label = { Text("Âge") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = heightCm, + onValueChange = { heightCm = it }, + label = { Text("Taille (cm)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + + Row(verticalAlignment = Alignment.CenterVertically, 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 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() + + 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) + } + onComplete() + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + enabled = age.isNotBlank() && heightCm.isNotBlank() && weight.isNotBlank() + ) { + Text("Sauvegarder le profil") + } + } +} diff --git a/app/src/main/java/com/example/scanwich/SportScreen.kt b/app/src/main/java/com/example/scanwich/SportScreen.kt new file mode 100644 index 0000000..c278944 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/SportScreen.kt @@ -0,0 +1,96 @@ +package com.example.scanwich + +import android.content.Context +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.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: android.content.SharedPreferences) { + val sports by dao.getAllSports().collectAsState(initial = emptyList()) + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium) + + Button( + onClick = { syncStravaActivities(dao, prefs, coroutineScope, context) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) + ) { + Icon(Icons.Default.Refresh, null) + Spacer(Modifier.width(8.dp)) + Text("Synchroniser Strava") + } + + Spacer(Modifier.height(8.dp)) + + LazyColumn(Modifier.weight(1f)) { + items(sports) { activity -> + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Column(modifier = Modifier.padding(16.dp)) { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Text(SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)), style = MaterialTheme.typography.bodySmall) + } + Text("${activity.type} - ${(activity.distance / 1000).format(2)} km") + Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + } + } + } + } + } +} + +private fun syncStravaActivities(dao: AppDao, prefs: android.content.SharedPreferences, scope: CoroutineScope, context: Context) { + scope.launch { + val token = ApiClient.getValidStravaToken(prefs) + if (token == null) { + Toast.makeText(context, "Veuillez connecter Strava dans les paramètres", Toast.LENGTH_LONG).show() + return@launch + } + try { + val activities = ApiClient.stravaApi.getActivities("Bearer $token") + val weightKg = prefs.getString("weight_kg", "70")?.toDoubleOrNull() ?: 70.0 + + val sportActivities = activities.map { + 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) // Firestore Sync + 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() + } + } +} diff --git a/app/src/main/java/com/example/scanwich/Utils.kt b/app/src/main/java/com/example/scanwich/Utils.kt new file mode 100644 index 0000000..7ed74c0 --- /dev/null +++ b/app/src/main/java/com/example/scanwich/Utils.kt @@ -0,0 +1,107 @@ +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?) -> Unit, + scope: CoroutineScope +) { + setAnalyzing(true) + scope.launch { + try { + val base64 = withContext(Dispatchers.Default) { + bitmap?.let { getOptimizedImageBase64(it) } + } + + val data = hashMapOf("imageBase64" to base64, "mealName" to textDescription) + + 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) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aecd7a1..e2d42b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/release-notes.txt b/release-notes.txt index 5d1abdf..dd0563b 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -1,6 +1,14 @@ 📝 Notes de version - Scan-Wich -Nouveautés et Améliorations : +Dernières mises à jour : +🛠️ Correctifs et Améliorations Strava : +- Résolution d'un problème de compilation bloquant sur l'écran des sports. +- Intégration d'un nouvel algorithme d'estimation des calories basé sur les MET (Metabolic Equivalent of Task) pour une précision accrue des activités Strava sans données de calories natives. +- Amélioration de la fiabilité du parsing des dates d'activités Strava. + +--- + +Nouveautés et Améliorations précédentes : 🛡️ Sécurité renforcée : - Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les accès non autorisés. @@ -29,4 +37,4 @@ Nouveautés et Améliorations : 🔧 Stabilité et Modernisation : - Optimisation pour Android 36. -- Correction de bugs majeurs sur le stockage sécurisé des préférences (MasterKey) et la synchronisation Firebase (collectAsState). \ No newline at end of file +- Correction de bugs majeurs sur le stockage sécurisé des préférences (MasterKey) et la synchronisation Firebase (collectAsState).