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 @@ + + + +