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:
parent
af35b70399
commit
b93e479be4
5 changed files with 209 additions and 52 deletions
|
|
@ -17,5 +17,14 @@
|
||||||
<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,5 +1,7 @@
|
||||||
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
|
||||||
|
|
@ -8,6 +10,7 @@ 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.*
|
||||||
|
|
@ -19,6 +22,7 @@ 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
|
||||||
|
|
@ -38,6 +42,11 @@ 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) }
|
||||||
|
|
@ -45,30 +54,48 @@ 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 payload = runCatching {
|
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
if (bytes != null) {
|
||||||
ImagePayload.fromBytes(
|
val bitmap = decodeSampledBitmap(bytes)
|
||||||
uri.lastPathSegment ?: "image",
|
val payload = ImagePayload.fromBytes(
|
||||||
context.contentResolver.getType(uri) ?: "image/jpeg",
|
uri.lastPathSegment ?: "image",
|
||||||
stream.readBytes()
|
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() }
|
fun handleUnauth() { connected = false; appState = AppState() }
|
||||||
|
|
@ -88,21 +115,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,13 +140,20 @@ 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(null) },
|
onTakePhoto = { camera.launch(cameraImageUri) },
|
||||||
onCancelEdit = { editing = null; selectedImage = null; status = "" },
|
onCancelEdit = {
|
||||||
|
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) }
|
||||||
|
|
@ -127,7 +161,10 @@ 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}" } }
|
||||||
|
|
@ -158,9 +195,11 @@ 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 = meal
|
editing = null
|
||||||
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 ->
|
||||||
|
|
@ -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 =
|
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",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -29,9 +30,11 @@ 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,
|
||||||
|
|
@ -51,6 +54,11 @@ 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(
|
||||||
|
|
@ -88,7 +96,18 @@ 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(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(
|
Screen.Diary -> DiaryScreen(
|
||||||
entries = appState.entries,
|
entries = appState.entries,
|
||||||
trash = appState.trash,
|
trash = appState.trash,
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,31 @@
|
||||||
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")
|
||||||
|
|
||||||
|
|
@ -23,6 +34,7 @@ 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,
|
||||||
|
|
@ -31,6 +43,8 @@ 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") }
|
||||||
|
|
@ -38,6 +52,41 @@ 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 }) {
|
||||||
|
|
@ -51,18 +100,12 @@ 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 {
|
modifier = Modifier.clickable { showImageSheet = false; onTakePhoto() }
|
||||||
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 {
|
modifier = Modifier.clickable { showImageSheet = false; onPickImage() }
|
||||||
showImageSheet = false
|
|
||||||
onPickImage()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,12 +113,42 @@ fun LogMealScreen(
|
||||||
|
|
||||||
SectionCard("Meal details") {
|
SectionCard("Meal details") {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Field("Date", date, { date = it }, modifier = Modifier.weight(1f), placeholder = "YYYY-MM-DD")
|
Box(Modifier.weight(1f)) {
|
||||||
Field("Time", time, { time = it }, modifier = Modifier.weight(1f), placeholder = "HH:MM")
|
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 })
|
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()
|
||||||
|
|
@ -83,31 +156,26 @@ 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).padding(end = 0.dp)
|
modifier = Modifier.size(18.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")) 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) =
|
private fun buildDraft(
|
||||||
MealEntry(editing?.id.orEmpty(), date, time, mealType, description, measure, editing?.imageIncluded ?: false, editing?.imageName.orEmpty(), editing?.visionEstimate.orEmpty(), estimate)
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
4
app/src/main/res/xml/file_paths.xml
Normal file
4
app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="camera_photos" path="." />
|
||||||
|
</paths>
|
||||||
Loading…
Reference in a new issue