Compare commits

...

3 commits

Author SHA1 Message Date
Daniel
03d4178113 Bump to v1.3
All checks were successful
Android CI / build (push) Successful in 1m40s
Web CI / test (push) Successful in 52s
Android Release / release-apk (push) Successful in 1m40s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:39:49 +02:00
Daniel
a4e44201a2 Fix AI nutrition extraction: robust parser + better prompt
- NutritionParser now handles short key aliases (protein/carbs/fat instead of
  proteinGrams/carbsGrams/fatGrams), string-typed numbers, surrounding prose,
  and multi-line markdown fences reliably
- safeInt/safeDouble helpers check multiple key names in priority order and
  handle Number, String, and missing values without throwing
- Nutrition prompt now includes an explicit JSON example so all models return
  the expected format regardless of their default style
- Same fixes applied to web app parseEstimate (aliases + fence stripping)
- Extended NutritionParserTest to cover aliases, string numbers, surrounding text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:39:33 +02:00
Daniel
b93e479be4 Fix camera, add photo preview, native date/time pickers, auto-navigate dashboard after save
- Replace TakePicturePreview with TakePicture + FileProvider for reliable full-res camera capture
- Add FileProvider to manifest with cache-path for camera photos
- Show selected photo as a thumbnail preview in the Log screen
- Replace plain date/time text fields with clickable fields that open native Android pickers
- After AI analysis or manual edit save, auto-navigate to Dashboard so results are visible immediately
- Clear editing/image state after successful save so Log screen resets for next entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:18:26 +02:00
9 changed files with 315 additions and 86 deletions

View file

