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>
This commit is contained in:
Daniel 2026-05-20 15:18:26 +02:00
parent af35b70399
commit b93e479be4
5 changed files with 209 additions and 52 deletions

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",

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>