changes #2
@@ -11,6 +11,9 @@ data class Meal(
|
|||||||
val name: String = "Repas",
|
val name: String = "Repas",
|
||||||
val analysisText: String,
|
val analysisText: String,
|
||||||
val totalCalories: Int,
|
val totalCalories: Int,
|
||||||
|
val carbs: Int = 0,
|
||||||
|
val protein: Int = 0,
|
||||||
|
val fat: Int = 0,
|
||||||
val type: String = "Collation"
|
val type: String = "Collation"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ interface AppDao {
|
|||||||
fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>>
|
fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class], version = 5)
|
@Database(entities = [Meal::class, Glycemia::class, SportActivity::class], version = 6)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
abstract fun appDao(): AppDao
|
abstract fun appDao(): AppDao
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -44,7 +45,6 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@@ -53,6 +53,7 @@ import com.example.scanwich.ui.theme.ScanwichTheme
|
|||||||
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
||||||
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
|
||||||
import com.google.android.gms.common.api.ApiException
|
import com.google.android.gms.common.api.ApiException
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -75,7 +76,7 @@ data class N8nMealRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface N8nApi {
|
interface N8nApi {
|
||||||
@POST("webhook/v1/gemini-proxy")
|
@POST("webhook-test/v1/gemini-proxy")
|
||||||
suspend fun analyzeMeal(
|
suspend fun analyzeMeal(
|
||||||
@Header("X-API-KEY") apiKey: String,
|
@Header("X-API-KEY") apiKey: String,
|
||||||
@Body request: N8nMealRequest
|
@Body request: N8nMealRequest
|
||||||
@@ -128,9 +129,6 @@ interface StravaApi {
|
|||||||
): StravaTokenResponse
|
): StravaTokenResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// Annotation for Gson
|
|
||||||
annotation class SerializedName(val value: String)
|
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
object ApiClient {
|
object ApiClient {
|
||||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
@@ -514,11 +512,16 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
|
|||||||
var targetCals = (bmr * multiplier).toInt()
|
var targetCals = (bmr * multiplier).toInt()
|
||||||
|
|
||||||
if (goal == "Perdre du poids") targetCals -= 500
|
if (goal == "Perdre du poids") targetCals -= 500
|
||||||
|
|
||||||
val targetCarbs = (targetCals * 0.5 / 4).toInt()
|
val targetCarbs = (targetCals * 0.5 / 4).toInt()
|
||||||
|
val targetProtein = (targetCals * 0.2 / 4).toInt()
|
||||||
|
val targetFat = (targetCals * 0.3 / 9).toInt()
|
||||||
|
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
putString("target_calories", targetCals.toString())
|
putString("target_calories", targetCals.toString())
|
||||||
putString("target_carbs", targetCarbs.toString())
|
putString("target_carbs", targetCarbs.toString())
|
||||||
|
putString("target_protein", targetProtein.toString())
|
||||||
|
putString("target_fat", targetFat.toString())
|
||||||
putString("weight_kg", weightKg.toString())
|
putString("weight_kg", weightKg.toString())
|
||||||
putString("weight_display", weightDisplay)
|
putString("weight_display", weightDisplay)
|
||||||
putBoolean("is_lbs", isLbs)
|
putBoolean("is_lbs", isLbs)
|
||||||
@@ -539,6 +542,7 @@ fun SetupScreen(prefs: SharedPreferences, onComplete: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
@@ -546,9 +550,10 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
|
|
||||||
var capturedBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
var capturedBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
var isAnalyzing by remember { mutableStateOf(false) }
|
var isAnalyzing by remember { mutableStateOf(false) }
|
||||||
var showMealDialog by remember { mutableStateOf(false) }
|
var currentMealData by remember { mutableStateOf<Triple<String, String, List<Int>>?>(null) }
|
||||||
var currentMealData by remember { mutableStateOf<Triple<String, String, Int>?>(null) }
|
|
||||||
var mealDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
var mealDateTime by remember { mutableLongStateOf(System.currentTimeMillis()) }
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||||
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var manualMealName by remember { mutableStateOf("") }
|
var manualMealName by remember { mutableStateOf("") }
|
||||||
|
|
||||||
@@ -559,7 +564,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error ->
|
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error ->
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
currentMealData = data
|
currentMealData = data
|
||||||
showMealDialog = true
|
showBottomSheet = true
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show()
|
Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
@@ -600,7 +605,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error ->
|
analyzeImage(bitmap, null, { isAnalyzing = it }, { data, error ->
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
currentMealData = data
|
currentMealData = data
|
||||||
showMealDialog = true
|
showBottomSheet = true
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "L'IA n'a pas pu identifier le repas.", Toast.LENGTH_LONG).show()
|
Toast.makeText(context, "L'IA n'a pas pu identifier le repas.", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
@@ -611,84 +616,131 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showMealDialog && currentMealData != null) {
|
if (showBottomSheet && currentMealData != null) {
|
||||||
var mealType by remember { mutableStateOf("Déjeuner") }
|
ModalBottomSheet(
|
||||||
val calendar = Calendar.getInstance().apply { timeInMillis = mealDateTime }
|
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 editableName by remember { mutableStateOf(currentMealData!!.first) }
|
||||||
var editableCalories by remember { mutableStateOf(currentMealData!!.third.toString()) }
|
var editableDesc by remember { mutableStateOf(currentMealData!!.second) }
|
||||||
var editableDesc by remember { mutableStateOf(currentMealData!!.second) }
|
|
||||||
|
|
||||||
AlertDialog(
|
val editableCalories = currentMealData!!.third[0].toString()
|
||||||
onDismissRequest = { showMealDialog = false },
|
val editableCarbs = currentMealData!!.third[1].toString()
|
||||||
title = { Text("Détails du repas") },
|
val editableProtein = currentMealData!!.third[2].toString()
|
||||||
text = {
|
val editableFat = currentMealData!!.third[3].toString()
|
||||||
// Ensure the content inside the dialog scrolls
|
|
||||||
Column(modifier = Modifier
|
// Update local state if currentMealData changes (e.g. after resubmission)
|
||||||
.fillMaxWidth()
|
LaunchedEffect(currentMealData) {
|
||||||
.heightIn(max = 450.dp)
|
editableName = currentMealData!!.first
|
||||||
.verticalScroll(rememberScrollState())
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
analyzeImage(capturedBitmap, "$editableName : $editableDesc", { isAnalyzing = it }, { data, error ->
|
||||||
|
if (data != null) {
|
||||||
|
currentMealData = data
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "Analyse échouée : $error", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}, coroutineScope)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
|
||||||
|
enabled = !isAnalyzing
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
Icon(Icons.Default.Refresh, null)
|
||||||
value = editableName,
|
Spacer(Modifier.width(8.dp))
|
||||||
onValueChange = { editableName = it },
|
Text("Ressoumettre à l'IA")
|
||||||
label = { Text("Nom du repas") },
|
}
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = editableCalories,
|
|
||||||
onValueChange = { editableCalories = it },
|
|
||||||
label = { Text("Calories (kcal)") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = editableDesc,
|
|
||||||
onValueChange = { editableDesc = it },
|
|
||||||
label = { Text("Description") },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Text("Type de repas :")
|
Text("Catégorie :", fontWeight = FontWeight.Bold)
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
listOf("Déjeuner", "Dîner", "Souper", "Collation").forEach { type ->
|
listOf("Déjeuner", "Dîner", "Souper", "Collation").forEach { type ->
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { mealType = type }) {
|
FilterChip(selected = mealType == type, onClick = { mealType = type }, label = { Text(type) })
|
||||||
RadioButton(selected = mealType == type, onClick = { mealType = type })
|
|
||||||
Text(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.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()
|
|
||||||
}) {
|
|
||||||
Text("Modifier Date/Heure: " + SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(mealDateTime)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
confirmButton = {
|
Spacer(Modifier.height(16.dp))
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
coroutineScope.launch {
|
DatePickerDialog(context, { _, y, m, d ->
|
||||||
dao.insertMeal(Meal(
|
calendar.set(y, m, d)
|
||||||
date = mealDateTime,
|
TimePickerDialog(context, { _, hh, mm ->
|
||||||
name = editableName,
|
calendar.set(Calendar.HOUR_OF_DAY, hh)
|
||||||
analysisText = editableDesc,
|
calendar.set(Calendar.MINUTE, mm)
|
||||||
totalCalories = editableCalories.toIntOrNull() ?: 0,
|
mealDateTime = calendar.timeInMillis
|
||||||
type = mealType
|
}, 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()
|
||||||
showMealDialog = false
|
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) {
|
||||||
capturedBitmap = null
|
Icon(Icons.Default.DateRange, null)
|
||||||
Toast.makeText(context, "Repas enregistré !", Toast.LENGTH_SHORT).show()
|
Spacer(Modifier.width(8.dp))
|
||||||
}
|
Text("Date/Heure: " + SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(mealDateTime)))
|
||||||
}) { Text("Enregistrer") }
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
dao.insertMeal(Meal(
|
||||||
|
date = mealDateTime,
|
||||||
|
name = editableName,
|
||||||
|
analysisText = editableDesc,
|
||||||
|
totalCalories = editableCalories.toIntOrNull() ?: 0,
|
||||||
|
carbs = editableCarbs.toIntOrNull() ?: 0,
|
||||||
|
protein = editableProtein.toIntOrNull() ?: 0,
|
||||||
|
fat = editableFat.toIntOrNull() ?: 0,
|
||||||
|
type = mealType
|
||||||
|
))
|
||||||
|
showBottomSheet = false
|
||||||
|
capturedBitmap = null
|
||||||
|
Toast.makeText(context, "Repas enregistré !", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
enabled = !isAnalyzing
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Check, null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Confirmer et Enregistrer")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
|
Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
|
||||||
@@ -742,7 +794,7 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
analyzeImage(null, manualMealName, { isAnalyzing = it }, { data, error ->
|
analyzeImage(null, manualMealName, { isAnalyzing = it }, { data, error ->
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
currentMealData = data
|
currentMealData = data
|
||||||
showMealDialog = true
|
showBottomSheet = true
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "Erreur AI: $error", Toast.LENGTH_LONG).show()
|
Toast.makeText(context, "Erreur AI: $error", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
@@ -758,9 +810,9 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
currentMealData = Triple(manualMealName, "Ajout manuel", 0)
|
currentMealData = Triple(manualMealName, "Ajout manuel", listOf(0, 0, 0, 0))
|
||||||
mealDateTime = System.currentTimeMillis()
|
mealDateTime = System.currentTimeMillis()
|
||||||
showMealDialog = true
|
showBottomSheet = true
|
||||||
},
|
},
|
||||||
enabled = manualMealName.isNotBlank() && !isAnalyzing,
|
enabled = manualMealName.isNotBlank() && !isAnalyzing,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
@@ -771,6 +823,37 @@ fun CaptureScreen(dao: AppDao, prefs: SharedPreferences, isDiabetic: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
||||||
var selectedDate by remember { mutableStateOf(Calendar.getInstance()) }
|
var selectedDate by remember { mutableStateOf(Calendar.getInstance()) }
|
||||||
@@ -787,12 +870,16 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
var selectedMealForDetail by remember { mutableStateOf<Meal?>(null) }
|
var selectedMealForDetail by remember { mutableStateOf<Meal?>(null) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
if (selectedMealForDetail != null) {
|
if (selectedMealForDetail != null) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { selectedMealForDetail = null },
|
onDismissRequest = { selectedMealForDetail = null },
|
||||||
title = { Text(selectedMealForDetail!!.name) },
|
title = { Text(selectedMealForDetail!!.name) },
|
||||||
text = {
|
text = {
|
||||||
// Ensure history detail dialog scrolls
|
|
||||||
Column(modifier = Modifier
|
Column(modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = 450.dp)
|
.heightIn(max = 450.dp)
|
||||||
@@ -800,6 +887,7 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
) {
|
) {
|
||||||
Text("Type: ${selectedMealForDetail!!.type}", fontWeight = FontWeight.Bold)
|
Text("Type: ${selectedMealForDetail!!.type}", fontWeight = FontWeight.Bold)
|
||||||
Text("Calories: ${selectedMealForDetail!!.totalCalories} kcal")
|
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))}")
|
Text("Heure: ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(selectedMealForDetail!!.date))}")
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Text("Analyse complète :", style = MaterialTheme.typography.labelMedium)
|
Text("Analyse complète :", style = MaterialTheme.typography.labelMedium)
|
||||||
@@ -843,23 +931,27 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
|
|
||||||
val totalIn = meals.sumOf { it.totalCalories }
|
val totalIn = meals.sumOf { it.totalCalories }
|
||||||
val totalOut = sports.sumOf { it.calories?.toInt() ?: 0 }
|
val totalOut = sports.sumOf { it.calories?.toInt() ?: 0 }
|
||||||
val balance = totalIn - totalOut
|
val totalCarbs = meals.sumOf { it.carbs }
|
||||||
|
val totalProt = meals.sumOf { it.protein }
|
||||||
|
val totalFat = meals.sumOf { it.fat }
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Text("Objectifs Quotidiens", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Text("Calories Consommées:")
|
Spacer(Modifier.height(12.dp))
|
||||||
Text("$totalIn kcal", fontWeight = FontWeight.Bold)
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
DailyGoalChart("Calories", totalIn, tCal, Color(0xFF4CAF50))
|
||||||
|
DailyGoalChart("Glucides", totalCarbs, tCarb, Color(0xFF2196F3))
|
||||||
|
DailyGoalChart("Protéines", totalProt, tProt, Color(0xFFFF9800))
|
||||||
|
DailyGoalChart("Lipides", totalFat, tFat, Color(0xFFE91E63))
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Text("Calories Brûlées (Sport):")
|
Text("Sport (Brûlées):")
|
||||||
Text("$totalOut kcal", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
Text("$totalOut kcal", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
|
||||||
Text("Bilan net:", fontWeight = FontWeight.Bold)
|
|
||||||
Text("$balance kcal", fontWeight = FontWeight.ExtraBold, color = if(balance > 0) Color.Red else Color.Green)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,7 +969,6 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
Text(category, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
|
Text(category, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display Before Glycemia
|
|
||||||
if (isDiabetic && beforeGly != null) {
|
if (isDiabetic && beforeGly != null) {
|
||||||
item {
|
item {
|
||||||
Surface(color = Color.Blue.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
|
Surface(color = Color.Blue.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
|
||||||
@@ -889,7 +980,7 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
items(categoryMeals) { meal ->
|
items(categoryMeals) { meal ->
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(meal.name) },
|
headlineContent = { Text(meal.name) },
|
||||||
supportingContent = { Text("${meal.totalCalories} kcal - ${SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(meal.date))}") },
|
supportingContent = { Text("${meal.totalCalories} kcal - G:${meal.carbs}g P:${meal.protein}g L:${meal.fat}g") },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
IconButton(onClick = { /* TODO */ }) { Icon(Icons.Default.Delete, null) }
|
IconButton(onClick = { /* TODO */ }) { Icon(Icons.Default.Delete, null) }
|
||||||
},
|
},
|
||||||
@@ -897,7 +988,6 @@ fun HistoryScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display After Glycemia
|
|
||||||
if (isDiabetic && afterGly != null) {
|
if (isDiabetic && afterGly != null) {
|
||||||
item {
|
item {
|
||||||
Surface(color = Color.Green.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
|
Surface(color = Color.Green.copy(alpha = 0.1f), modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small)) {
|
||||||
@@ -928,7 +1018,7 @@ private fun analyzeImage(
|
|||||||
bitmap: Bitmap?,
|
bitmap: Bitmap?,
|
||||||
textDescription: String?,
|
textDescription: String?,
|
||||||
setAnalyzing: (Boolean) -> Unit,
|
setAnalyzing: (Boolean) -> Unit,
|
||||||
onResult: (Triple<String, String, Int>?, String?) -> Unit,
|
onResult: (Triple<String, String, List<Int>>?, String?) -> Unit,
|
||||||
scope: CoroutineScope
|
scope: CoroutineScope
|
||||||
) {
|
) {
|
||||||
setAnalyzing(true)
|
setAnalyzing(true)
|
||||||
@@ -940,10 +1030,12 @@ private fun analyzeImage(
|
|||||||
base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
val prompt = if (bitmap != null) {
|
val prompt = if (bitmap != null && textDescription == null) {
|
||||||
"Analyze this food image. Provide ONLY: 1. Name (ex: 'Bol de Ramen'), 2. Summary description, 3. Total calories. Format EXACTLY as: {\"name\": \"...\", \"description\": \"...\", \"calories\": integer}"
|
"Analyze this food image in FRENCH. Provide ONLY: 1. Name, 2. Summary description in FRENCH, 3. Macros. Format EXACTLY as: {\"name\": \"...\", \"description\": \"...\", \"calories\": int, \"carbs\": int, \"protein\": int, \"fat\": int}"
|
||||||
|
} else if (bitmap != null && textDescription != null) {
|
||||||
|
"Analyze this food image in FRENCH, taking into account these corrections or details: '$textDescription'. Provide ONLY: 1. Name, 2. Summary description in FRENCH, 3. Macros. Format EXACTLY as: {\"name\": \"...\", \"description\": \"...\", \"calories\": int, \"carbs\": int, \"protein\": int, \"fat\": int}"
|
||||||
} else {
|
} else {
|
||||||
"Analyze this meal description: '$textDescription'. Estimate the calories. Provide ONLY a JSON object. Format EXACTLY as: {\"name\": \"...\", \"description\": \"...\", \"calories\": integer}"
|
"Analyze this meal description in FRENCH: '$textDescription'. Estimate the macros. Provide ONLY a JSON object. Format EXACTLY as: {\"name\": \"...\", \"description\": \"...\", \"calories\": int, \"carbs\": int, \"protein\": int, \"fat\": int}"
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -958,7 +1050,6 @@ private fun analyzeImage(
|
|||||||
)
|
)
|
||||||
val responseStr = response.string()
|
val responseStr = response.string()
|
||||||
|
|
||||||
// Extract JSON
|
|
||||||
val jsonStartIndex = responseStr.indexOf("{")
|
val jsonStartIndex = responseStr.indexOf("{")
|
||||||
val jsonEndIndex = responseStr.lastIndexOf("}") + 1
|
val jsonEndIndex = responseStr.lastIndexOf("}") + 1
|
||||||
|
|
||||||
@@ -969,20 +1060,17 @@ private fun analyzeImage(
|
|||||||
onResult(Triple(
|
onResult(Triple(
|
||||||
json.optString("name", textDescription ?: "Repas"),
|
json.optString("name", textDescription ?: "Repas"),
|
||||||
json.optString("description", "Analyse réussie"),
|
json.optString("description", "Analyse réussie"),
|
||||||
json.optInt("calories", 0)
|
listOf(
|
||||||
|
json.optInt("calories", 0),
|
||||||
|
json.optInt("carbs", 0),
|
||||||
|
json.optInt("protein", 0),
|
||||||
|
json.optInt("fat", 0)
|
||||||
|
)
|
||||||
), null)
|
), null)
|
||||||
return@launch
|
return@launch
|
||||||
} catch (je: Exception) { }
|
} catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
|
onResult(null, "Format de réponse inconnu")
|
||||||
// Fallback for text
|
|
||||||
val nameMatch = "(?:Nom|Name)\\s*[:\\-]?\\s*([^\\n.*]+)".toRegex(RegexOption.IGNORE_CASE).find(responseStr)
|
|
||||||
val calMatch = "(?:Total|Calories|Kcal)\\s*[:\\-]?\\s*(\\d+)".toRegex(RegexOption.IGNORE_CASE).find(responseStr)
|
|
||||||
|
|
||||||
val detectedName = nameMatch?.groupValues?.get(1)?.trim() ?: textDescription ?: "Repas"
|
|
||||||
val calories = calMatch?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
|
||||||
onResult(Triple(detectedName, responseStr.take(1000), calories), null)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
onResult(null, e.localizedMessage ?: "Erreur réseau")
|
onResult(null, e.localizedMessage ?: "Erreur réseau")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1028,7 +1116,6 @@ private fun syncStravaActivities(dao: AppDao, prefs: SharedPreferences, scope: C
|
|||||||
@Composable
|
@Composable
|
||||||
fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
|
fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
|
||||||
val sports by dao.getAllSports().collectAsState(initial = emptyList())
|
val sports by dao.getAllSports().collectAsState(initial = emptyList())
|
||||||
val weightKg = prefs.getString("weight_kg", "70")?.toDoubleOrNull() ?: 70.0
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
@@ -1063,7 +1150,6 @@ fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to format float
|
|
||||||
fun Float.format(digits: Int) = "%.${digits}f".format(this)
|
fun Float.format(digits: Int) = "%.${digits}f".format(this)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user