@ -12,8 +12,8 @@ android {
applicationId 'com.danvics.calorieai'
minSdk 26
targetSdk 35
versionCode 3
versionName '1.2'
versionCode 4
versionName '1.3'
}
buildFeatures {

View file

@ -17,5 +17,14 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -1,5 +1,7 @@
package com.danvics.calorieai
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
@ -8,6 +10,7 @@ import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import com.danvics.calorieai.ai.ImagePayload
import com.danvics.calorieai.ai.NutritionParser
import com.danvics.calorieai.data.*
@ -19,6 +22,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@ -38,6 +42,11 @@ private fun AppRoot(repo: ApiRepository) {
val scope = rememberCoroutineScope()
val parser = remember { NutritionParser() }
val cameraImageFile = remember { File(context.cacheDir, "cam_capture.jpg") }
val cameraImageUri: Uri = remember {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", cameraImageFile)
}
var connected by remember { mutableStateOf(repo.isConnected()) }
var connectError by remember { mutableStateOf("") }
var connecting by remember { mutableStateOf(false) }
@ -45,30 +54,48 @@ private fun AppRoot(repo: ApiRepository) {
var appState by remember { mutableStateOf(AppState()) }
var editing by remember { mutableStateOf<MealEntry?>(null) }
var selectedImage by remember { mutableStateOf<ImagePayload?>(null) }
var selectedImageBitmap by remember { mutableStateOf<Bitmap?>(null) }
var status by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var planStatus by remember { mutableStateOf("") }
var planBusy by remember { mutableStateOf(false) }
var syncing by remember { mutableStateOf(false) }
var mealSaveCount by remember { mutableStateOf(0) }
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) {
scope.launch(Dispatchers.IO) {
val payload = runCatching {
context.contentResolver.openInputStream(uri)?.use { stream ->
ImagePayload.fromBytes(
uri.lastPathSegment ?: "image",
context.contentResolver.getType(uri) ?: "image/jpeg",
stream.readBytes()
)
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
if (bytes != null) {
val bitmap = decodeSampledBitmap(bytes)
val payload = ImagePayload.fromBytes(
uri.lastPathSegment ?: "image",
context.contentResolver.getType(uri) ?: "image/jpeg",
bytes
)
withContext(Dispatchers.Main) {
selectedImage = payload
selectedImageBitmap = bitmap
}
}.getOrNull()
withContext(Dispatchers.Main) { if (payload != null) selectedImage = payload }
}
}
}
}
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
if (bitmap != null) selectedImage = ImagePayload.fromBitmap("camera photo", bitmap)
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
scope.launch(Dispatchers.IO) {
val bytes = cameraImageFile.readBytes()
if (bytes.isNotEmpty()) {
val bitmap = decodeSampledBitmap(bytes)
val payload = ImagePayload.fromBytes("photo.jpg", "image/jpeg", bytes)
withContext(Dispatchers.Main) {
selectedImage = payload
selectedImageBitmap = bitmap
}
}
}
}
}
fun handleUnauth() { connected = false; appState = AppState() }
@ -88,21 +115,21 @@ private fun AppRoot(repo: ApiRepository) {
if (!connected) {
CalorieTheme {
ConnectScreen(error = connectError, connecting = connecting) { url, user, pass ->
connecting = true
connectError = ""
scope.launch(Dispatchers.IO) {
runCatching { repo.login(url, user, pass) }
.onSuccess { cookie ->
repo.saveConfig(url, cookie)
withContext(Dispatchers.Main) { connecting = false; connected = true }
}
.onFailure { e ->
withContext(Dispatchers.Main) { connecting = false; connectError = e.message ?: "Connection failed" }
}
ConnectScreen(error = connectError, connecting = connecting) { url, user, pass ->
connecting = true
connectError = ""
scope.launch(Dispatchers.IO) {
runCatching { repo.login(url, user, pass) }
.onSuccess { cookie ->
repo.saveConfig(url, cookie)
withContext(Dispatchers.Main) { connecting = false; connected = true }
}
.onFailure { e ->
withContext(Dispatchers.Main) { connecting = false; connectError = e.message ?: "Connection failed" }
}
}
}
}
}
return
}
@ -113,13 +140,20 @@ private fun AppRoot(repo: ApiRepository) {
busy = busy,
editing = editing,
selectedImageName = selectedImage?.name.orEmpty(),
selectedImageBitmap = selectedImageBitmap,
planStatus = planStatus,
planBusy = planBusy,
syncing = syncing,
mealSaveCount = mealSaveCount,
onSync = { syncState() },
onPickImage = { imagePicker.launch("image/*") },
onTakePhoto = { camera.launch(null) },
onCancelEdit = { editing = null; selectedImage = null; status = "" },
onTakePhoto = { camera.launch(cameraImageUri) },
onCancelEdit = {
editing = null
selectedImage = null
selectedImageBitmap = null
status = ""
},
onSaveManualEdit = { updated ->
scope.launch(Dispatchers.IO) {
runCatching { repo.upsertEntry(updated) }
@ -127,7 +161,10 @@ private fun AppRoot(repo: ApiRepository) {
withContext(Dispatchers.Main) {
appState = appState.copy(entries = entries, trash = trash)
editing = null
selectedImage = null
selectedImageBitmap = null
status = "Meal updated."
mealSaveCount++
}
}
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() else status = "Save failed: ${e.message}" } }
@ -158,9 +195,11 @@ private fun AppRoot(repo: ApiRepository) {
val (entries, trash) = pair
withContext(Dispatchers.Main) {
appState = appState.copy(entries = entries, trash = trash)
editing = meal
editing = null
selectedImage = null
selectedImageBitmap = null
status = "Saved. ${meal.estimate.mealName} · ${meal.estimate.calories} kcal"
mealSaveCount++
busy = false
}
}.onFailure { e ->
@ -289,6 +328,14 @@ private fun AppRoot(repo: ApiRepository) {
)
}
private fun decodeSampledBitmap(bytes: ByteArray, maxWidth: Int = 600): Bitmap {
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
var sample = 1
while (opts.outWidth / sample > maxWidth) sample *= 2
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample })!!
}
private fun buildVisionBody(model: String, description: String, measure: String, image: ImagePayload): JSONObject =
JSONObject().put("model", model).put("temperature", 0.15)
.put("messages", JSONArray().put(JSONObject().put("role", "user").put("content",
@ -300,9 +347,13 @@ private fun buildVisionBody(model: String, description: String, measure: String,
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.1)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap in markdown."))
.put(JSONObject().put("role", "system").put("content",
"You are a nutrition estimator. Respond with ONLY a JSON object — no markdown, no explanation, no extra text.\n" +
"Required keys: mealName (string), calories (integer), proteinGrams (integer), carbsGrams (integer), fatGrams (integer), fruitServings (decimal), vegetableServings (decimal), foodGroups (string), notes (string).\n" +
"Example: {\"mealName\":\"Grilled chicken and rice\",\"calories\":520,\"proteinGrams\":45,\"carbsGrams\":48,\"fatGrams\":10,\"fruitServings\":0,\"vegetableServings\":1.5,\"foodGroups\":\"protein, grains\",\"notes\":\"standard portion\"}"
))
.put(JSONObject().put("role", "user").put("content",
"Estimate nutrition for one meal. Return JSON with keys: mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. Use integers for grams and calories.\n\nDescription: $description\nPortion: $measure\nImage estimate: $visionEstimate"
"Estimate the nutrition for this meal:\nDescription: $description\nPortion: $measure\nAdditional context: $visionEstimate"
)))
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =

View file

@ -6,29 +6,58 @@ import org.json.JSONObject
class NutritionParser {
fun parse(content: String): NutritionEstimate {
// Strip markdown code fences and extract the JSON object
var cleaned = content.trim()
.removePrefix("```json")
.removePrefix("```")
.removeSuffix("```")
.trim()
val fenceEnd = cleaned.indexOf('\n')
if (cleaned.startsWith("```") && fenceEnd >= 0) cleaned = cleaned.substring(fenceEnd + 1)
if (cleaned.endsWith("```")) cleaned = cleaned.dropLast(3)
cleaned = cleaned.trim()
val start = cleaned.indexOf('{')
val end = cleaned.lastIndexOf('}')
if (start >= 0 && end > start) cleaned = cleaned.substring(start, end + 1)
val json = JSONObject(cleaned)
return NutritionEstimate(
mealName = json.optString("mealName", "Meal").ifBlank { "Meal" },
calories = json.optInt("calories").coerceAtLeast(0),
proteinGrams = json.optInt("proteinGrams").coerceAtLeast(0),
carbsGrams = json.optInt("carbsGrams").coerceAtLeast(0),
fatGrams = json.optInt("fatGrams").coerceAtLeast(0),
fruitServings = json.optDouble("fruitServings", json.optDouble("fruitsServings", 0.0)).coerceAtLeast(0.0),
vegetableServings = json.optDouble("vegetableServings", json.optDouble("vegetablesServings", 0.0)).coerceAtLeast(0.0),
mealName = json.optString("mealName", "").ifBlank { "Meal" },
calories = safeInt(json, "calories", "kcal", "energy"),
proteinGrams = safeInt(json, "proteinGrams", "protein", "protein_g", "proteins"),
carbsGrams = safeInt(json, "carbsGrams", "carbs", "carbohydrates", "carbohydrateGrams", "carbs_g", "carbohydrate"),
fatGrams = safeInt(json, "fatGrams", "fat", "totalFat", "fat_g", "fats"),
fruitServings = safeDouble(json, "fruitServings", "fruit", "fruitsServings", "fruits"),
vegetableServings = safeDouble(json, "vegetableServings", "vegetables", "vegetablesServings", "veggies"),
foodGroups = stringifyGroups(json.opt("foodGroups")),
notes = json.optString("notes", ""),
raw = content
)
}
private fun safeInt(json: JSONObject, vararg keys: String): Int {
for (key in keys) {
val raw = json.opt(key) ?: continue
val n = when (raw) {
is Number -> raw.toInt()
is String -> raw.toDoubleOrNull()?.toInt() ?: continue
else -> continue
}
if (n > 0) return n
}
return 0
}
private fun safeDouble(json: JSONObject, vararg keys: String): Double {
for (key in keys) {
val raw = json.opt(key) ?: continue
val n = when (raw) {
is Number -> raw.toDouble()
is String -> raw.toDoubleOrNull() ?: continue
else -> continue
}
if (n > 0.0) return n
}
return 0.0
}
private fun stringifyGroups(value: Any?): String = when (value) {
null, JSONObject.NULL -> ""
is JSONArray -> (0 until value.length()).joinToString(", ") { value.optString(it) }

View file

@ -1,5 +1,6 @@
package com.danvics.calorieai.ui
import android.graphics.Bitmap
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -29,9 +30,11 @@ fun CalorieAiApp(
busy: Boolean,
editing: MealEntry?,
selectedImageName: String,
selectedImageBitmap: Bitmap?,
planStatus: String,
planBusy: Boolean,
syncing: Boolean,
mealSaveCount: Int,
onSync: () -> Unit,
onPickImage: () -> Unit,
onTakePhoto: () -> Unit,
@ -51,6 +54,11 @@ fun CalorieAiApp(
) {
CalorieTheme {
var screen by remember { mutableStateOf(Screen.Dashboard) }
LaunchedEffect(mealSaveCount) {
if (mealSaveCount > 0) screen = Screen.Dashboard
}
Scaffold(
topBar = {
TopAppBar(
@ -88,7 +96,18 @@ fun CalorieAiApp(
Box(Modifier.padding(padding).fillMaxSize()) {
when (screen) {
Screen.Dashboard -> DashboardScreen(appState.entries, appState.settings, onLog = { screen = Screen.Log })
Screen.Log -> LogMealScreen(editing, selectedImageName, status, busy, onPickImage, onTakePhoto, onAnalyze, onSaveManualEdit, onCancelEdit)
Screen.Log -> LogMealScreen(
editing = editing,
selectedImageName = selectedImageName,
selectedImageBitmap = selectedImageBitmap,
status = status,
busy = busy,
onPickImage = onPickImage,
onTakePhoto = onTakePhoto,
onAnalyze = onAnalyze,
onSaveManualEdit = onSaveManualEdit,
onCancelEdit = onCancelEdit
)
Screen.Diary -> DiaryScreen(
entries = appState.entries,
trash = appState.trash,

View file

@ -1,20 +1,31 @@
package com.danvics.calorieai.ui
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.Schedule
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.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.MealEntry
import com.danvics.calorieai.data.NutritionEstimate
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Calendar
private val MEAL_TYPES = listOf("Breakfast", "Lunch", "Dinner", "Snack", "Other")
@ -23,6 +34,7 @@ private val MEAL_TYPES = listOf("Breakfast", "Lunch", "Dinner", "Snack", "Other"
fun LogMealScreen(
editing: MealEntry?,
selectedImageName: String,
selectedImageBitmap: Bitmap?,
status: String,
busy: Boolean,
onPickImage: () -> Unit,
@ -31,6 +43,8 @@ fun LogMealScreen(
onSaveManualEdit: (MealEntry) -> Unit,
onCancelEdit: () -> Unit
) = Page {
val context = LocalContext.current
var date by remember(editing?.id) { mutableStateOf(editing?.date ?: LocalDate.now().toString()) }
var time by remember(editing?.id) { mutableStateOf(editing?.time ?: LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"))) }
var mealType by remember(editing?.id) { mutableStateOf(editing?.mealType ?: "Breakfast") }
@ -38,6 +52,41 @@ fun LogMealScreen(
var measure by remember(editing?.id) { mutableStateOf(editing?.measure ?: "") }
var estimate by remember(editing?.id) { mutableStateOf(editing?.estimate ?: NutritionEstimate()) }
var showImageSheet by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
if (showDatePicker) {
val cal = Calendar.getInstance()
runCatching {
val d = LocalDate.parse(date)
cal.set(d.year, d.monthValue - 1, d.dayOfMonth)
}
DisposableEffect(Unit) {
val dialog = DatePickerDialog(
context,
{ _, year, month, day -> date = "%04d-%02d-%02d".format(year, month + 1, day) },
cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)
)
dialog.setOnDismissListener { showDatePicker = false }
dialog.show()
onDispose { if (dialog.isShowing) dialog.dismiss() }
}
}
if (showTimePicker) {
val h = time.split(":").getOrNull(0)?.toIntOrNull() ?: LocalTime.now().hour
val m = time.split(":").getOrNull(1)?.toIntOrNull() ?: LocalTime.now().minute
DisposableEffect(Unit) {
val dialog = TimePickerDialog(
context,
{ _, hour, minute -> time = "%02d:%02d".format(hour, minute) },
h, m, true
)
dialog.setOnDismissListener { showTimePicker = false }
dialog.show()
onDispose { if (dialog.isShowing) dialog.dismiss() }
}
}
if (showImageSheet) {
ModalBottomSheet(onDismissRequest = { showImageSheet = false }) {
@ -51,18 +100,12 @@ fun LogMealScreen(
ListItem(
headlineContent = { Text("Take photo") },
leadingContent = { Icon(Icons.Default.CameraAlt, contentDescription = null) },
modifier = Modifier.clickable {
showImageSheet = false
onTakePhoto()
}
modifier = Modifier.clickable { showImageSheet = false; onTakePhoto() }
)
ListItem(
headlineContent = { Text("Choose from gallery") },
leadingContent = { Icon(Icons.Default.PhotoLibrary, contentDescription = null) },
modifier = Modifier.clickable {
showImageSheet = false
onPickImage()
}
modifier = Modifier.clickable { showImageSheet = false; onPickImage() }
)
}
}
@ -70,12 +113,42 @@ fun LogMealScreen(
SectionCard("Meal details") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Date", date, { date = it }, modifier = Modifier.weight(1f), placeholder = "YYYY-MM-DD")
Field("Time", time, { time = it }, modifier = Modifier.weight(1f), placeholder = "HH:MM")
Box(Modifier.weight(1f)) {
OutlinedTextField(
value = date,
onValueChange = {},
readOnly = true,
label = { Text("Date") },
trailingIcon = { Icon(Icons.Default.CalendarToday, contentDescription = null, modifier = Modifier.size(18.dp)) },
modifier = Modifier.fillMaxWidth()
)
Box(Modifier.matchParentSize().clickable { showDatePicker = true })
}
Box(Modifier.weight(1f)) {
OutlinedTextField(
value = time,
onValueChange = {},
readOnly = true,
label = { Text("Time") },
trailingIcon = { Icon(Icons.Default.Schedule, contentDescription = null, modifier = Modifier.size(18.dp)) },
modifier = Modifier.fillMaxWidth()
)
Box(Modifier.matchParentSize().clickable { showTimePicker = true })
}
}
DropdownField("Meal type", MEAL_TYPES, mealType, { mealType = it })
Field("Description", description, { description = it }, singleLine = false, placeholder = "e.g. grilled salmon, rice, broccoli")
Field("Portion or measure", measure, { measure = it }, placeholder = "e.g. one plate, 450g")
if (selectedImageBitmap != null) {
Image(
bitmap = selectedImageBitmap.asImageBitmap(),
contentDescription = "Meal photo preview",
modifier = Modifier.fillMaxWidth().height(180.dp).clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop
)
}
OutlinedButton(
onClick = { showImageSheet = true },
modifier = Modifier.fillMaxWidth()
@ -83,31 +156,26 @@ fun LogMealScreen(
Icon(
if (selectedImageName.isNotBlank()) Icons.Default.CameraAlt else Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp).padding(end = 0.dp)
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(if (selectedImageName.isNotBlank()) "Change photo" else "Add photo")
}
if (selectedImageName.isNotBlank()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
selectedImageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
}
}
Button(
enabled = !busy && (description.isNotBlank() || selectedImageName.isNotBlank()),
onClick = { onAnalyze(buildDraft(editing, date, time, mealType, description, measure, estimate)) },
modifier = Modifier.fillMaxWidth()
) { Text(if (busy) "Analyzing..." else if (editing == null) "Analyze and save" else "Analyze again") }
if (status.isNotBlank()) {
Text(
status,
style = MaterialTheme.typography.bodySmall,
color = if (status.startsWith("Analysis failed") || status.startsWith("No AI")) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
color = if (status.startsWith("Analysis failed") || status.startsWith("No AI") || status.startsWith("Save failed"))
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.primary
)
}
}
@ -140,5 +208,15 @@ fun LogMealScreen(
}
}
private fun buildDraft(editing: MealEntry?, date: String, time: String, mealType: String, description: String, measure: String, estimate: NutritionEstimate) =
MealEntry(editing?.id.orEmpty(), date, time, mealType, description, measure, editing?.imageIncluded ?: false, editing?.imageName.orEmpty(), editing?.visionEstimate.orEmpty(), estimate)
private fun buildDraft(
editing: MealEntry?,
date: String,
time: String,
mealType: String,
description: String,
measure: String,
estimate: NutritionEstimate
) = MealEntry(
editing?.id.orEmpty(), date, time, mealType, description, measure,
editing?.imageIncluded ?: false, editing?.imageName.orEmpty(), editing?.visionEstimate.orEmpty(), estimate
)

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="camera_photos" path="." />
</paths>

View file

@ -6,9 +6,16 @@ import org.junit.Test
class NutritionParserTest {
private val parser = NutritionParser()
@Test fun parsesCleanJson() {
val estimate = parser.parse("{\"mealName\":\"Chicken salad\",\"calories\":450,\"proteinGrams\":38,\"carbsGrams\":12,\"fatGrams\":22,\"fruitServings\":0,\"vegetableServings\":2,\"foodGroups\":\"protein, vegetables\",\"notes\":\"\"}")
assertEquals("Chicken salad", estimate.mealName)
assertEquals(450, estimate.calories)
assertEquals(38, estimate.proteinGrams)
assertEquals(2.0, estimate.vegetableServings, 0.001)
}
@Test fun parsesJsonInsideMarkdownFence() {
val estimate = parser.parse("```json\n{\"mealName\":\"Rice bowl\",\"calories\":640,\"proteinGrams\":42,\"carbsGrams\":70,\"fatGrams\":18,\"fruitServings\":0.5,\"vegetableServings\":1.5,\"foodGroups\":[\"grain\",\"vegetable\"]}\n```")
assertEquals("Rice bowl", estimate.mealName)
assertEquals(640, estimate.calories)
assertEquals(42, estimate.proteinGrams)
@ -16,11 +23,32 @@ class NutritionParserTest {
assertEquals(1.5, estimate.vegetableServings, 0.001)
}
@Test fun parsesShortKeyAliases() {
// Models often return "protein" instead of "proteinGrams" etc.
val estimate = parser.parse("{\"mealName\":\"Pasta\",\"calories\":580,\"protein\":28,\"carbs\":80,\"fat\":14}")
assertEquals("Pasta", estimate.mealName)
assertEquals(580, estimate.calories)
assertEquals(28, estimate.proteinGrams)
assertEquals(80, estimate.carbsGrams)
assertEquals(14, estimate.fatGrams)
}
@Test fun parsesJsonWithSurroundingText() {
val estimate = parser.parse("Here is the estimate:\n{\"mealName\":\"Toast\",\"calories\":200,\"proteinGrams\":6,\"carbsGrams\":35,\"fatGrams\":4,\"fruitServings\":0,\"vegetableServings\":0,\"foodGroups\":\"grains\",\"notes\":\"2 slices\"}\nThese are estimates only.")
assertEquals("Toast", estimate.mealName)
assertEquals(200, estimate.calories)
}
@Test fun clampsNegativeNumbers() {
val estimate = parser.parse("{\"calories\":-10,\"proteinGrams\":-3,\"fruitServings\":-1}")
assertEquals(0, estimate.calories)
assertEquals(0, estimate.proteinGrams)
assertEquals(0.0, estimate.fruitServings, 0.001)
}
@Test fun handlesStringNumbers() {
val estimate = parser.parse("{\"mealName\":\"Oatmeal\",\"calories\":\"320\",\"protein\":\"12\",\"carbs\":\"55\",\"fat\":\"8\"}")
assertEquals(320, estimate.calories)
assertEquals(12, estimate.proteinGrams)
}
}

View file

@ -528,32 +528,43 @@
model: settings.taskModel,
temperature: 0.1,
messages: [
{ role: 'system', content: 'Return strict JSON only. Do not wrap the response in markdown.' },
{ role: 'user', content: `Estimate nutrition for one meal. Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. Use integer grams and calories. fruitServings and vegetableServings can be decimal numbers. foodGroups should be a short comma-separated string.
Description: ${description}
Portion: ${measure}
Image estimate: ${imageEstimate}` },
{ role: 'system', content: 'You are a nutrition estimator. Respond with ONLY a JSON object no markdown, no explanation, no extra text.\nRequired keys: mealName (string), calories (integer), proteinGrams (integer), carbsGrams (integer), fatGrams (integer), fruitServings (decimal), vegetableServings (decimal), foodGroups (string), notes (string).\nExample: {"mealName":"Grilled chicken and rice","calories":520,"proteinGrams":45,"carbsGrams":48,"fatGrams":10,"fruitServings":0,"vegetableServings":1.5,"foodGroups":"protein, grains","notes":"standard portion"}' },
{ role: 'user', content: `Estimate the nutrition for this meal:\nDescription: ${description}\nPortion: ${measure}\nAdditional context: ${imageEstimate}` },
],
});
}
function parseEstimate(content) {
let cleaned = content.trim().replace(/^```json/i, '').replace(/^```/, '').replace(/```$/, '').trim();
let cleaned = content.trim();
// Strip markdown code fences
const fenceEnd = cleaned.indexOf('\n');
if (cleaned.startsWith('```') && fenceEnd >= 0) cleaned = cleaned.slice(fenceEnd + 1);
if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3);
cleaned = cleaned.trim();
const start = cleaned.indexOf('{');
const end = cleaned.lastIndexOf('}');
if (start >= 0 && end > start) cleaned = cleaned.slice(start, end + 1);
const parsed = JSON.parse(cleaned);
const p = JSON.parse(cleaned);
function safeInt(...keys) {
for (const k of keys) { const v = parseInt(p[k], 10); if (v > 0) return v; }
return 0;
}
function safeFloat(...keys) {
for (const k of keys) { const v = parseFloat(p[k]); if (v > 0) return v; }
return 0;
}
return {
mealName: parsed.mealName || 'Meal',
calories: Math.max(0, Number.parseInt(parsed.calories || 0, 10)),
proteinGrams: Math.max(0, Number.parseInt(parsed.proteinGrams || 0, 10)),
carbsGrams: Math.max(0, Number.parseInt(parsed.carbsGrams || 0, 10)),
fatGrams: Math.max(0, Number.parseInt(parsed.fatGrams || 0, 10)),
fruitServings: Math.max(0, Number(parsed.fruitServings ?? parsed.fruitsServings ?? 0)),
vegetableServings: Math.max(0, Number(parsed.vegetableServings ?? parsed.vegetablesServings ?? 0)),
foodGroups: Array.isArray(parsed.foodGroups) ? parsed.foodGroups.join(', ') : String(parsed.foodGroups || ''),
notes: parsed.notes || '',
mealName: p.mealName || 'Meal',
calories: safeInt('calories', 'kcal', 'energy'),
proteinGrams: safeInt('proteinGrams', 'protein', 'protein_g'),
carbsGrams: safeInt('carbsGrams', 'carbs', 'carbohydrates', 'carbohydrateGrams'),
fatGrams: safeInt('fatGrams', 'fat', 'totalFat'),
fruitServings: safeFloat('fruitServings', 'fruit', 'fruitsServings'),
vegetableServings: safeFloat('vegetableServings', 'vegetables', 'vegetablesServings'),
foodGroups: Array.isArray(p.foodGroups) ? p.foodGroups.join(', ') : String(p.foodGroups || ''),
notes: p.notes || '',
};
}