Files
calorie/app/src/main/java/com/example/scanwich/CaptureScreen.kt
2026-02-24 10:03:35 -05:00

450 lines
21 KiB
Kotlin

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