450 lines
21 KiB
Kotlin
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)")
|
|
}
|
|
}
|
|
}
|
|
}
|