diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fbad768..30a8d3d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -17,5 +17,14 @@
+
+
+
diff --git a/app/src/main/java/com/danvics/calorieai/MainActivity.kt b/app/src/main/java/com/danvics/calorieai/MainActivity.kt
index 6ab5819..a76f83e 100644
--- a/app/src/main/java/com/danvics/calorieai/MainActivity.kt
+++ b/app/src/main/java/com/danvics/calorieai/MainActivity.kt
@@ -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(null) }
var selectedImage by remember { mutableStateOf(null) }
+ var selectedImageBitmap by remember { mutableStateOf(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",
diff --git a/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt b/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt
index 33afe70..f079e9a 100644
--- a/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt
+++ b/app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt
@@ -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,
diff --git a/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt b/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt
index e5b8d2d..70e587b 100644
--- a/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt
+++ b/app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt
@@ -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
+)
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..01430e0
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+