This commit is contained in:
mac
2026-02-24 10:03:35 -05:00
parent e9c586adcd
commit 1bb637ae62
19 changed files with 2112 additions and 1876 deletions

View File

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

View File

@@ -0,0 +1,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<FirebaseUser?>(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)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<Meal?>(null) }
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val tCal = prefs.getString("target_calories", "2000")?.toIntOrNull() ?: 2000
val tCarb = prefs.getString("target_carbs", "250")?.toIntOrNull() ?: 250
val tProt = prefs.getString("target_protein", "100")?.toIntOrNull() ?: 100
val tFat = prefs.getString("target_fat", "60")?.toIntOrNull() ?: 60
val isDark = isSystemInDarkTheme()
val calorieColor = if (isDark) Color(0xFF4CAF50) else Color(0xFF2E7D32)
val carbsColor = if (isDark) Color(0xFF2196F3) else Color(0xFF1565C0)
val proteinColor = if (isDark) Color(0xFFFF9800) else ReadableAmber
val fatColor = if (isDark) Color(0xFFE91E63) else Color(0xFFC2185B)
var showExportDialog by remember { mutableStateOf(false) }
var exportStartDate by remember { mutableStateOf(Calendar.getInstance()) }
var exportEndDate by remember { mutableStateOf(Calendar.getInstance()) }
val createPdfLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/pdf")) { uri ->
uri?.let {
coroutineScope.launch {
val start = exportStartDate.clone() as Calendar
start.set(Calendar.HOUR_OF_DAY, 0); start.set(Calendar.MINUTE, 0); start.set(Calendar.SECOND, 0)
val end = exportEndDate.clone() as Calendar
end.set(Calendar.HOUR_OF_DAY, 23); end.set(Calendar.MINUTE, 59); end.set(Calendar.SECOND, 59)
val mealsToExport = dao.getMealsInRangeSync(start.timeInMillis, end.timeInMillis)
val sportsToExport = dao.getSportsInRangeSync(start.timeInMillis, end.timeInMillis)
val glycemiaToExport = dao.getGlycemiaInRangeSync(start.timeInMillis, end.timeInMillis)
withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(it)?.use { os ->
generatePdfReport(os, mealsToExport, sportsToExport, glycemiaToExport, start.time, end.time)
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "PDF Exporté avec succès !", Toast.LENGTH_LONG).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(context, "Erreur PDF: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
}
}
}
if (showMonthPicker) {
AlertDialog(
onDismissRequest = { showMonthPicker = false },
title = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.MONTH, -1)
selectedDate = newDate
}) { Icon(Icons.Default.ArrowBack, null) }
Text(SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(selectedDate.time))
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.MONTH, 1)
selectedDate = newDate
}) { Icon(Icons.Default.ArrowForward, null) }
}
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
val daysOfWeek = listOf("L", "M", "M", "J", "V", "S", "D")
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
daysOfWeek.forEach { day ->
Text(day, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(32.dp), textAlign = TextAlign.Center)
}
}
val cal = selectedDate.clone() as Calendar
cal.set(Calendar.DAY_OF_MONTH, 1)
val firstDayIdx = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7
val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
val gridItems = mutableListOf<Int?>()
repeat(firstDayIdx) { gridItems.add(null) }
for (i in 1..daysInMonth) { gridItems.add(i) }
LazyVerticalGrid(
columns = GridCells.Fixed(7),
modifier = Modifier.height(250.dp).padding(top = 8.dp)
) {
items(gridItems) { day ->
if (day != null) {
val dayCal = selectedDate.clone() as Calendar
dayCal.set(Calendar.DAY_OF_MONTH, day)
dayCal.set(Calendar.HOUR_OF_DAY, 0); dayCal.set(Calendar.MINUTE, 0); dayCal.set(Calendar.SECOND, 0); dayCal.set(Calendar.MILLISECOND, 0)
val hasData = normalizedDatesWithData.contains(dayCal.timeInMillis)
val isSelected = day == selectedDate.get(Calendar.DAY_OF_MONTH)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable {
selectedDate = dayCal
showMonthPicker = false
}
.padding(4.dp)
) {
Text(
text = day.toString(),
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
if (hasData) {
Box(Modifier.size(4.dp).clip(CircleShape).background(if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary))
}
}
} else {
Spacer(Modifier.size(40.dp))
}
}
}
}
},
confirmButton = { TextButton(onClick = { showMonthPicker = false }) { Text("Fermer") } }
)
}
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
title = { Text("Exporter l'historique") },
text = {
Column {
Text("Sélectionnez la plage de dates :")
Spacer(Modifier.height(16.dp))
Button(onClick = {
DatePickerDialog(context, { _, y, m, d ->
val newDate = Calendar.getInstance()
newDate.set(y, m, d)
exportStartDate = newDate
}, exportStartDate.get(Calendar.YEAR), exportStartDate.get(Calendar.MONTH), exportStartDate.get(Calendar.DAY_OF_MONTH)).show()
}, modifier = Modifier.fillMaxWidth()) {
Text("Du: " + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportStartDate.time))
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
DatePickerDialog(context, { _, y, m, d ->
val newDate = Calendar.getInstance()
newDate.set(y, m, d)
exportEndDate = newDate
}, exportEndDate.get(Calendar.YEAR), exportEndDate.get(Calendar.MONTH), exportEndDate.get(Calendar.DAY_OF_MONTH)).show()
}, modifier = Modifier.fillMaxWidth()) {
Text("Au: " + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(exportEndDate.time))
}
}
},
confirmButton = {
TextButton(onClick = {
showExportDialog = false
val fileName = "ScanWich_Rapport_${SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportStartDate.time)}_au_${SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(exportEndDate.time)}.pdf"
createPdfLauncher.launch(fileName)
}) { Text("Exporter") }
},
dismissButton = {
TextButton(onClick = { showExportDialog = false }) { Text("Annuler") }
}
)
}
if (selectedMealForDetail != null) {
AlertDialog(
onDismissRequest = { selectedMealForDetail = null },
title = { Text(selectedMealForDetail!!.name) },
text = {
Column(modifier = Modifier
.fillMaxWidth()
.heightIn(max = 450.dp)
.verticalScroll(rememberScrollState())
) {
Text("Type: ${selectedMealForDetail!!.type}", fontWeight = FontWeight.Bold)
Text("Calories: ${selectedMealForDetail!!.totalCalories} kcal")
Text("Macro: G ${selectedMealForDetail!!.carbs}g | P ${selectedMealForDetail!!.protein}g | L ${selectedMealForDetail!!.fat}g")
Text("Heure: ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(selectedMealForDetail!!.date))}")
Spacer(Modifier.height(8.dp))
Text("Analyse complète :", style = MaterialTheme.typography.labelMedium)
Text(selectedMealForDetail!!.analysisText)
}
},
confirmButton = {
TextButton(onClick = { selectedMealForDetail = null }) { Text("Fermer") }
}
)
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, -1)
selectedDate = newDate
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, null) }
Column(modifier = Modifier.weight(1f).clickable { showMonthPicker = true }) {
Text(
text = SimpleDateFormat("EEEE d MMMM", Locale.getDefault()).format(selectedDate.time).replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Cliquer pour voir le calendrier",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = { showExportDialog = true }) {
Icon(Icons.Default.PictureAsPdf, contentDescription = "Export PDF", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = {
val newDate = selectedDate.clone() as Calendar
newDate.add(Calendar.DAY_OF_MONTH, 1)
selectedDate = newDate
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) }
}
Spacer(Modifier.height(16.dp))
val totalIn = meals.sumOf { it.totalCalories }
val totalOut = sports.sumOf { it.calories?.toInt() ?: 0 }
val 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<Meal>,
sports: List<SportActivity>,
glycemia: List<Glycemia>,
startDate: Date,
endDate: Date
) {
val writer = com.itextpdf.kernel.pdf.PdfWriter(outputStream)
val pdf = com.itextpdf.kernel.pdf.PdfDocument(writer)
val document = com.itextpdf.layout.Document(pdf)
val df = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
val tf = SimpleDateFormat("HH:mm", Locale.getDefault())
// Header
document.add(com.itextpdf.layout.element.Paragraph("Rapport d'Historique Scan-Wich")
.setTextAlignment(com.itextpdf.layout.properties.TextAlignment.CENTER)
.setFontSize(20f)
.setBold())
document.add(com.itextpdf.layout.element.Paragraph("Période: ${df.format(startDate)} au ${df.format(endDate)}")
.setTextAlignment(com.itextpdf.layout.properties.TextAlignment.CENTER)
.setFontSize(12f))
document.add(com.itextpdf.layout.element.Paragraph("\n"))
// Meals Table
if (meals.isNotEmpty()) {
document.add(com.itextpdf.layout.element.Paragraph("Repas").setBold().setFontSize(14f))
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f, 1f, 1f, 1f, 1f))).useAllAvailableWidth()
table.addHeaderCell("Date")
table.addHeaderCell("Nom")
table.addHeaderCell("Type")
table.addHeaderCell("Kcal")
table.addHeaderCell("Glu")
table.addHeaderCell("Pro")
table.addHeaderCell("Lip")
meals.forEach { meal ->
table.addCell(df.format(Date(meal.date)) + " " + tf.format(Date(meal.date)))
table.addCell(meal.name)
table.addCell(meal.type)
table.addCell(meal.totalCalories.toString())
table.addCell(meal.carbs.toString())
table.addCell(meal.protein.toString())
table.addCell(meal.fat.toString())
}
document.add(table)
document.add(com.itextpdf.layout.element.Paragraph("\n"))
}
// Glycemia Table
if (glycemia.isNotEmpty()) {
document.add(com.itextpdf.layout.element.Paragraph("Glycémie").setBold().setFontSize(14f))
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f))).useAllAvailableWidth()
table.addHeaderCell("Date/Heure")
table.addHeaderCell("Moment")
table.addHeaderCell("Valeur (mmol/L)")
glycemia.forEach { gly ->
table.addCell(df.format(Date(gly.date)) + " " + tf.format(Date(gly.date)))
table.addCell(gly.moment)
table.addCell(gly.value.toString())
}
document.add(table)
document.add(com.itextpdf.layout.element.Paragraph("\n"))
}
// Sports Table
if (sports.isNotEmpty()) {
document.add(com.itextpdf.layout.element.Paragraph("Activités Sportives").setBold().setFontSize(14f))
val table = com.itextpdf.layout.element.Table(com.itextpdf.layout.properties.UnitValue.createPercentArray(floatArrayOf(2f, 3f, 2f, 2f))).useAllAvailableWidth()
table.addHeaderCell("Date")
table.addHeaderCell("Activité")
table.addHeaderCell("Type")
table.addHeaderCell("Calories")
sports.forEach { sport ->
table.addCell(df.format(Date(sport.date)))
table.addCell(sport.name)
table.addCell(sport.type)
table.addCell(sport.calories?.toInt()?.toString() ?: "0")
}
document.add(table)
}
document.close()
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,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<StravaActivity>
@POST("oauth/token")
suspend fun exchangeToken(@Query("client_id") clientId: String, @Query("client_secret") clientSecret: String, @Query("code") code: String, @Query("grant_type") grantType: String = "authorization_code"): StravaTokenResponse
@POST("oauth/token")
suspend fun refreshToken(@Query("client_id") clientId: String, @Query("client_secret") clientSecret: String, @Query("refresh_token") refreshToken: String, @Query("grant_type") grantType: String = "refresh_token"): StravaTokenResponse
}
object ApiClient {
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
val stravaApi: StravaApi = Retrofit.Builder()
.baseUrl("https://www.strava.com/api/v3/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(StravaApi::class.java)
val offApi: OffApi = Retrofit.Builder()
.baseUrl("https://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
}
}

View File

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

View File

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

View File

@@ -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()
}
}
}

View File

@@ -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, String, List<Int>>?, 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)
}
}
}