Compare commits
No commits in common. "03d41781136aacca6edadc7167f67f2c0588e7a1" and "af35b70399641b07ff7c237197857841e076c0aa" have entirely different histories.
03d4178113
...
af35b70399
9 changed files with 86 additions and 315 deletions
|
|
@ -12,8 +12,8 @@ android {
|
||||||
applicationId 'com.danvics.calorieai'
|
applicationId 'com.danvics.calorieai'
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode 4
|
versionCode 3
|
||||||
versionName '1.3'
|
versionName '1.2'
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,5 @@
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package com.danvics.calorieai
|
package com.danvics.calorieai
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
|
@ -10,7 +8,6 @@ import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import com.danvics.calorieai.ai.ImagePayload
|
import com.danvics.calorieai.ai.ImagePayload
|
||||||
import com.danvics.calorieai.ai.NutritionParser
|
import com.danvics.calorieai.ai.NutritionParser
|
||||||
import com.danvics.calorieai.data.*
|
import com.danvics.calorieai.data.*
|
||||||
|
|
@ -22,7 +19,6 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
@ -42,11 +38,6 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val parser = remember { NutritionParser() }
|
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 connected by remember { mutableStateOf(repo.isConnected()) }
|
||||||
var connectError by remember { mutableStateOf("") }
|
var connectError by remember { mutableStateOf("") }
|
||||||
var connecting by remember { mutableStateOf(false) }
|
var connecting by remember { mutableStateOf(false) }
|
||||||
|
|
@ -54,48 +45,30 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
var appState by remember { mutableStateOf(AppState()) }
|
var appState by remember { mutableStateOf(AppState()) }
|
||||||
var editing by remember { mutableStateOf<MealEntry?>(null) }
|
var editing by remember { mutableStateOf<MealEntry?>(null) }
|
||||||
var selectedImage by remember { mutableStateOf<ImagePayload?>(null) }
|
var selectedImage by remember { mutableStateOf<ImagePayload?>(null) }
|
||||||
var selectedImageBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
|
||||||
var status by remember { mutableStateOf("") }
|
var status by remember { mutableStateOf("") }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
var planStatus by remember { mutableStateOf("") }
|
var planStatus by remember { mutableStateOf("") }
|
||||||
var planBusy by remember { mutableStateOf(false) }
|
var planBusy by remember { mutableStateOf(false) }
|
||||||
var syncing by remember { mutableStateOf(false) }
|
var syncing by remember { mutableStateOf(false) }
|
||||||
var mealSaveCount by remember { mutableStateOf(0) }
|
|
||||||
|
|
||||||
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
val payload = runCatching {
|
||||||
if (bytes != null) {
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
val bitmap = decodeSampledBitmap(bytes)
|
ImagePayload.fromBytes(
|
||||||
val payload = ImagePayload.fromBytes(
|
uri.lastPathSegment ?: "image",
|
||||||
uri.lastPathSegment ?: "image",
|
context.contentResolver.getType(uri) ?: "image/jpeg",
|
||||||
context.contentResolver.getType(uri) ?: "image/jpeg",
|
stream.readBytes()
|
||||||
bytes
|
)
|
||||||
)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
selectedImage = payload
|
|
||||||
selectedImageBitmap = bitmap
|
|
||||||
}
|
}
|
||||||
}
|
}.getOrNull()
|
||||||
|
withContext(Dispatchers.Main) { if (payload != null) selectedImage = payload }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
|
||||||
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
if (bitmap != null) selectedImage = ImagePayload.fromBitmap("camera photo", bitmap)
|
||||||
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() }
|
fun handleUnauth() { connected = false; appState = AppState() }
|
||||||
|
|
@ -115,21 +88,21 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
CalorieTheme {
|
CalorieTheme {
|
||||||
ConnectScreen(error = connectError, connecting = connecting) { url, user, pass ->
|
ConnectScreen(error = connectError, connecting = connecting) { url, user, pass ->
|
||||||
connecting = true
|
connecting = true
|
||||||
connectError = ""
|
connectError = ""
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
runCatching { repo.login(url, user, pass) }
|
runCatching { repo.login(url, user, pass) }
|
||||||
.onSuccess { cookie ->
|
.onSuccess { cookie ->
|
||||||
repo.saveConfig(url, cookie)
|
repo.saveConfig(url, cookie)
|
||||||
withContext(Dispatchers.Main) { connecting = false; connected = true }
|
withContext(Dispatchers.Main) { connecting = false; connected = true }
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
withContext(Dispatchers.Main) { connecting = false; connectError = e.message ?: "Connection failed" }
|
withContext(Dispatchers.Main) { connecting = false; connectError = e.message ?: "Connection failed" }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,20 +113,13 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
busy = busy,
|
busy = busy,
|
||||||
editing = editing,
|
editing = editing,
|
||||||
selectedImageName = selectedImage?.name.orEmpty(),
|
selectedImageName = selectedImage?.name.orEmpty(),
|
||||||
selectedImageBitmap = selectedImageBitmap,
|
|
||||||
planStatus = planStatus,
|
planStatus = planStatus,
|
||||||
planBusy = planBusy,
|
planBusy = planBusy,
|
||||||
syncing = syncing,
|
syncing = syncing,
|
||||||
mealSaveCount = mealSaveCount,
|
|
||||||
onSync = { syncState() },
|
onSync = { syncState() },
|
||||||
onPickImage = { imagePicker.launch("image/*") },
|
onPickImage = { imagePicker.launch("image/*") },
|
||||||
onTakePhoto = { camera.launch(cameraImageUri) },
|
onTakePhoto = { camera.launch(null) },
|
||||||
onCancelEdit = {
|
onCancelEdit = { editing = null; selectedImage = null; status = "" },
|
||||||
editing = null
|
|
||||||
selectedImage = null
|
|
||||||
selectedImageBitmap = null
|
|
||||||
status = ""
|
|
||||||
},
|
|
||||||
onSaveManualEdit = { updated ->
|
onSaveManualEdit = { updated ->
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
runCatching { repo.upsertEntry(updated) }
|
runCatching { repo.upsertEntry(updated) }
|
||||||
|
|
@ -161,10 +127,7 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
appState = appState.copy(entries = entries, trash = trash)
|
appState = appState.copy(entries = entries, trash = trash)
|
||||||
editing = null
|
editing = null
|
||||||
selectedImage = null
|
|
||||||
selectedImageBitmap = null
|
|
||||||
status = "Meal updated."
|
status = "Meal updated."
|
||||||
mealSaveCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() else status = "Save failed: ${e.message}" } }
|
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() else status = "Save failed: ${e.message}" } }
|
||||||
|
|
@ -195,11 +158,9 @@ private fun AppRoot(repo: ApiRepository) {
|
||||||
val (entries, trash) = pair
|
val (entries, trash) = pair
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
appState = appState.copy(entries = entries, trash = trash)
|
appState = appState.copy(entries = entries, trash = trash)
|
||||||
editing = null
|
editing = meal
|
||||||
selectedImage = null
|
selectedImage = null
|
||||||
selectedImageBitmap = null
|
|
||||||
status = "Saved. ${meal.estimate.mealName} · ${meal.estimate.calories} kcal"
|
status = "Saved. ${meal.estimate.mealName} · ${meal.estimate.calories} kcal"
|
||||||
mealSaveCount++
|
|
||||||
busy = false
|
busy = false
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
|
|
@ -328,14 +289,6 @@ 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 =
|
private fun buildVisionBody(model: String, description: String, measure: String, image: ImagePayload): JSONObject =
|
||||||
JSONObject().put("model", model).put("temperature", 0.15)
|
JSONObject().put("model", model).put("temperature", 0.15)
|
||||||
.put("messages", JSONArray().put(JSONObject().put("role", "user").put("content",
|
.put("messages", JSONArray().put(JSONObject().put("role", "user").put("content",
|
||||||
|
|
@ -347,13 +300,9 @@ private fun buildVisionBody(model: String, description: String, measure: String,
|
||||||
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String): JSONObject =
|
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String): JSONObject =
|
||||||
JSONObject().put("model", model).put("temperature", 0.1)
|
JSONObject().put("model", model).put("temperature", 0.1)
|
||||||
.put("messages", JSONArray()
|
.put("messages", JSONArray()
|
||||||
.put(JSONObject().put("role", "system").put("content",
|
.put(JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap in markdown."))
|
||||||
"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",
|
.put(JSONObject().put("role", "user").put("content",
|
||||||
"Estimate the nutrition for this meal:\nDescription: $description\nPortion: $measure\nAdditional context: $visionEstimate"
|
"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"
|
||||||
)))
|
)))
|
||||||
|
|
||||||
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =
|
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =
|
||||||
|
|
|
||||||
|
|
@ -6,58 +6,29 @@ import org.json.JSONObject
|
||||||
|
|
||||||
class NutritionParser {
|
class NutritionParser {
|
||||||
fun parse(content: String): NutritionEstimate {
|
fun parse(content: String): NutritionEstimate {
|
||||||
// Strip markdown code fences and extract the JSON object
|
|
||||||
var cleaned = content.trim()
|
var cleaned = content.trim()
|
||||||
val fenceEnd = cleaned.indexOf('\n')
|
.removePrefix("```json")
|
||||||
if (cleaned.startsWith("```") && fenceEnd >= 0) cleaned = cleaned.substring(fenceEnd + 1)
|
.removePrefix("```")
|
||||||
if (cleaned.endsWith("```")) cleaned = cleaned.dropLast(3)
|
.removeSuffix("```")
|
||||||
cleaned = cleaned.trim()
|
.trim()
|
||||||
|
|
||||||
val start = cleaned.indexOf('{')
|
val start = cleaned.indexOf('{')
|
||||||
val end = cleaned.lastIndexOf('}')
|
val end = cleaned.lastIndexOf('}')
|
||||||
if (start >= 0 && end > start) cleaned = cleaned.substring(start, end + 1)
|
if (start >= 0 && end > start) cleaned = cleaned.substring(start, end + 1)
|
||||||
|
|
||||||
val json = JSONObject(cleaned)
|
val json = JSONObject(cleaned)
|
||||||
return NutritionEstimate(
|
return NutritionEstimate(
|
||||||
mealName = json.optString("mealName", "").ifBlank { "Meal" },
|
mealName = json.optString("mealName", "Meal").ifBlank { "Meal" },
|
||||||
calories = safeInt(json, "calories", "kcal", "energy"),
|
calories = json.optInt("calories").coerceAtLeast(0),
|
||||||
proteinGrams = safeInt(json, "proteinGrams", "protein", "protein_g", "proteins"),
|
proteinGrams = json.optInt("proteinGrams").coerceAtLeast(0),
|
||||||
carbsGrams = safeInt(json, "carbsGrams", "carbs", "carbohydrates", "carbohydrateGrams", "carbs_g", "carbohydrate"),
|
carbsGrams = json.optInt("carbsGrams").coerceAtLeast(0),
|
||||||
fatGrams = safeInt(json, "fatGrams", "fat", "totalFat", "fat_g", "fats"),
|
fatGrams = json.optInt("fatGrams").coerceAtLeast(0),
|
||||||
fruitServings = safeDouble(json, "fruitServings", "fruit", "fruitsServings", "fruits"),
|
fruitServings = json.optDouble("fruitServings", json.optDouble("fruitsServings", 0.0)).coerceAtLeast(0.0),
|
||||||
vegetableServings = safeDouble(json, "vegetableServings", "vegetables", "vegetablesServings", "veggies"),
|
vegetableServings = json.optDouble("vegetableServings", json.optDouble("vegetablesServings", 0.0)).coerceAtLeast(0.0),
|
||||||
foodGroups = stringifyGroups(json.opt("foodGroups")),
|
foodGroups = stringifyGroups(json.opt("foodGroups")),
|
||||||
notes = json.optString("notes", ""),
|
notes = json.optString("notes", ""),
|
||||||
raw = content
|
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) {
|
private fun stringifyGroups(value: Any?): String = when (value) {
|
||||||
null, JSONObject.NULL -> ""
|
null, JSONObject.NULL -> ""
|
||||||
is JSONArray -> (0 until value.length()).joinToString(", ") { value.optString(it) }
|
is JSONArray -> (0 until value.length()).joinToString(", ") { value.optString(it) }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.danvics.calorieai.ui
|
package com.danvics.calorieai.ui
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
|
@ -30,11 +29,9 @@ fun CalorieAiApp(
|
||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
editing: MealEntry?,
|
editing: MealEntry?,
|
||||||
selectedImageName: String,
|
selectedImageName: String,
|
||||||
selectedImageBitmap: Bitmap?,
|
|
||||||
planStatus: String,
|
planStatus: String,
|
||||||
planBusy: Boolean,
|
planBusy: Boolean,
|
||||||
syncing: Boolean,
|
syncing: Boolean,
|
||||||
mealSaveCount: Int,
|
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onPickImage: () -> Unit,
|
onPickImage: () -> Unit,
|
||||||
onTakePhoto: () -> Unit,
|
onTakePhoto: () -> Unit,
|
||||||
|
|
@ -54,11 +51,6 @@ fun CalorieAiApp(
|
||||||
) {
|
) {
|
||||||
CalorieTheme {
|
CalorieTheme {
|
||||||
var screen by remember { mutableStateOf(Screen.Dashboard) }
|
var screen by remember { mutableStateOf(Screen.Dashboard) }
|
||||||
|
|
||||||
LaunchedEffect(mealSaveCount) {
|
|
||||||
if (mealSaveCount > 0) screen = Screen.Dashboard
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -96,18 +88,7 @@ fun CalorieAiApp(
|
||||||
Box(Modifier.padding(padding).fillMaxSize()) {
|
Box(Modifier.padding(padding).fillMaxSize()) {
|
||||||
when (screen) {
|
when (screen) {
|
||||||
Screen.Dashboard -> DashboardScreen(appState.entries, appState.settings, onLog = { screen = Screen.Log })
|
Screen.Dashboard -> DashboardScreen(appState.entries, appState.settings, onLog = { screen = Screen.Log })
|
||||||
Screen.Log -> LogMealScreen(
|
Screen.Log -> LogMealScreen(editing, selectedImageName, status, busy, onPickImage, onTakePhoto, onAnalyze, onSaveManualEdit, onCancelEdit)
|
||||||
editing = editing,
|
|
||||||
selectedImageName = selectedImageName,
|
|
||||||
selectedImageBitmap = selectedImageBitmap,
|
|
||||||
status = status,
|
|
||||||
busy = busy,
|
|
||||||
onPickImage = onPickImage,
|
|
||||||
onTakePhoto = onTakePhoto,
|
|
||||||
onAnalyze = onAnalyze,
|
|
||||||
onSaveManualEdit = onSaveManualEdit,
|
|
||||||
onCancelEdit = onCancelEdit
|
|
||||||
)
|
|
||||||
Screen.Diary -> DiaryScreen(
|
Screen.Diary -> DiaryScreen(
|
||||||
entries = appState.entries,
|
entries = appState.entries,
|
||||||
trash = appState.trash,
|
trash = appState.trash,
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,20 @@
|
||||||
package com.danvics.calorieai.ui
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
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.CameraAlt
|
||||||
import androidx.compose.material.icons.filled.PhotoLibrary
|
import androidx.compose.material.icons.filled.PhotoLibrary
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
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.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.danvics.calorieai.data.MealEntry
|
import com.danvics.calorieai.data.MealEntry
|
||||||
import com.danvics.calorieai.data.NutritionEstimate
|
import com.danvics.calorieai.data.NutritionEstimate
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Calendar
|
|
||||||
|
|
||||||
private val MEAL_TYPES = listOf("Breakfast", "Lunch", "Dinner", "Snack", "Other")
|
private val MEAL_TYPES = listOf("Breakfast", "Lunch", "Dinner", "Snack", "Other")
|
||||||
|
|
||||||
|
|
@ -34,7 +23,6 @@ private val MEAL_TYPES = listOf("Breakfast", "Lunch", "Dinner", "Snack", "Other"
|
||||||
fun LogMealScreen(
|
fun LogMealScreen(
|
||||||
editing: MealEntry?,
|
editing: MealEntry?,
|
||||||
selectedImageName: String,
|
selectedImageName: String,
|
||||||
selectedImageBitmap: Bitmap?,
|
|
||||||
status: String,
|
status: String,
|
||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
onPickImage: () -> Unit,
|
onPickImage: () -> Unit,
|
||||||
|
|
@ -43,8 +31,6 @@ fun LogMealScreen(
|
||||||
onSaveManualEdit: (MealEntry) -> Unit,
|
onSaveManualEdit: (MealEntry) -> Unit,
|
||||||
onCancelEdit: () -> Unit
|
onCancelEdit: () -> Unit
|
||||||
) = Page {
|
) = Page {
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
var date by remember(editing?.id) { mutableStateOf(editing?.date ?: LocalDate.now().toString()) }
|
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 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") }
|
var mealType by remember(editing?.id) { mutableStateOf(editing?.mealType ?: "Breakfast") }
|
||||||
|
|
@ -52,41 +38,6 @@ fun LogMealScreen(
|
||||||
var measure by remember(editing?.id) { mutableStateOf(editing?.measure ?: "") }
|
var measure by remember(editing?.id) { mutableStateOf(editing?.measure ?: "") }
|
||||||
var estimate by remember(editing?.id) { mutableStateOf(editing?.estimate ?: NutritionEstimate()) }
|
var estimate by remember(editing?.id) { mutableStateOf(editing?.estimate ?: NutritionEstimate()) }
|
||||||
var showImageSheet by remember { mutableStateOf(false) }
|
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) {
|
if (showImageSheet) {
|
||||||
ModalBottomSheet(onDismissRequest = { showImageSheet = false }) {
|
ModalBottomSheet(onDismissRequest = { showImageSheet = false }) {
|
||||||
|
|
@ -100,12 +51,18 @@ fun LogMealScreen(
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Take photo") },
|
headlineContent = { Text("Take photo") },
|
||||||
leadingContent = { Icon(Icons.Default.CameraAlt, contentDescription = null) },
|
leadingContent = { Icon(Icons.Default.CameraAlt, contentDescription = null) },
|
||||||
modifier = Modifier.clickable { showImageSheet = false; onTakePhoto() }
|
modifier = Modifier.clickable {
|
||||||
|
showImageSheet = false
|
||||||
|
onTakePhoto()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Choose from gallery") },
|
headlineContent = { Text("Choose from gallery") },
|
||||||
leadingContent = { Icon(Icons.Default.PhotoLibrary, contentDescription = null) },
|
leadingContent = { Icon(Icons.Default.PhotoLibrary, contentDescription = null) },
|
||||||
modifier = Modifier.clickable { showImageSheet = false; onPickImage() }
|
modifier = Modifier.clickable {
|
||||||
|
showImageSheet = false
|
||||||
|
onPickImage()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -113,42 +70,12 @@ fun LogMealScreen(
|
||||||
|
|
||||||
SectionCard("Meal details") {
|
SectionCard("Meal details") {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Box(Modifier.weight(1f)) {
|
Field("Date", date, { date = it }, modifier = Modifier.weight(1f), placeholder = "YYYY-MM-DD")
|
||||||
OutlinedTextField(
|
Field("Time", time, { time = it }, modifier = Modifier.weight(1f), placeholder = "HH:MM")
|
||||||
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 })
|
DropdownField("Meal type", MEAL_TYPES, mealType, { mealType = it })
|
||||||
Field("Description", description, { description = it }, singleLine = false, placeholder = "e.g. grilled salmon, rice, broccoli")
|
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")
|
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(
|
OutlinedButton(
|
||||||
onClick = { showImageSheet = true },
|
onClick = { showImageSheet = true },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
|
@ -156,26 +83,31 @@ fun LogMealScreen(
|
||||||
Icon(
|
Icon(
|
||||||
if (selectedImageName.isNotBlank()) Icons.Default.CameraAlt else Icons.Default.PhotoLibrary,
|
if (selectedImageName.isNotBlank()) Icons.Default.CameraAlt else Icons.Default.PhotoLibrary,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(18.dp).padding(end = 0.dp)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(if (selectedImageName.isNotBlank()) "Change photo" else "Add photo")
|
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(
|
Button(
|
||||||
enabled = !busy && (description.isNotBlank() || selectedImageName.isNotBlank()),
|
enabled = !busy && (description.isNotBlank() || selectedImageName.isNotBlank()),
|
||||||
onClick = { onAnalyze(buildDraft(editing, date, time, mealType, description, measure, estimate)) },
|
onClick = { onAnalyze(buildDraft(editing, date, time, mealType, description, measure, estimate)) },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) { Text(if (busy) "Analyzing..." else if (editing == null) "Analyze and save" else "Analyze again") }
|
) { Text(if (busy) "Analyzing..." else if (editing == null) "Analyze and save" else "Analyze again") }
|
||||||
|
|
||||||
if (status.isNotBlank()) {
|
if (status.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
status,
|
status,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = if (status.startsWith("Analysis failed") || status.startsWith("No AI") || status.startsWith("Save failed"))
|
color = if (status.startsWith("Analysis failed") || status.startsWith("No AI")) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||||
MaterialTheme.colorScheme.error
|
|
||||||
else
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -208,15 +140,5 @@ fun LogMealScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildDraft(
|
private fun buildDraft(editing: MealEntry?, date: String, time: String, mealType: String, description: String, measure: String, estimate: NutritionEstimate) =
|
||||||
editing: MealEntry?,
|
MealEntry(editing?.id.orEmpty(), date, time, mealType, description, measure, editing?.imageIncluded ?: false, editing?.imageName.orEmpty(), editing?.visionEstimate.orEmpty(), estimate)
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths>
|
|
||||||
<cache-path name="camera_photos" path="." />
|
|
||||||
</paths>
|
|
||||||
|
|
@ -6,16 +6,9 @@ import org.junit.Test
|
||||||
class NutritionParserTest {
|
class NutritionParserTest {
|
||||||
private val parser = NutritionParser()
|
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() {
|
@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```")
|
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("Rice bowl", estimate.mealName)
|
||||||
assertEquals(640, estimate.calories)
|
assertEquals(640, estimate.calories)
|
||||||
assertEquals(42, estimate.proteinGrams)
|
assertEquals(42, estimate.proteinGrams)
|
||||||
|
|
@ -23,32 +16,11 @@ class NutritionParserTest {
|
||||||
assertEquals(1.5, estimate.vegetableServings, 0.001)
|
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() {
|
@Test fun clampsNegativeNumbers() {
|
||||||
val estimate = parser.parse("{\"calories\":-10,\"proteinGrams\":-3,\"fruitServings\":-1}")
|
val estimate = parser.parse("{\"calories\":-10,\"proteinGrams\":-3,\"fruitServings\":-1}")
|
||||||
|
|
||||||
assertEquals(0, estimate.calories)
|
assertEquals(0, estimate.calories)
|
||||||
assertEquals(0, estimate.proteinGrams)
|
assertEquals(0, estimate.proteinGrams)
|
||||||
assertEquals(0.0, estimate.fruitServings, 0.001)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -528,43 +528,32 @@
|
||||||
model: settings.taskModel,
|
model: settings.taskModel,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
messages: [
|
messages: [
|
||||||
{ 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: 'system', content: 'Return strict JSON only. Do not wrap the response in markdown.' },
|
||||||
{ role: 'user', content: `Estimate the nutrition for this meal:\nDescription: ${description}\nPortion: ${measure}\nAdditional context: ${imageEstimate}` },
|
{ 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}` },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEstimate(content) {
|
function parseEstimate(content) {
|
||||||
let cleaned = content.trim();
|
let cleaned = content.trim().replace(/^```json/i, '').replace(/^```/, '').replace(/```$/, '').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 start = cleaned.indexOf('{');
|
||||||
const end = cleaned.lastIndexOf('}');
|
const end = cleaned.lastIndexOf('}');
|
||||||
if (start >= 0 && end > start) cleaned = cleaned.slice(start, end + 1);
|
if (start >= 0 && end > start) cleaned = cleaned.slice(start, end + 1);
|
||||||
const p = JSON.parse(cleaned);
|
const parsed = 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 {
|
return {
|
||||||
mealName: p.mealName || 'Meal',
|
mealName: parsed.mealName || 'Meal',
|
||||||
calories: safeInt('calories', 'kcal', 'energy'),
|
calories: Math.max(0, Number.parseInt(parsed.calories || 0, 10)),
|
||||||
proteinGrams: safeInt('proteinGrams', 'protein', 'protein_g'),
|
proteinGrams: Math.max(0, Number.parseInt(parsed.proteinGrams || 0, 10)),
|
||||||
carbsGrams: safeInt('carbsGrams', 'carbs', 'carbohydrates', 'carbohydrateGrams'),
|
carbsGrams: Math.max(0, Number.parseInt(parsed.carbsGrams || 0, 10)),
|
||||||
fatGrams: safeInt('fatGrams', 'fat', 'totalFat'),
|
fatGrams: Math.max(0, Number.parseInt(parsed.fatGrams || 0, 10)),
|
||||||
fruitServings: safeFloat('fruitServings', 'fruit', 'fruitsServings'),
|
fruitServings: Math.max(0, Number(parsed.fruitServings ?? parsed.fruitsServings ?? 0)),
|
||||||
vegetableServings: safeFloat('vegetableServings', 'vegetables', 'vegetablesServings'),
|
vegetableServings: Math.max(0, Number(parsed.vegetableServings ?? parsed.vegetablesServings ?? 0)),
|
||||||
foodGroups: Array.isArray(p.foodGroups) ? p.foodGroups.join(', ') : String(p.foodGroups || ''),
|
foodGroups: Array.isArray(parsed.foodGroups) ? parsed.foodGroups.join(', ') : String(parsed.foodGroups || ''),
|
||||||
notes: p.notes || '',
|
notes: parsed.notes || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue