Refactor Android app to production grade with improved UI and sync
Some checks failed
Android CI / build (push) Successful in 1m16s
Web CI / test (push) Failing after 1m46s
Android Release / release-apk (push) Successful in 1m16s

Android:
- Add adaptive app icon (blue background, white flame foreground)
- Add android:icon and roundIcon references to manifest
- Add windowSoftInputMode=adjustResize for keyboard handling
- Dark mode support via isSystemInDarkTheme() in Theme.kt
- Bump versionCode 1→2, versionName 1.0→1.1, named APK output
- Merge Trash into Diary as a tab (was a 6th bottom-nav item)
- Add sync refresh button in top bar with loading indicator
- Add CalorieGoalBar progress indicator on dashboard
- Add calorieTarget to ServerSettings, Settings screen, and API
- Add clearTrash() to ApiRepository (server route was unused)
- Fix onCancelEdit to also clear selected image state
- AiClient.kt: remove old pre-server AiClient class (replaced by ApiRepository)
- LogMealScreen: side-by-side field rows, DropdownField for meal type

Web server:
- Add calorieTarget to settings defaults and allowed field list
- Remove dead handleRequest() function (never called, Express routes it)

CI:
- Fix APK paths in android.yml and release.yml for renamed output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-05-20 05:32:01 +02:00
parent f42e8c8784
commit 9f660d50bc
29 changed files with 1654 additions and 549 deletions

View file

@ -36,4 +36,4 @@ jobs:
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: calorie-ai-debug-apk name: calorie-ai-debug-apk
path: app/build/outputs/apk/debug/app-debug.apk path: app/build/outputs/apk/debug/*.apk

View file

@ -32,7 +32,7 @@ jobs:
run: | run: |
gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug
mkdir -p dist mkdir -p dist
cp app/build/outputs/apk/debug/app-debug.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk" cp app/build/outputs/apk/debug/*.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk"
- name: Publish Forgejo release asset - name: Publish Forgejo release asset
env: env:

View file

@ -12,14 +12,20 @@ android {
applicationId 'com.danvics.calorieai' applicationId 'com.danvics.calorieai'
minSdk 26 minSdk 26
targetSdk 35 targetSdk 35
versionCode 1 versionCode 2
versionName '1.0' versionName '1.1'
} }
buildFeatures { buildFeatures {
compose true compose true
} }
applicationVariants.configureEach { variant ->
variant.outputs.configureEach {
outputFileName = "calorie-ai-${variant.versionName}.apk"
}
}
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
@ -38,6 +44,7 @@ dependencies {
implementation platform('androidx.compose:compose-bom:2025.05.01') implementation platform('androidx.compose:compose-bom:2025.05.01')
implementation 'androidx.activity:activity-compose:1.10.1' implementation 'androidx.activity:activity-compose:1.10.1'
implementation 'androidx.compose.material3:material3' implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material:material-icons-core'
implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'

View file

@ -5,10 +5,13 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:label="Calorie AI" android:label="Calorie AI"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View file

@ -8,13 +8,17 @@ 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 com.danvics.calorieai.ai.AiClient
import com.danvics.calorieai.ai.ImagePayload import com.danvics.calorieai.ai.ImagePayload
import com.danvics.calorieai.ai.NutritionParser
import com.danvics.calorieai.data.* import com.danvics.calorieai.data.*
import com.danvics.calorieai.ui.CalorieAiApp import com.danvics.calorieai.ui.CalorieAiApp
import com.danvics.calorieai.ui.CalorieTheme
import com.danvics.calorieai.ui.ConnectScreen
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
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
@ -23,91 +27,285 @@ import java.util.UUID
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val repo = AppRepository(getSharedPreferences("calorie_ai", MODE_PRIVATE)) val repo = ApiRepository(getSharedPreferences("calorie_ai", MODE_PRIVATE))
val ai = AiClient() setContent { AppRoot(repo) }
setContent { AppRoot(repo, ai) }
} }
} }
@Composable @Composable
private fun AppRoot(repo: AppRepository, ai: AiClient) { private fun AppRoot(repo: ApiRepository) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var entries by remember { mutableStateOf(repo.entries()) } val parser = remember { NutritionParser() }
var trash by remember { mutableStateOf(repo.trash()) }
var settings by remember { mutableStateOf(repo.settings()) } var connected by remember { mutableStateOf(repo.isConnected()) }
var connectError by remember { mutableStateOf("") }
var connecting by remember { mutableStateOf(false) }
var appState by remember { mutableStateOf(AppState()) }
var editing by remember { mutableStateOf<MealEntry?>(null) }
var selectedImage by remember { mutableStateOf<ImagePayload?>(null) } var selectedImage by remember { mutableStateOf<ImagePayload?>(null) }
var status by remember { mutableStateOf("") } var status by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<MealEntry?>(null) } var planStatus by remember { mutableStateOf("") }
var planBusy by remember { mutableStateOf(false) }
var syncing by remember { mutableStateOf(false) }
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) selectedImage = context.contentResolver.openInputStream(uri)?.use { input -> if (uri != null) selectedImage = context.contentResolver.openInputStream(uri)?.use { stream ->
val bytes = input.readBytes() ImagePayload.fromBytes(uri.lastPathSegment ?: "image", context.contentResolver.getType(uri) ?: "image/jpeg", stream.readBytes())
ImagePayload.fromBytes(uri.lastPathSegment ?: "meal image", context.contentResolver.getType(uri) ?: "image/jpeg", bytes)
} }
} }
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap -> val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
if (bitmap != null) selectedImage = ImagePayload.fromBitmap("camera meal photo", bitmap) if (bitmap != null) selectedImage = ImagePayload.fromBitmap("camera photo", bitmap)
} }
fun persistEntries(next: List<MealEntry>) { entries = next; repo.saveEntries(next) } fun handleUnauth() { connected = false; appState = AppState() }
fun persistTrash(next: List<MealEntry>) { trash = next; repo.saveTrash(next) }
fun persistSettings(next: AppSettings) { settings = next; repo.saveSettings(next) } fun syncState() {
syncing = true
scope.launch(Dispatchers.IO) {
runCatching { repo.getAppState() }
.onSuccess { state -> withContext(Dispatchers.Main) { appState = state; syncing = false } }
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth(); syncing = false } }
}
}
LaunchedEffect(connected) {
if (connected) syncState()
}
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" }
}
}
}
}
return
}
CalorieAiApp( CalorieAiApp(
entries = entries, appState = appState,
trash = trash, serverUrl = repo.localConfig().serverUrl,
settings = settings,
status = status, status = status,
busy = busy, busy = busy,
editing = editing, editing = editing,
selectedImageName = selectedImage?.name.orEmpty(), selectedImageName = selectedImage?.name.orEmpty(),
planStatus = planStatus,
planBusy = planBusy,
syncing = syncing,
onSync = { syncState() },
onPickImage = { imagePicker.launch("image/*") }, onPickImage = { imagePicker.launch("image/*") },
onTakePhoto = { camera.launch(null) }, onTakePhoto = { camera.launch(null) },
onCancelEdit = { editing = null; status = "" }, onCancelEdit = { editing = null; selectedImage = null; status = "" },
onSaveManualEdit = { updated -> onSaveManualEdit = { updated ->
persistEntries(entries.map { if (it.id == updated.id) updated else it }) scope.launch(Dispatchers.IO) {
editing = null runCatching { repo.upsertEntry(updated) }
status = "Meal updated." .onSuccess { (entries, trash) ->
withContext(Dispatchers.Main) {
appState = appState.copy(entries = entries, trash = trash)
editing = null
status = "Meal updated."
}
}
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() else status = "Save failed: ${e.message}" } }
}
}, },
onAnalyze = { draft -> onAnalyze = { draft ->
if (!ai.isConfigured(settings)) { status = "Set API base URL and nutrition model in Settings first."; return@CalorieAiApp } if (appState.settings.taskModel.isBlank()) { status = "No AI model configured on server."; return@CalorieAiApp }
busy = true busy = true
status = "Analyzing meal..." status = "Analyzing meal..."
scope.launch { scope.launch(Dispatchers.IO) {
runCatching { runCatching {
withContext(Dispatchers.IO) { ai.estimateMeal(settings, draft.description, draft.measure, selectedImage) } val visionEstimate = if (selectedImage != null && appState.settings.visionModel.isNotBlank()) {
}.onSuccess { estimate -> repo.chat(buildVisionBody(appState.settings.visionModel, draft.description, draft.measure, selectedImage!!))
} else ""
val nutritionJson = repo.chat(buildNutritionBody(appState.settings.taskModel, draft.description, draft.measure, visionEstimate))
val estimate = parser.parse(nutritionJson)
val meal = draft.copy( val meal = draft.copy(
id = editing?.id ?: UUID.randomUUID().toString(), id = editing?.id ?: UUID.randomUUID().toString(),
date = draft.date.ifBlank { LocalDate.now().toString() }, date = draft.date.ifBlank { LocalDate.now().toString() },
time = draft.time.ifBlank { LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) }, time = draft.time.ifBlank { LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) },
imageIncluded = selectedImage != null, imageIncluded = selectedImage != null,
imageName = selectedImage?.name.orEmpty(), imageName = selectedImage?.name.orEmpty(),
visionEstimate = visionEstimate,
estimate = estimate estimate = estimate
) )
persistEntries(if (editing == null) entries + meal else entries.map { if (it.id == meal.id) meal else it }) repo.upsertEntry(meal) to meal
editing = meal }.onSuccess { (pair, meal) ->
status = "Saved. You can edit values or analyze again." val (entries, trash) = pair
}.onFailure { status = "Analysis failed: ${it.message}" } withContext(Dispatchers.Main) {
busy = false appState = appState.copy(entries = entries, trash = trash)
editing = meal
selectedImage = null
status = "Saved. ${meal.estimate.mealName} · ${meal.estimate.calories} kcal"
busy = false
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
if (e is UnauthorizedException) handleUnauth()
else status = "Analysis failed: ${e.message}"
busy = false
}
}
} }
}, },
onEdit = { editing = it }, onEdit = { editing = it },
onMoveToTrash = { ids -> onMoveToTrash = { ids ->
val moving = entries.filter { it.id in ids }.map { it.copy(trashedAt = LocalDate.now().toString()) } scope.launch(Dispatchers.IO) {
persistEntries(entries.filterNot { it.id in ids }) runCatching { repo.deleteEntries(ids.toList()) }
persistTrash(moving + trash) .onSuccess { (entries, trash) -> withContext(Dispatchers.Main) { appState = appState.copy(entries = entries, trash = trash) } }
}, .onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
onRestore = { id ->
trash.find { it.id == id }?.let { found ->
persistTrash(trash.filterNot { it.id == id })
persistEntries(listOf(found.copy(trashedAt = "")) + entries)
} }
}, },
onSettingsChange = ::persistSettings, onClearTrash = {
onSearchModels = { query -> withContext(Dispatchers.IO) { ai.searchModels(settings, query) } }, scope.launch(Dispatchers.IO) {
onGeneratePlan = { withContext(Dispatchers.IO) { ai.weightPlan(settings) } } runCatching { repo.clearTrash() }
.onSuccess { (entries, trash) -> withContext(Dispatchers.Main) { appState = appState.copy(entries = entries, trash = trash) } }
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
}
},
onRestore = { id ->
scope.launch(Dispatchers.IO) {
runCatching { repo.restoreEntry(id) }
.onSuccess { (entries, trash) -> withContext(Dispatchers.Main) { appState = appState.copy(entries = entries, trash = trash) } }
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
}
},
onSettingsChange = { updated ->
scope.launch(Dispatchers.IO) {
runCatching { repo.saveSettings(updated) }
.onSuccess { saved -> withContext(Dispatchers.Main) { appState = appState.copy(settings = saved) } }
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
}
},
onGeneratePlan = {
val settings = appState.settings
if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) {
planStatus = "Enter height and current weight in Settings first."
return@CalorieAiApp
}
planBusy = true
planStatus = "Generating plan..."
scope.launch(Dispatchers.IO) {
runCatching {
val recentMeals = appState.entries.takeLast(10)
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
.ifBlank { "none logged yet" }
val content = repo.chat(buildPlanBody(settings.taskModel, settings, recentMeals))
val plan = Plan(
id = UUID.randomUUID().toString(),
title = "Plan ${appState.plans.plans.size + 1}",
version = appState.plans.plans.size + 1,
content = content,
createdAt = java.time.Instant.now().toString()
)
repo.savePlan(plan)
}.onSuccess { plans ->
withContext(Dispatchers.Main) { appState = appState.copy(plans = plans); planStatus = "Plan generated."; planBusy = false }
}.onFailure { e ->
withContext(Dispatchers.Main) {
if (e is UnauthorizedException) handleUnauth()
else planStatus = "Error: ${e.message}"
planBusy = false
}
}
}
},
onTunePlan = { planId, note ->
val currentPlan = appState.plans.plans.find { it.id == planId } ?: return@CalorieAiApp
planBusy = true
planStatus = "Updating plan..."
scope.launch(Dispatchers.IO) {
runCatching {
val recentMeals = appState.entries.takeLast(15)
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
.ifBlank { "none logged yet" }
val content = repo.chat(buildTuneBody(appState.settings.taskModel, currentPlan.content, note, recentMeals))
val version = appState.plans.plans.maxOfOrNull { it.version }?.plus(1) ?: 2
val updated = Plan(
id = UUID.randomUUID().toString(),
title = "Updated plan $version",
version = version,
content = content,
createdAt = java.time.Instant.now().toString(),
previousPlanId = planId
)
repo.savePlan(updated)
}.onSuccess { plans ->
withContext(Dispatchers.Main) { appState = appState.copy(plans = plans); planStatus = "Plan updated."; planBusy = false }
}.onFailure { e ->
withContext(Dispatchers.Main) {
if (e is UnauthorizedException) handleUnauth()
else planStatus = "Error: ${e.message}"
planBusy = false
}
}
}
},
onSelectPlan = { id ->
scope.launch(Dispatchers.IO) {
runCatching { repo.selectPlan(id) }
.onSuccess { plans -> withContext(Dispatchers.Main) { appState = appState.copy(plans = plans) } }
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
}
},
onDeletePlan = { id ->
scope.launch(Dispatchers.IO) {
runCatching { repo.deletePlan(id) }
.onSuccess { plans -> withContext(Dispatchers.Main) { appState = appState.copy(plans = plans) } }
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
}
},
onDisconnect = {
repo.disconnect()
connected = false
appState = AppState()
editing = null
status = ""
}
) )
} }
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",
JSONArray()
.put(JSONObject().put("type", "text").put("text", "Analyze this meal photo. Estimate foods, portions, calories, protein, carbs, fat, fruit servings, vegetable servings. Description: $description. Portion: $measure"))
.put(JSONObject().put("type", "image_url").put("image_url", JSONObject().put("url", image.dataUri)))
)))
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.1)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap in markdown."))
.put(JSONObject().put("role", "user").put("content",
"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 =
JSONObject().put("model", model).put("temperature", 0.2)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", "Create safe, concise weight-loss guidance. Do not diagnose disease. Use plain text section titles. Do not use markdown symbols. Recommend medical review for pregnancy, eating disorders, chronic disease, or aggressive goals."))
.put(JSONObject().put("role", "user").put("content",
"Create a personalized weight-loss plan: height ${settings.heightCm} cm, weight ${settings.weightKg} kg, target ${settings.targetWeightKg.ifBlank { "not set" }} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, weekly review steps, safety notes. Recent meals: $recentMeals"
)))
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String): JSONObject =
JSONObject().put("model", model).put("temperature", 0.2)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", "Update this weight-loss plan using the notes and recent meals. Use plain text section titles. Do not use markdown symbols."))
.put(JSONObject().put("role", "user").put("content",
"Current plan:\n$currentContent\n\nUpdate notes:\n$note\n\nRecent meals:\n$recentMeals"
)))

View file

@ -1,90 +1,8 @@
package com.danvics.calorieai.ai package com.danvics.calorieai.ai
import android.util.Base64
import android.graphics.Bitmap import android.graphics.Bitmap
import com.danvics.calorieai.data.AppSettings import android.util.Base64
import com.danvics.calorieai.data.ModelOption
import com.danvics.calorieai.data.NutritionEstimate
import org.json.JSONArray
import org.json.JSONObject
import java.io.InputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
class AiClient(private val parser: NutritionParser = NutritionParser()) {
fun isConfigured(settings: AppSettings) = settings.apiBaseUrl.isNotBlank() && settings.nutritionModel.isNotBlank()
fun estimateMeal(settings: AppSettings, description: String, measure: String, image: ImagePayload?): NutritionEstimate {
val vision = if (image != null && settings.visionModel.isNotBlank()) visionEstimate(settings, description, measure, image) else ""
val prompt = "Estimate nutrition for one meal. Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\n\nDescription: $description\nPortion: $measure\nImage estimate: $vision"
val body = JSONObject()
.put("model", settings.nutritionModel)
.put("temperature", 0.1)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap the response in markdown."))
.put(JSONObject().put("role", "user").put("content", prompt)))
return parser.parse(chat(settings, body))
}
fun weightPlan(settings: AppSettings): String {
val prompt = "Create a safe personalized weight-loss plan. Height ${settings.heightCm} cm, current weight ${settings.weightKg} kg, target ${settings.targetWeightKg.ifBlank { "not set" }} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, and safety notes."
val body = JSONObject()
.put("model", settings.nutritionModel)
.put("temperature", 0.2)
.put("messages", JSONArray()
.put(JSONObject().put("role", "system").put("content", "Give concise nutrition guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals."))
.put(JSONObject().put("role", "user").put("content", prompt)))
return chat(settings, body)
}
fun searchModels(settings: AppSettings, query: String): List<ModelOption> {
val base = settings.apiBaseUrl.trimEnd('/').removeSuffix("/v1")
val response = runCatching { request(settings, "$base/model/info", null, "GET") }.getOrElse {
request(settings, settings.apiBaseUrl.trimEnd('/') + "/models", null, "GET")
}
val root = JSONObject(response)
val rows = root.optJSONArray("data") ?: JSONArray()
val q = query.trim().lowercase()
return (0 until rows.length()).mapNotNull { rows.optJSONObject(it) }.mapNotNull { item ->
val id = item.optString("model_name", item.optString("id"))
if (id.isBlank()) null else ModelOption(id, id)
}.distinctBy { it.id }.filter { q.isBlank() || it.id.lowercase().contains(q) }.take(100)
}
private fun visionEstimate(settings: AppSettings, description: String, measure: String, image: ImagePayload): String {
val content = JSONArray()
.put(JSONObject().put("type", "text").put("text", "Analyze this meal photo for nutrition tracking. Estimate foods, portions, calories, protein grams, carbs grams, fat grams, fruit servings, and vegetable servings. Description: $description; portion: $measure"))
.put(JSONObject().put("type", "image_url").put("image_url", JSONObject().put("url", image.dataUri)))
val body = JSONObject().put("model", settings.visionModel).put("temperature", 0.15)
.put("messages", JSONArray().put(JSONObject().put("role", "user").put("content", content)))
return chat(settings, body)
}
private fun chat(settings: AppSettings, body: JSONObject): String {
val response = request(settings, settings.apiBaseUrl.trimEnd('/') + "/chat/completions", body.toString(), "POST")
return JSONObject(response).getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content").trim()
}
private fun request(settings: AppSettings, url: String, body: String?, method: String): String {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 20_000
readTimeout = 90_000
setRequestProperty("Content-Type", "application/json")
if (settings.apiKey.isNotBlank()) setRequestProperty("Authorization", "Bearer ${settings.apiKey}")
}
if (body != null) {
connection.doOutput = true
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
}
val code = connection.responseCode
val text = (if (code in 200..299) connection.inputStream else connection.errorStream).readTextSafely()
if (code !in 200..299) error("HTTP $code: $text")
return text
}
}
data class ImagePayload(val name: String, val dataUri: String) { data class ImagePayload(val name: String, val dataUri: String) {
companion object { companion object {
@ -100,5 +18,3 @@ data class ImagePayload(val name: String, val dataUri: String) {
} }
} }
} }
private fun InputStream?.readTextSafely(): String = this?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } ?: ""

View file

@ -3,64 +3,195 @@ package com.danvics.calorieai.data
import android.content.SharedPreferences import android.content.SharedPreferences
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
class AppRepository(private val prefs: SharedPreferences) { class UnauthorizedException : Exception("Session expired. Please reconnect.")
fun entries(): List<MealEntry> = readEntries("entries")
fun trash(): List<MealEntry> = readEntries("trash")
fun saveEntries(entries: List<MealEntry>) = writeEntries("entries", entries) class ApiRepository(private val prefs: SharedPreferences) {
fun saveTrash(entries: List<MealEntry>) = writeEntries("trash", entries) private var config = loadConfig()
fun settings(): AppSettings = AppSettings( fun localConfig() = config
apiBaseUrl = prefs.getString("api_base_url", "") ?: "", fun isConnected() = config.isConnected()
apiKey = prefs.getString("api_key", "") ?: "",
visionModel = prefs.getString("vision_model", "gpt-4o-mini") ?: "gpt-4o-mini", fun login(serverUrl: String, username: String, password: String): String {
nutritionModel = prefs.getString("nutrition_model", prefs.getString("task_model", "gpt-4o-mini")) ?: "gpt-4o-mini", val base = serverUrl.trimEnd('/')
heightCm = prefs.getString("height_cm", "") ?: "", val body = JSONObject().put("username", username).put("password", password).toString()
weightKg = prefs.getString("weight_kg", "") ?: "", val conn = openConn("$base/api/login", "POST", null).apply {
targetWeightKg = prefs.getString("target_weight_kg", "") ?: "", doOutput = true
activityLevel = prefs.getString("activity_level", "Moderate") ?: "Moderate", outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
pace = prefs.getString("pace", "Steady") ?: "Steady", }
models = readModels() val code = conn.responseCode
val text = conn.readStream(code)
if (code !in 200..299) error(runCatching { JSONObject(text).optString("error", "Login failed") }.getOrElse { "Login failed ($code)" })
return conn.headerFields["Set-Cookie"]
?.firstOrNull { it.startsWith("calorie_ai_session=") }
?.substringBefore(';')?.trim()
?: error("No session cookie received from server")
}
fun saveConfig(serverUrl: String, cookie: String) {
config = LocalConfig(serverUrl, cookie)
prefs.edit().putString("server_url", serverUrl).putString("session_cookie", cookie).apply()
}
fun disconnect() {
config = LocalConfig()
prefs.edit().remove("server_url").remove("session_cookie").apply()
}
fun getAppState(): AppState {
val entries = JSONObject(get("/api/entries"))
val settings = parseSettings(JSONObject(get("/api/settings")))
val plans = parsePlans(JSONObject(get("/api/plans")))
val models = runCatching { parseModels(JSONObject(post("/api/models", "{}"))) }.getOrDefault(emptyList())
return AppState(
entries = parseEntries(entries.optJSONArray("entries")),
trash = parseEntries(entries.optJSONArray("trash")),
plans = plans,
settings = settings,
models = models
)
}
fun upsertEntry(entry: MealEntry): Pair<List<MealEntry>, List<MealEntry>> {
val root = JSONObject(post("/api/entries", entry.toJson().toString()))
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
}
fun deleteEntries(ids: List<String>): Pair<List<MealEntry>, List<MealEntry>> {
val body = JSONObject().put("ids", JSONArray(ids)).toString()
val root = JSONObject(post("/api/entries/delete", body))
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
}
fun restoreEntry(id: String): Pair<List<MealEntry>, List<MealEntry>> {
val root = JSONObject(post("/api/entries/restore", JSONObject().put("id", id).toString()))
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
}
fun getSettings(): ServerSettings = parseSettings(JSONObject(get("/api/settings")))
fun saveSettings(settings: ServerSettings): ServerSettings =
parseSettings(JSONObject(post("/api/settings", JSONObject()
.put("heightCm", settings.heightCm)
.put("weightKg", settings.weightKg)
.put("targetWeightKg", settings.targetWeightKg)
.put("calorieTarget", settings.calorieTarget)
.put("activityLevel", settings.activityLevel)
.put("pace", settings.pace)
.toString())))
fun getPlans(): PlansState = parsePlans(JSONObject(get("/api/plans")))
fun savePlan(plan: Plan): PlansState =
parsePlans(JSONObject(post("/api/plans", JSONObject()
.put("id", plan.id)
.put("title", plan.title)
.put("version", plan.version)
.put("content", plan.content)
.put("createdAt", plan.createdAt)
.put("previousPlanId", plan.previousPlanId)
.toString())))
fun selectPlan(id: String): PlansState =
parsePlans(JSONObject(post("/api/plans/select", JSONObject().put("id", id).toString())))
fun deletePlan(id: String): PlansState =
parsePlans(JSONObject(post("/api/plans/delete", JSONObject().put("id", id).toString())))
fun chat(body: JSONObject): String {
val response = JSONObject(post("/api/chat", JSONObject().put("body", body).toString()))
return response.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content").trim()
}
private fun get(path: String) = request(path, null, "GET")
private fun post(path: String, body: String) = request(path, body, "POST")
private fun request(path: String, body: String?, method: String): String {
val conn = openConn(config.serverUrl.trimEnd('/') + path, method, config.sessionCookie)
if (body != null) {
conn.doOutput = true
conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
}
val code = conn.responseCode
if (code == 401) throw UnauthorizedException()
val text = conn.readStream(code)
if (code !in 200..299) error("HTTP $code: $text")
return text
}
private fun openConn(url: String, method: String, cookie: String?) =
(URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 20_000
readTimeout = 90_000
setRequestProperty("Content-Type", "application/json")
if (!cookie.isNullOrBlank()) setRequestProperty("Cookie", cookie)
}
private fun loadConfig() = LocalConfig(
serverUrl = prefs.getString("server_url", "") ?: "",
sessionCookie = prefs.getString("session_cookie", "") ?: ""
) )
fun saveSettings(settings: AppSettings) { private fun parseEntries(arr: JSONArray?): List<MealEntry> =
prefs.edit() (0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toMealEntry() }
.putString("api_base_url", settings.apiBaseUrl)
.putString("api_key", settings.apiKey) fun clearTrash(): Pair<List<MealEntry>, List<MealEntry>> {
.putString("vision_model", settings.visionModel) val root = JSONObject(post("/api/entries/clear-trash", "{}"))
.putString("nutrition_model", settings.nutritionModel) return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
.putString("height_cm", settings.heightCm)
.putString("weight_kg", settings.weightKg)
.putString("target_weight_kg", settings.targetWeightKg)
.putString("activity_level", settings.activityLevel)
.putString("pace", settings.pace)
.putString("models", JSONArray(settings.models.map { JSONObject().put("id", it.id).put("name", it.name) }).toString())
.apply()
} }
private fun readEntries(key: String): List<MealEntry> = runCatching { private fun parseSettings(json: JSONObject) = ServerSettings(
val array = JSONArray(prefs.getString(key, "[]")) visionModel = json.optString("visionModel"),
(0 until array.length()).mapNotNull { index -> array.optJSONObject(index)?.toMealEntry() } taskModel = json.optString("taskModel"),
}.getOrDefault(emptyList()) heightCm = json.optString("heightCm"),
weightKg = json.optString("weightKg"),
targetWeightKg = json.optString("targetWeightKg"),
calorieTarget = json.optString("calorieTarget"),
activityLevel = json.optString("activityLevel", "Moderate").ifBlank { "Moderate" },
pace = json.optString("pace", "Steady").ifBlank { "Steady" }
)
private fun writeEntries(key: String, entries: List<MealEntry>) { private fun parsePlans(json: JSONObject): PlansState {
prefs.edit().putString(key, JSONArray(entries.map { it.toJson() }).toString()).apply() val arr = json.optJSONArray("plans") ?: JSONArray()
val plans = (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map { obj ->
Plan(
id = obj.optString("id"),
title = obj.optString("title", "Plan"),
version = obj.optInt("version", 1),
content = obj.optString("content"),
createdAt = obj.optString("createdAt"),
previousPlanId = obj.optString("previousPlanId")
)
}
return PlansState(
plans = plans,
selectedPlanId = json.optString("selectedPlanId").ifBlank { plans.firstOrNull()?.id ?: "" }
)
} }
private fun readModels(): List<ModelOption> = runCatching { private fun parseModels(json: JSONObject): List<ModelOption> {
val array = JSONArray(prefs.getString("models", "[]")) val arr = json.optJSONArray("models") ?: return emptyList()
(0 until array.length()).mapNotNull { array.optJSONObject(it) }.map { ModelOption(it.optString("id"), it.optString("name", it.optString("id"))) } return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }
}.getOrDefault(emptyList()).ifEmpty { listOf(ModelOption("gpt-4o-mini")) } .map { obj -> ModelOption(obj.optString("id"), obj.optString("name", obj.optString("id"))) }
}
} }
private fun HttpURLConnection.readStream(code: Int): String =
(if (code in 200..299) inputStream else errorStream ?: inputStream)
.bufferedReader(Charsets.UTF_8).use { it.readText() }
fun MealEntry.toJson(): JSONObject = JSONObject() fun MealEntry.toJson(): JSONObject = JSONObject()
.put("id", id).put("date", date).put("time", time).put("mealType", mealType) .put("id", id).put("date", date).put("time", time).put("mealType", mealType)
.put("description", description).put("measure", measure).put("imageIncluded", imageIncluded) .put("description", description).put("measure", measure).put("imageIncluded", imageIncluded)
.put("imageName", imageName).put("visionEstimate", visionEstimate).put("trashedAt", trashedAt) .put("imageName", imageName).put("visionEstimate", visionEstimate).put("trashedAt", trashedAt)
.put("mealName", estimate.mealName).put("calories", estimate.calories) .put("mealName", estimate.mealName).put("calories", estimate.calories)
.put("proteinGrams", estimate.proteinGrams).put("carbsGrams", estimate.carbsGrams).put("fatGrams", estimate.fatGrams) .put("proteinGrams", estimate.proteinGrams).put("carbsGrams", estimate.carbsGrams)
.put("fruitServings", estimate.fruitServings).put("vegetableServings", estimate.vegetableServings) .put("fatGrams", estimate.fatGrams).put("fruitServings", estimate.fruitServings)
.put("vegetableServings", estimate.vegetableServings)
.put("foodGroups", estimate.foodGroups).put("notes", estimate.notes).put("rawAi", estimate.raw) .put("foodGroups", estimate.foodGroups).put("notes", estimate.notes).put("rawAi", estimate.raw)
fun JSONObject.toMealEntry(): MealEntry = MealEntry( fun JSONObject.toMealEntry(): MealEntry = MealEntry(
@ -75,8 +206,10 @@ fun JSONObject.toMealEntry(): MealEntry = MealEntry(
visionEstimate = optString("visionEstimate"), visionEstimate = optString("visionEstimate"),
trashedAt = optString("trashedAt"), trashedAt = optString("trashedAt"),
estimate = NutritionEstimate( estimate = NutritionEstimate(
mealName = optString("mealName", "Meal"), calories = optInt("calories"), proteinGrams = optInt("proteinGrams"), mealName = optString("mealName", "Meal").ifBlank { "Meal" },
carbsGrams = optInt("carbsGrams"), fatGrams = optInt("fatGrams"), fruitServings = optDouble("fruitServings"), calories = optInt("calories"), proteinGrams = optInt("proteinGrams"),
vegetableServings = optDouble("vegetableServings"), foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi") carbsGrams = optInt("carbsGrams"), fatGrams = optInt("fatGrams"),
fruitServings = optDouble("fruitServings"), vegetableServings = optDouble("vegetableServings"),
foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi")
) )
) )

View file

@ -41,15 +41,39 @@ data class DayTotals(
data class ModelOption(val id: String, val name: String = id) data class ModelOption(val id: String, val name: String = id)
data class AppSettings( data class LocalConfig(val serverUrl: String = "", val sessionCookie: String = "") {
val apiBaseUrl: String = "", fun isConnected() = serverUrl.isNotBlank() && sessionCookie.isNotBlank()
val apiKey: String = "", }
val visionModel: String = "gpt-4o-mini",
val nutritionModel: String = "gpt-4o-mini", data class Plan(
val id: String,
val title: String,
val version: Int = 1,
val content: String,
val createdAt: String,
val previousPlanId: String = ""
)
data class PlansState(
val plans: List<Plan> = emptyList(),
val selectedPlanId: String = ""
)
data class ServerSettings(
val visionModel: String = "",
val taskModel: String = "",
val heightCm: String = "", val heightCm: String = "",
val weightKg: String = "", val weightKg: String = "",
val targetWeightKg: String = "", val targetWeightKg: String = "",
val calorieTarget: String = "",
val activityLevel: String = "Moderate", val activityLevel: String = "Moderate",
val pace: String = "Steady", val pace: String = "Steady"
val models: List<ModelOption> = listOf(ModelOption("gpt-4o-mini")) )
data class AppState(
val entries: List<MealEntry> = emptyList(),
val trash: List<MealEntry> = emptyList(),
val plans: PlansState = PlansState(),
val settings: ServerSettings = ServerSettings(),
val models: List<ModelOption> = emptyList()
) )

View file

@ -3,23 +3,36 @@ package com.danvics.calorieai.ui
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
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.* import com.danvics.calorieai.data.*
private enum class Screen(val label: String) { Dashboard("Dashboard"), Log("Log"), Diary("Diary"), Trash("Trash"), Settings("Settings") } private enum class Screen(val label: String, val icon: ImageVector) {
Dashboard("Dashboard", Icons.Default.Home),
Log("Log meal", Icons.Default.Add),
Diary("Diary", Icons.Default.Edit),
Plans("Plans", Icons.Default.Star),
Settings("Settings", Icons.Default.Settings),
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CalorieAiApp( fun CalorieAiApp(
entries: List<MealEntry>, appState: AppState,
trash: List<MealEntry>, serverUrl: String,
settings: AppSettings,
status: String, status: String,
busy: Boolean, busy: Boolean,
editing: MealEntry?, editing: MealEntry?,
selectedImageName: String, selectedImageName: String,
planStatus: String,
planBusy: Boolean,
syncing: Boolean,
onSync: () -> Unit,
onPickImage: () -> Unit, onPickImage: () -> Unit,
onTakePhoto: () -> Unit, onTakePhoto: () -> Unit,
onCancelEdit: () -> Unit, onCancelEdit: () -> Unit,
@ -27,25 +40,62 @@ fun CalorieAiApp(
onAnalyze: (MealEntry) -> Unit, onAnalyze: (MealEntry) -> Unit,
onEdit: (MealEntry) -> Unit, onEdit: (MealEntry) -> Unit,
onMoveToTrash: (Set<String>) -> Unit, onMoveToTrash: (Set<String>) -> Unit,
onClearTrash: () -> Unit,
onRestore: (String) -> Unit, onRestore: (String) -> Unit,
onSettingsChange: (AppSettings) -> Unit, onSettingsChange: (ServerSettings) -> Unit,
onSearchModels: suspend (String) -> List<ModelOption>, onGeneratePlan: () -> Unit,
onGeneratePlan: suspend () -> String onTunePlan: (String, String) -> Unit,
onSelectPlan: (String) -> Unit,
onDeletePlan: (String) -> Unit,
onDisconnect: () -> Unit
) { ) {
CalorieTheme { CalorieTheme {
var screen by remember { mutableStateOf(Screen.Dashboard) } var screen by remember { mutableStateOf(Screen.Dashboard) }
Scaffold( Scaffold(
topBar = {
TopAppBar(
title = { Text(screen.label, style = MaterialTheme.typography.titleLarge) },
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
actions = {
if (syncing) {
Box(Modifier.padding(end = 16.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
} else {
IconButton(onClick = onSync) {
Icon(Icons.Default.Refresh, contentDescription = "Sync")
}
}
}
)
},
bottomBar = { bottomBar = {
NavigationBar { Screen.entries.forEach { item -> NavigationBarItem(selected = screen == item, onClick = { screen = item }, label = { Text(item.label) }, icon = {}) } } NavigationBar {
Screen.entries.forEach { item ->
NavigationBarItem(
selected = screen == item,
onClick = { screen = item },
label = { Text(item.label, style = MaterialTheme.typography.labelSmall) },
icon = { Icon(item.icon, contentDescription = item.label) }
)
}
}
} }
) { padding -> ) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) { Box(Modifier.padding(padding).fillMaxSize()) {
when (screen) { when (screen) {
Screen.Dashboard -> DashboardScreen(entries, onLog = { screen = Screen.Log }, onSettings = { screen = Screen.Settings }) 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, selectedImageName, status, busy, onPickImage, onTakePhoto, onAnalyze, onSaveManualEdit, onCancelEdit)
Screen.Diary -> DiaryScreen(entries, onEdit = { onEdit(it); screen = Screen.Log }, onMoveToTrash = onMoveToTrash) Screen.Diary -> DiaryScreen(
Screen.Trash -> TrashScreen(trash, onRestore) entries = appState.entries,
Screen.Settings -> SettingsScreen(settings, onSettingsChange, onSearchModels, onGeneratePlan) trash = appState.trash,
onEdit = { onEdit(it); screen = Screen.Log },
onMoveToTrash = onMoveToTrash,
onRestore = onRestore,
onClearTrash = onClearTrash
)
Screen.Plans -> PlansScreen(appState.settings, appState.plans, planStatus, planBusy, onGeneratePlan, onTunePlan, onSelectPlan, onDeletePlan)
Screen.Settings -> SettingsScreen(appState.settings, serverUrl, onSettingsChange, onDisconnect)
} }
} }
} }
@ -56,5 +106,3 @@ fun CalorieAiApp(
fun Page(content: @Composable ColumnScope.() -> Unit) { fun Page(content: @Composable ColumnScope.() -> Unit) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), content = content) Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), content = content)
} }
private fun blankMeal() = MealEntry("", "", "", "Breakfast", "", "", false, "", "", NutritionEstimate())

View file

@ -2,8 +2,9 @@ package com.danvics.calorieai.ui
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -11,7 +12,7 @@ import androidx.compose.ui.unit.dp
fun SectionCard(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { fun SectionCard(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant)
content() content()
} }
} }
@ -20,20 +21,86 @@ fun SectionCard(title: String, modifier: Modifier = Modifier, content: @Composab
@Composable @Composable
fun StatTile(label: String, value: String, modifier: Modifier = Modifier) { fun StatTile(label: String, value: String, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) { ElevatedCard(modifier = modifier) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black) Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Black)
} }
} }
} }
@Composable @Composable
fun Field(label: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, singleLine: Boolean = true) { fun Field(label: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, singleLine: Boolean = true, placeholder: String = "") {
OutlinedTextField( OutlinedTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
label = { Text(label) }, label = { Text(label) },
placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) }) else null,
singleLine = singleLine, singleLine = singleLine,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DropdownField(label: String, options: List<String>, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = modifier.fillMaxWidth()) {
OutlinedTextField(
value = value,
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable, true)
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { option ->
DropdownMenuItem(text = { Text(option) }, onClick = { onValueChange(option); expanded = false })
}
}
}
}
@Composable
fun MacroBar(label: String, grams: Int, totalGrams: Int, color: Color) {
val pct = if (totalGrams > 0) grams.toFloat() / totalGrams else 0f
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
Text("${grams}g · ${(pct * 100).toInt()}%", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
LinearProgressIndicator(
progress = { pct },
modifier = Modifier.fillMaxWidth().height(8.dp),
color = color,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
}
}
@Composable
fun CalorieGoalBar(consumed: Int, target: Int) {
val fraction = (consumed.toFloat() / target).coerceIn(0f, 1f)
val barColor = when {
fraction >= 1f -> MaterialTheme.colorScheme.error
fraction >= 0.85f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("$consumed kcal consumed", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
Text("goal: $target kcal", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
LinearProgressIndicator(
progress = { fraction },
modifier = Modifier.fillMaxWidth().height(8.dp),
color = barColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
Text(
if (fraction >= 1f) "Daily goal reached" else "${target - consumed} kcal remaining",
style = MaterialTheme.typography.labelSmall,
color = if (fraction >= 1f) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View file

@ -0,0 +1,66 @@
package com.danvics.calorieai.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun ConnectScreen(error: String, connecting: Boolean, onConnect: (String, String, String) -> Unit) {
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("admin") }
var password by remember { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Calorie AI", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black)
Text(
"Connect to your self-hosted server",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server URL") },
placeholder = { Text("http://192.168.1.10:3000") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
Spacer(Modifier.height(20.dp))
Button(
enabled = !connecting && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
onClick = { onConnect(serverUrl.trim(), username.trim(), password) },
modifier = Modifier.fillMaxWidth()
) { Text(if (connecting) "Connecting..." else "Connect") }
if (error.isNotBlank()) {
Spacer(Modifier.height(8.dp))
Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
}

View file

@ -8,27 +8,70 @@ import androidx.compose.ui.text.font.FontWeight
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.MealStats import com.danvics.calorieai.data.MealStats
import com.danvics.calorieai.data.ServerSettings
import java.time.LocalDate import java.time.LocalDate
@Composable @Composable
fun DashboardScreen(entries: List<MealEntry>, onLog: () -> Unit, onSettings: () -> Unit) = Page { fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: () -> Unit) = Page {
val today = MealStats.totalsForDate(entries, LocalDate.now().toString()) val today = MealStats.totalsForDate(entries, LocalDate.now().toString())
Text("Calorie AI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Black) val week = MealStats.lastSevenDays(entries)
val avgCalories = if (week.isEmpty()) 0 else week.sumOf { it.calories } / week.size
val macroTotal = today.protein + today.carbs + today.fat
val calorieTarget = settings.calorieTarget.toIntOrNull() ?: 0
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Today", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f), fontWeight = FontWeight.Bold)
Text("${today.calories} kcal", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onPrimary)
Text("P ${today.protein}g · C ${today.carbs}g · F ${today.fat}g", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.85f))
Text("Fruit ${"%.1f".format(today.fruit)} · Veg ${"%.1f".format(today.vegetables)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f))
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
StatTile("Today", "${today.calories} kcal", Modifier.weight(1f)) StatTile("Meals today", today.meals.toString(), Modifier.weight(1f))
StatTile("Meals", today.meals.toString(), Modifier.weight(1f)) StatTile("7-day avg", "$avgCalories kcal", Modifier.weight(1f))
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
StatTile("Protein", "${today.protein}g", Modifier.weight(1f)) if (calorieTarget > 0) {
StatTile("Produce", "%.1f".format(today.fruit + today.vegetables), Modifier.weight(1f)) SectionCard("Daily calorie goal") {
CalorieGoalBar(today.calories, calorieTarget)
}
} }
SectionCard("Quick actions") {
Button(onClick = onLog, modifier = Modifier.fillMaxWidth()) { Text("Log a meal") } if (macroTotal > 0) {
OutlinedButton(onClick = onSettings, modifier = Modifier.fillMaxWidth()) { Text("Settings") } SectionCard("Macros") {
MacroBar("Protein", today.protein, macroTotal, MaterialTheme.colorScheme.primary)
MacroBar("Carbs", today.carbs, macroTotal, MaterialTheme.colorScheme.tertiary)
MacroBar("Fat", today.fat, macroTotal, MaterialTheme.colorScheme.secondary)
}
} }
SectionCard("Last 7 days") {
MealStats.lastSevenDays(entries).forEach { day -> val todayEntries = entries.filter { it.date == LocalDate.now().toString() }.sortedBy { it.time }
Text("${day.label}: ${day.calories} kcal, produce ${"%.1f".format(day.fruit + day.vegetables)}") if (todayEntries.isNotEmpty()) {
SectionCard("Today's meals") {
todayEntries.forEach { meal ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(meal.estimate.mealName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Text("${meal.estimate.calories} kcal", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
} else {
Button(onClick = onLog, modifier = Modifier.fillMaxWidth()) { Text("Log your first meal today") }
}
if (week.any { it.calories > 0 }) {
SectionCard("Last 7 days") {
week.reversed().forEach { day ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(day.label, style = MaterialTheme.typography.bodyMedium)
Text(if (day.calories > 0) "${day.calories} kcal" else "", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} }
} }
} }

View file

@ -3,51 +3,153 @@ package com.danvics.calorieai.ui
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.MealEntry import com.danvics.calorieai.data.MealEntry
@Composable @Composable
fun DiaryScreen(entries: List<MealEntry>, onEdit: (MealEntry) -> Unit, onMoveToTrash: (Set<String>) -> Unit) = Page { fun DiaryScreen(
entries: List<MealEntry>,
trash: List<MealEntry>,
onEdit: (MealEntry) -> Unit,
onMoveToTrash: (Set<String>) -> Unit,
onRestore: (String) -> Unit,
onClearTrash: () -> Unit
) {
var activeTab by remember { mutableStateOf(0) }
Column(Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = activeTab) {
Tab(selected = activeTab == 0, onClick = { activeTab = 0 }, text = { Text("Meals") })
Tab(
selected = activeTab == 1,
onClick = { activeTab = 1 },
text = { Text(if (trash.isEmpty()) "Trash" else "Trash (${trash.size})") }
)
}
when (activeTab) {
0 -> EntriesContent(entries, onEdit, onMoveToTrash)
1 -> TrashContent(trash, onRestore, onClearTrash)
}
}
}
@Composable
private fun EntriesContent(
entries: List<MealEntry>,
onEdit: (MealEntry) -> Unit,
onMoveToTrash: (Set<String>) -> Unit
) = Page {
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
var selected by remember { mutableStateOf(setOf<String>()) } var selected by remember { mutableStateOf(setOf<String>()) }
val rows = entries.filter { query.isBlank() || listOf(it.description, it.estimate.mealName, it.estimate.foodGroups).any { value -> value.contains(query, true) } } val rows = entries.filter {
Text("Diary", style = MaterialTheme.typography.headlineMedium) query.isBlank() || listOf(it.description, it.estimate.mealName, it.estimate.foodGroups, it.mealType)
Field("Search meals", query, { query = it }) .any { value -> value.contains(query, ignoreCase = true) }
Button(enabled = selected.isNotEmpty(), onClick = { onMoveToTrash(selected); selected = emptySet() }, modifier = Modifier.fillMaxWidth()) { Text("Move selected to trash") } }
Field("Search meals", query, { query = it }, placeholder = "Search by name, type, or food groups...")
if (selected.isNotEmpty()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"${selected.size} selected",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.align(Alignment.CenterVertically).weight(1f)
)
Button(
onClick = { onMoveToTrash(selected); selected = emptySet() },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) { Text("Move to trash") }
}
}
rows.groupBy { it.date }.toSortedMap(reverseOrder()).forEach { (date, dayRows) -> rows.groupBy { it.date }.toSortedMap(reverseOrder()).forEach { (date, dayRows) ->
SectionCard(date) { SectionCard(date) {
OutlinedButton(onClick = { onMoveToTrash(dayRows.map { it.id }.toSet()) }, modifier = Modifier.fillMaxWidth()) { Text("Move day to trash") } dayRows.sortedByDescending { it.time }.forEach { meal ->
dayRows.forEach { meal -> HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
ElevatedCard(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Checkbox(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { checked = meal.id in selected,
Checkbox(checked = meal.id in selected, onCheckedChange = { checked -> selected = if (checked) selected + meal.id else selected - meal.id }) onCheckedChange = { checked -> selected = if (checked) selected + meal.id else selected - meal.id }
Column(Modifier.weight(1f)) { )
Text(meal.estimate.mealName, fontWeight = FontWeight.Bold) Column(Modifier.weight(1f)) {
Text("${meal.time} · ${meal.mealType} · ${meal.estimate.calories} kcal") Text(meal.estimate.mealName, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium)
} Text(
} "${meal.time} · ${meal.mealType} · ${meal.estimate.calories} kcal",
Text("P ${meal.estimate.proteinGrams}g · C ${meal.estimate.carbsGrams}g · F ${meal.estimate.fatGrams}g") style = MaterialTheme.typography.bodySmall,
if (meal.description.isNotBlank()) Text(meal.description) color = MaterialTheme.colorScheme.onSurfaceVariant
OutlinedButton(onClick = { onEdit(meal) }) { Text("Edit") } )
Text(
"P ${meal.estimate.proteinGrams}g · C ${meal.estimate.carbsGrams}g · F ${meal.estimate.fatGrams}g",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
TextButton(onClick = { onEdit(meal) }) { Text("Edit") }
} }
} }
} }
} }
if (rows.isEmpty()) Text("No meals match the current filters.")
if (rows.isEmpty()) {
Text(
if (query.isBlank()) "No meals logged yet." else "No meals match your search.",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
@Composable @Composable
fun TrashScreen(trash: List<MealEntry>, onRestore: (String) -> Unit) = Page { private fun TrashContent(
Text("Trash", style = MaterialTheme.typography.headlineMedium) trash: List<MealEntry>,
if (trash.isEmpty()) Text("Trash is empty.") onRestore: (String) -> Unit,
onClearTrash: () -> Unit
) = Page {
var showConfirm by remember { mutableStateOf(false) }
if (showConfirm) {
AlertDialog(
onDismissRequest = { showConfirm = false },
title = { Text("Clear trash?") },
text = { Text("This will permanently delete all ${trash.size} items. This cannot be undone.") },
confirmButton = {
TextButton(
onClick = { onClearTrash(); showConfirm = false },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Clear all") }
},
dismissButton = {
TextButton(onClick = { showConfirm = false }) { Text("Cancel") }
}
)
}
if (trash.isEmpty()) {
Text("Trash is empty.", color = MaterialTheme.colorScheme.onSurfaceVariant)
return@Page
}
Button(
onClick = { showConfirm = true },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth()
) { Text("Clear all trash (${trash.size})") }
trash.forEach { meal -> trash.forEach { meal ->
SectionCard(meal.estimate.mealName) { Card(modifier = Modifier.fillMaxWidth()) {
Text("${meal.date} ${meal.time} · ${meal.estimate.calories} kcal") Row(Modifier.padding(16.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = { onRestore(meal.id) }) { Text("Restore") } Column(Modifier.weight(1f)) {
Text(meal.estimate.mealName, fontWeight = FontWeight.SemiBold)
Text(
"${meal.date} · ${meal.mealType} · ${meal.estimate.calories} kcal",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
OutlinedButton(onClick = { onRestore(meal.id) }) { Text("Restore") }
}
} }
} }
} }

View file

@ -11,6 +11,8 @@ import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
private val MEAL_TYPES = listOf("Breakfast", "Lunch", "Dinner", "Snack", "Other")
@Composable @Composable
fun LogMealScreen( fun LogMealScreen(
editing: MealEntry?, editing: MealEntry?,
@ -30,43 +32,62 @@ 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()) }
Text(if (editing == null) "Log meal" else "Edit meal", style = MaterialTheme.typography.headlineMedium)
SectionCard("Meal details") { SectionCard("Meal details") {
Field("Date", date, { date = it }) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Time", time, { time = it }) Field("Date", date, { date = it }, modifier = Modifier.weight(1f), placeholder = "YYYY-MM-DD")
Field("Meal type", mealType, { mealType = it }) Field("Time", time, { time = it }, modifier = Modifier.weight(1f), placeholder = "HH:MM")
Field("Description", description, { description = it }, singleLine = false) }
Field("Portion or measure", measure, { measure = it }) 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")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = onTakePhoto, modifier = Modifier.weight(1f)) { Text("Take photo") } OutlinedButton(onClick = onTakePhoto, modifier = Modifier.weight(1f)) { Text("Take photo") }
OutlinedButton(onClick = onPickImage, modifier = Modifier.weight(1f)) { Text("Choose image") } OutlinedButton(onClick = onPickImage, modifier = Modifier.weight(1f)) { Text("Choose image") }
} }
if (selectedImageName.isNotBlank()) Text("Selected: $selectedImageName") if (selectedImageName.isNotBlank()) {
Text("Image: $selectedImageName", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Button( Button(
enabled = !busy && (description.isNotBlank() || selectedImageName.isNotBlank()), enabled = !busy && (description.isNotBlank() || selectedImageName.isNotBlank()),
onClick = { onAnalyze(draft(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()) Text(status, color = MaterialTheme.colorScheme.primary) if (status.isNotBlank()) {
Text(
status,
style = MaterialTheme.typography.bodySmall,
color = if (status.startsWith("Analysis failed") || status.startsWith("Set API")) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
}
} }
if (editing != null) { if (editing != null) {
SectionCard("Editable estimate") { SectionCard("Edit estimate") {
Field("Meal name", estimate.mealName, { estimate = estimate.copy(mealName = it) }) Field("Meal name", estimate.mealName, { estimate = estimate.copy(mealName = it) })
Field("Calories", estimate.calories.toString(), { estimate = estimate.copy(calories = it.toIntOrNull() ?: 0) }) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Protein grams", estimate.proteinGrams.toString(), { estimate = estimate.copy(proteinGrams = it.toIntOrNull() ?: 0) }) Field("Calories", estimate.calories.toString(), { estimate = estimate.copy(calories = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f))
Field("Carbs grams", estimate.carbsGrams.toString(), { estimate = estimate.copy(carbsGrams = it.toIntOrNull() ?: 0) }) Field("Protein g", estimate.proteinGrams.toString(), { estimate = estimate.copy(proteinGrams = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f))
Field("Fat grams", estimate.fatGrams.toString(), { estimate = estimate.copy(fatGrams = it.toIntOrNull() ?: 0) }) }
Field("Fruit servings", estimate.fruitServings.toString(), { estimate = estimate.copy(fruitServings = it.toDoubleOrNull() ?: 0.0) }) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Vegetable servings", estimate.vegetableServings.toString(), { estimate = estimate.copy(vegetableServings = it.toDoubleOrNull() ?: 0.0) }) Field("Carbs g", estimate.carbsGrams.toString(), { estimate = estimate.copy(carbsGrams = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f))
Field("Fat g", estimate.fatGrams.toString(), { estimate = estimate.copy(fatGrams = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Fruit srv", estimate.fruitServings.toString(), { estimate = estimate.copy(fruitServings = it.toDoubleOrNull() ?: 0.0) }, modifier = Modifier.weight(1f))
Field("Veg srv", estimate.vegetableServings.toString(), { estimate = estimate.copy(vegetableServings = it.toDoubleOrNull() ?: 0.0) }, modifier = Modifier.weight(1f))
}
Field("Food groups", estimate.foodGroups, { estimate = estimate.copy(foodGroups = it) }) Field("Food groups", estimate.foodGroups, { estimate = estimate.copy(foodGroups = it) })
Field("Notes", estimate.notes, { estimate = estimate.copy(notes = it) }, singleLine = false) Field("Notes", estimate.notes, { estimate = estimate.copy(notes = it) }, singleLine = false)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { onSaveManualEdit(draft(editing, date, time, mealType, description, measure, estimate)) }) { Text("Save edits") } Button(
OutlinedButton(onClick = onCancelEdit) { Text("Cancel") } onClick = { onSaveManualEdit(buildDraft(editing, date, time, mealType, description, measure, estimate)) },
modifier = Modifier.weight(1f)
) { Text("Save edits") }
OutlinedButton(onClick = onCancelEdit, modifier = Modifier.weight(1f)) { Text("Cancel") }
} }
} }
} }
} }
private fun draft(editing: MealEntry?, date: String, time: String, mealType: String, description: String, measure: String, estimate: NutritionEstimate) = 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) MealEntry(editing?.id.orEmpty(), date, time, mealType, description, measure, editing?.imageIncluded ?: false, editing?.imageName.orEmpty(), editing?.visionEstimate.orEmpty(), estimate)

View file

@ -0,0 +1,102 @@
package com.danvics.calorieai.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.PlansState
import com.danvics.calorieai.data.ServerSettings
@Composable
fun PlansScreen(
settings: ServerSettings,
plansState: PlansState,
planStatus: String,
planBusy: Boolean,
onGenerate: () -> Unit,
onTune: (planId: String, note: String) -> Unit,
onSelect: (String) -> Unit,
onDelete: (String) -> Unit
) = Page {
val selectedPlan = plansState.plans.find { it.id == plansState.selectedPlanId } ?: plansState.plans.firstOrNull()
var tuneNote by remember { mutableStateOf("") }
SectionCard("Generate plan") {
Text(
"Your plan is built from your profile and recent meals, then updated as you log and progress.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(
enabled = !planBusy && settings.heightCm.isNotBlank() && settings.weightKg.isNotBlank(),
onClick = onGenerate,
modifier = Modifier.fillMaxWidth()
) { Text(if (planBusy) "Generating..." else "Generate new plan") }
if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) {
Text(
"Fill in your height and weight in Settings first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
if (planStatus.isNotBlank()) {
Text(planStatus, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
}
if (selectedPlan != null) {
SectionCard("${selectedPlan.title}") {
Text(
"Version ${selectedPlan.version} · ${selectedPlan.createdAt.take(10)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Text(selectedPlan.content, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(12.dp))
}
Field("Update notes", tuneNote, { tuneNote = it }, singleLine = false, placeholder = "e.g. walked 7k steps daily, felt hungry at night, lost 0.5 kg")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
enabled = !planBusy && tuneNote.isNotBlank(),
onClick = { onTune(selectedPlan.id, tuneNote); tuneNote = "" },
modifier = Modifier.weight(1f)
) { Text(if (planBusy) "Updating..." else "Update plan") }
OutlinedButton(
onClick = { onDelete(selectedPlan.id) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Delete") }
}
}
} else if (!planBusy) {
SectionCard("No plan yet") {
Text("Generate your first plan to get started.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
if (plansState.plans.size > 1) {
SectionCard("Plan history") {
plansState.plans.forEach { plan ->
OutlinedButton(
onClick = { onSelect(plan.id) },
modifier = Modifier.fillMaxWidth()
) {
Column(Modifier.fillMaxWidth()) {
Text(
plan.title,
fontWeight = if (plan.id == plansState.selectedPlanId) FontWeight.Bold else FontWeight.Normal,
style = MaterialTheme.typography.bodyMedium
)
Text(
"Version ${plan.version} · ${plan.createdAt.take(10)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}

View file

@ -5,49 +5,43 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.AppSettings import com.danvics.calorieai.data.ServerSettings
import com.danvics.calorieai.data.ModelOption
import kotlinx.coroutines.launch private val ACTIVITY_LEVELS = listOf("Low", "Moderate", "High")
private val PACE_OPTIONS = listOf("Gentle", "Steady", "Aggressive")
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
settings: AppSettings, settings: ServerSettings,
onSettingsChange: (AppSettings) -> Unit, serverUrl: String,
onSearchModels: suspend (String) -> List<ModelOption>, onSettingsChange: (ServerSettings) -> Unit,
onGeneratePlan: suspend () -> String onDisconnect: () -> Unit
) = Page { ) = Page {
val scope = rememberCoroutineScope()
var draft by remember(settings) { mutableStateOf(settings) } var draft by remember(settings) { mutableStateOf(settings) }
var modelQuery by remember { mutableStateOf("") }
var discovered by remember { mutableStateOf(emptyList<ModelOption>()) }
var plan by remember { mutableStateOf("") }
var status by remember { mutableStateOf("") } var status by remember { mutableStateOf("") }
Text("Settings", style = MaterialTheme.typography.headlineMedium) SectionCard("Profile") {
SectionCard("AI connection") { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("API base URL", draft.apiBaseUrl, { draft = draft.copy(apiBaseUrl = it) }) Field("Height (cm)", draft.heightCm, { draft = draft.copy(heightCm = it) }, modifier = Modifier.weight(1f), placeholder = "175")
Field("API key", draft.apiKey, { draft = draft.copy(apiKey = it) }) Field("Weight (kg)", draft.weightKg, { draft = draft.copy(weightKg = it) }, modifier = Modifier.weight(1f), placeholder = "80")
Field("Image model", draft.visionModel, { draft = draft.copy(visionModel = it) })
Field("Nutrition and advice model", draft.nutritionModel, { draft = draft.copy(nutritionModel = it) })
Button(onClick = { onSettingsChange(draft); status = "Settings saved." }, modifier = Modifier.fillMaxWidth()) { Text("Save settings") }
if (status.isNotBlank()) Text(status)
}
SectionCard("Model search") {
Field("Search current provider models", modelQuery, { modelQuery = it })
Button(onClick = { scope.launch { discovered = onSearchModels(modelQuery) } }, modifier = Modifier.fillMaxWidth()) { Text("Search") }
Column(Modifier.heightIn(max = 260.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
discovered.forEach { model ->
OutlinedButton(onClick = { draft = draft.copy(models = (draft.models + model).distinctBy { it.id }, nutritionModel = model.id) }, modifier = Modifier.fillMaxWidth()) { Text("+ ${model.name}") }
}
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Target weight (kg)", draft.targetWeightKg, { draft = draft.copy(targetWeightKg = it) }, modifier = Modifier.weight(1f), placeholder = "70")
Field("Daily kcal goal", draft.calorieTarget, { draft = draft.copy(calorieTarget = it) }, modifier = Modifier.weight(1f), placeholder = "2000")
}
DropdownField("Activity level", ACTIVITY_LEVELS, draft.activityLevel, { draft = draft.copy(activityLevel = it) })
DropdownField("Pace", PACE_OPTIONS, draft.pace, { draft = draft.copy(pace = it) })
Button(onClick = { onSettingsChange(draft); status = "Profile saved." }, modifier = Modifier.fillMaxWidth()) { Text("Save profile") }
if (status.isNotBlank()) Text(status, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
} }
SectionCard("Weight-loss plan") {
Field("Height cm", draft.heightCm, { draft = draft.copy(heightCm = it) }) SectionCard("Server") {
Field("Current weight kg", draft.weightKg, { draft = draft.copy(weightKg = it) }) Text("Connected to $serverUrl", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Field("Target weight kg", draft.targetWeightKg, { draft = draft.copy(targetWeightKg = it) }) Text("AI models and API keys are configured on the server.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Field("Activity", draft.activityLevel, { draft = draft.copy(activityLevel = it) }) OutlinedButton(
Field("Pace", draft.pace, { draft = draft.copy(pace = it) }) onClick = onDisconnect,
Button(onClick = { onSettingsChange(draft); scope.launch { plan = onGeneratePlan() } }, modifier = Modifier.fillMaxWidth()) { Text("Generate plan") } modifier = Modifier.fillMaxWidth(),
if (plan.isNotBlank()) Text(plan) colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Disconnect") }
} }
} }

View file

@ -1,19 +1,36 @@
package com.danvics.calorieai.ui package com.danvics.calorieai.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
private val Colors = lightColorScheme( private val LightColors = lightColorScheme(
primary = Color(0xFF2563EB), primary = Color(0xFF2563EB),
secondary = Color(0xFF10B981), secondary = Color(0xFF10B981),
tertiary = Color(0xFFF59E0B), tertiary = Color(0xFFF59E0B),
background = Color(0xFFF8FAFC), background = Color(0xFFF8FAFC),
surface = Color.White surface = Color(0xFFFFFFFF),
surfaceVariant = Color(0xFFF1F5F9),
onSurfaceVariant = Color(0xFF64748B),
)
private val DarkColors = darkColorScheme(
primary = Color(0xFF60A5FA),
secondary = Color(0xFF34D399),
tertiary = Color(0xFFFBBF24),
background = Color(0xFF0F172A),
surface = Color(0xFF1E293B),
surfaceVariant = Color(0xFF334155),
onSurfaceVariant = Color(0xFF94A3B8),
) )
@Composable @Composable
fun CalorieTheme(content: @Composable () -> Unit) { fun CalorieTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = Colors, content = content) MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors,
content = content
)
} }

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2563EB" />
</shape>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Outer flame body (white) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,22 C54,22 36,40 36,57 C36,69 44,78 54,78 C64,78 72,69 72,57 C72,40 54,22 54,22 Z" />
<!-- Inner cutout — same color as background creates hollow flame -->
<path
android:fillColor="#2563EB"
android:pathData="M54,44 C54,44 46,54 46,62 C46,68 50,72 54,72 C58,72 62,68 62,62 C62,54 54,44 54,44 Z" />
<!-- Small inner highlight dot -->
<path
android:fillColor="#FFFFFF"
android:alpha="0.4"
android:pathData="M54,52 C52,56 52,60 54,62 C56,60 56,56 54,52 Z" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2563EB</color>
</resources>

View file

@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar"> <style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item> <item name="android:windowBackground">@android:color/white</item>
<item name="android:windowLightStatusBar">true</item> <item name="android:statusBarColor">@color/ic_launcher_background</item>
<item name="android:statusBarColor">#F8F4EC</item> <item name="android:navigationBarColor">@android:color/white</item>
<item name="android:navigationBarColor">#F8F4EC</item> <item name="android:windowLightStatusBar">false</item>
<item name="android:colorAccent">#2F7D59</item> <item name="android:fontFamily">sans-serif</item>
<item name="android:colorAccent">#2563EB</item>
</style> </style>
</resources> </resources>

View file

@ -12,6 +12,8 @@ const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data');
const authFile = path.join(dataDir, 'auth.json'); const authFile = path.join(dataDir, 'auth.json');
const modelsFile = path.join(dataDir, 'models.json'); const modelsFile = path.join(dataDir, 'models.json');
const plansFile = path.join(dataDir, 'plans.json'); const plansFile = path.join(dataDir, 'plans.json');
const settingsFile = path.join(dataDir, 'settings.json');
const entriesFile = path.join(dataDir, 'entries.json');
const sessionCookieName = 'calorie_ai_session'; const sessionCookieName = 'calorie_ai_session';
const sessionSeconds = 60 * 60 * 24 * 7; const sessionSeconds = 60 * 60 * 24 * 7;
let auth; let auth;
@ -275,6 +277,114 @@ async function deletePlanRoute(req, res) {
send(res, 200, JSON.stringify(config)); send(res, 200, JSON.stringify(config));
} }
function loadSettings() {
return readJsonFile(settingsFile, {
visionModel: '',
taskModel: '',
heightCm: '',
weightKg: '',
targetWeightKg: '',
calorieTarget: '',
activityLevel: 'Moderate',
pace: 'Steady',
});
}
function saveSettingsData(settings) {
writePrivateJson(settingsFile, { ...settings, updatedAt: new Date().toISOString() });
}
async function getSettings(req, res) {
send(res, 200, JSON.stringify(loadSettings()));
}
async function updateSettings(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const current = loadSettings();
const allowed = ['visionModel', 'taskModel', 'heightCm', 'weightKg', 'targetWeightKg', 'calorieTarget', 'activityLevel', 'pace'];
const updated = { ...current };
for (const key of allowed) if (payload[key] !== undefined) updated[key] = payload[key];
saveSettingsData(updated);
send(res, 200, JSON.stringify(updated));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
function loadEntriesData() {
const stored = readJsonFile(entriesFile, {});
return {
entries: Array.isArray(stored.entries) ? stored.entries : [],
trash: Array.isArray(stored.trash) ? stored.trash : [],
};
}
function saveEntriesData(data) {
writePrivateJson(entriesFile, { ...data, updatedAt: new Date().toISOString() });
}
async function getEntriesRoute(req, res) {
send(res, 200, JSON.stringify(loadEntriesData()));
}
async function upsertEntryRoute(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Entry ID required' }));
const data = loadEntriesData();
const idx = data.entries.findIndex(e => e.id === payload.id);
if (idx >= 0) data.entries[idx] = { ...data.entries[idx], ...payload };
else data.entries = [...data.entries, payload];
saveEntriesData(data);
send(res, 200, JSON.stringify(data));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function deleteEntriesRoute(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const ids = new Set(Array.isArray(payload.ids) ? payload.ids : [payload.id].filter(Boolean));
const data = loadEntriesData();
const moving = data.entries.filter(e => ids.has(e.id)).map(e => ({ ...e, trashedAt: new Date().toISOString() }));
data.entries = data.entries.filter(e => !ids.has(e.id));
data.trash = [...moving, ...data.trash];
saveEntriesData(data);
send(res, 200, JSON.stringify(data));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function restoreEntryRoute(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const data = loadEntriesData();
const found = data.trash.find(e => e.id === payload.id);
if (!found) return send(res, 404, JSON.stringify({ error: 'Entry not found in trash' }));
const { trashedAt, ...restored } = found;
data.entries = [restored, ...data.entries];
data.trash = data.trash.filter(e => e.id !== payload.id);
saveEntriesData(data);
send(res, 200, JSON.stringify(data));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function clearTrashRoute(req, res) {
try {
const data = loadEntriesData();
data.trash = [];
saveEntriesData(data);
send(res, 200, JSON.stringify(data));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
function dedupeModels(models) { function dedupeModels(models) {
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, { return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [model.id, {
id: String(model.id), id: String(model.id),
@ -595,35 +705,6 @@ function logout(req, res) {
}); });
} }
async function handleRequest(req, res) {
const url = req.url.split('?')[0];
const authenticated = isAuthenticated(req);
if (!authenticated && !publicRequest(req)) {
if (url.startsWith('/api/')) return send(res, 401, JSON.stringify({ error: 'Authentication required' }));
return redirect(res, '/login');
}
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') return redirect(res, '/');
if (req.method === 'POST' && url === '/api/login') return login(req, res);
if (req.method === 'POST' && url === '/api/logout') return logout(req, res);
if (req.method === 'POST' && url === '/api/account') return updateAccount(req, res);
if (req.method === 'POST' && url === '/api/account/send-verification') return sendVerification(req, res);
if (req.method === 'POST' && url === '/api/account/verify') return verifyAccount(req, res);
if (req.method === 'POST' && url === '/api/password/change') return changePassword(req, res);
if (req.method === 'POST' && url === '/api/password/reset') return resetPassword(req, res);
if (req.method === 'POST' && url === '/api/password/request-reset') return requestPasswordReset(req, res);
if (req.method === 'POST' && url === '/api/password/confirm-reset') return confirmPasswordReset(req, res);
if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user, email: auth.email || '', emailVerified: auth.emailVerified === true }));
if (req.method === 'POST' && url === '/api/models') return proxyModels(req, res);
if (req.method === 'POST' && url === '/api/models/search') return searchModels(req, res);
if (req.method === 'POST' && url === '/api/models/add') return addModel(req, res);
if (req.method === 'POST' && url === '/api/models/remove') return removeModel(req, res);
if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res);
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8');
}
async function start() { async function start() {
auth = await loadAuthConfig(); auth = await loadAuthConfig();
modelConfig = loadModelConfig(); modelConfig = loadModelConfig();
@ -658,6 +739,13 @@ async function start() {
app.post('/api/plans', wrap(createPlan)); app.post('/api/plans', wrap(createPlan));
app.post('/api/plans/select', wrap(selectPlanRoute)); app.post('/api/plans/select', wrap(selectPlanRoute));
app.post('/api/plans/delete', wrap(deletePlanRoute)); app.post('/api/plans/delete', wrap(deletePlanRoute));
app.get('/api/settings', wrap(getSettings));
app.post('/api/settings', wrap(updateSettings));
app.get('/api/entries', wrap(getEntriesRoute));
app.post('/api/entries', wrap(upsertEntryRoute));
app.post('/api/entries/delete', wrap(deleteEntriesRoute));
app.post('/api/entries/restore', wrap(restoreEntryRoute));
app.post('/api/entries/clear-trash', wrap(clearTrashRoute));
app.post('/api/chat', wrap(proxyChat)); app.post('/api/chat', wrap(proxyChat));
app.use((req, res) => { app.use((req, res) => {
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res); if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);

View file

@ -1,5 +1,6 @@
<script> <script>
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import Field from './Field.svelte'; import Field from './Field.svelte';
import Stat from './Stat.svelte'; import Stat from './Stat.svelte';
import MealForm from './MealForm.svelte'; import MealForm from './MealForm.svelte';
@ -7,17 +8,14 @@
import SettingsPage from './SettingsPage.svelte'; import SettingsPage from './SettingsPage.svelte';
import PlansPage from './PlansPage.svelte'; import PlansPage from './PlansPage.svelte';
const entriesKey = 'calorie-ai-web-entries';
const trashKey = 'calorie-ai-web-trash';
const settingsKey = 'calorie-ai-web-settings';
const pages = [ const pages = [
{ id: 'dashboard', label: 'Dashboard', icon: '', group: 'Tracker' }, { id: 'dashboard', label: 'Dashboard', icon: 'dashboard', group: 'Tracker' },
{ id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' }, { id: 'log', label: 'Log meal', icon: 'plus', group: 'Tracker' },
{ id: 'analytics', label: 'Analytics', icon: '', group: 'Tracker' }, { id: 'analytics', label: 'Analytics', icon: 'analytics', group: 'Tracker' },
{ id: 'diary', label: 'Diary', icon: '', group: 'Tracker' }, { id: 'diary', label: 'Diary', icon: 'diary', group: 'Tracker' },
{ id: 'trash', label: 'Trash', icon: '×', group: 'Tracker' }, { id: 'trash', label: 'Trash', icon: 'trash', group: 'Tracker' },
{ id: 'plans', label: 'Plans', icon: '', group: 'Tracker' }, { id: 'plans', label: 'Plans', icon: 'plans', group: 'Tracker' },
{ id: 'settings', label: 'Settings', icon: '', group: 'Admin' }, { id: 'settings', label: 'Settings', icon: 'settings', group: 'Admin' },
]; ];
const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other']; const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
const fallbackModels = [ const fallbackModels = [
@ -26,6 +24,16 @@
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' }, { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
{ id: 'gpt-4o-mini', name: 'GPT-4o mini' }, { id: 'gpt-4o-mini', name: 'GPT-4o mini' },
]; ];
const defaultSettings = {
visionModel: fallbackModels[0].id,
taskModel: fallbackModels[0].id,
heightCm: '',
weightKg: '',
targetWeightKg: '',
calorieTarget: '',
activityLevel: 'Moderate',
pace: 'Steady',
};
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify'; let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
let username = 'admin'; let username = 'admin';
@ -52,7 +60,7 @@
let modelStatus = ''; let modelStatus = '';
let modelError = false; let modelError = false;
let modelSearching = false; let modelSearching = false;
let settings = { visionModel: fallbackModels[0].id, taskModel: fallbackModels[0].id, heightCm: '', weightKg: '', targetWeightKg: '', activityLevel: 'Moderate', pace: 'Steady' }; let settings = { ...defaultSettings };
let currentPassword = ''; let currentPassword = '';
let newPassword = ''; let newPassword = '';
let confirmPassword = ''; let confirmPassword = '';
@ -89,7 +97,6 @@
let selectedEntryIds = []; let selectedEntryIds = [];
let trash = []; let trash = [];
let macroCanvas;
let calorieCanvas; let calorieCanvas;
let produceCanvas; let produceCanvas;
@ -99,6 +106,8 @@
$: week = last7Days(); $: week = last7Days();
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length); $: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
$: produceToday = today.fruit + today.vegetables; $: produceToday = today.fruit + today.vegetables;
$: todayEntries = entries.filter(e => e.date === todayKey()).sort((a, b) => a.time.localeCompare(b.time));
$: macroTotal = Math.max(1, today.protein + today.carbs + today.fat);
$: diaryRows = entries $: diaryRows = entries
.filter(entry => !diaryType || entry.mealType === diaryType) .filter(entry => !diaryType || entry.mealType === diaryType)
.filter(entry => !diaryDate || entry.date === diaryDate) .filter(entry => !diaryDate || entry.date === diaryDate)
@ -111,15 +120,11 @@
$: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a)); $: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a));
onMount(async () => { onMount(async () => {
entries = readJson(entriesKey, []);
trash = readJson(trashKey, []);
settings = { ...settings, ...readJson(settingsKey, settings) };
await loadSession(); await loadSession();
if (location.pathname === '/verify' && resetToken) await verifyAccountToken(); if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard'; page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime(); setDefaultMealDateTime();
await loadModels(); await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadModels(), loadPlans()]);
await loadPlans();
await drawCharts(); await drawCharts();
}); });
@ -143,14 +148,6 @@
return dateKey(new Date()); return dateKey(new Date());
} }
function readJson(key, fallback) {
try { return JSON.parse(localStorage.getItem(key)) || fallback; } catch { return fallback; }
}
function saveJson(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
function setDefaultMealDateTime() { function setDefaultMealDateTime() {
const now = new Date(); const now = new Date();
mealDate = dateKey(now); mealDate = dateKey(now);
@ -264,8 +261,42 @@
verifyError = !response.ok; verifyError = !response.ok;
} }
async function loadSettingsFromServer() {
try {
const response = await fetch('/api/settings');
if (response.ok) settings = { ...defaultSettings, ...await response.json() };
} catch {}
}
async function saveSettings() {
try {
await fetch('/api/settings', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(settings),
});
} catch {}
}
async function loadEntriesFromServer() {
try {
const response = await fetch('/api/entries');
if (response.ok) applyEntriesData(await response.json());
} catch {}
}
function applyEntriesData(data) {
entries = data.entries || [];
trash = data.trash || [];
}
function applyPlanConfig(config) {
plans = config.plans || [];
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
}
async function loadModels() { async function loadModels() {
modelStatus = 'Loading models...'; modelStatus = '';
modelError = false; modelError = false;
try { try {
const response = await fetch('/api/models', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); const response = await fetch('/api/models', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
@ -273,10 +304,11 @@
const data = await response.json(); const data = await response.json();
models = data.models?.length ? data.models : fallbackModels; models = data.models?.length ? data.models : fallbackModels;
const defaultModel = data.defaultModel || models[0].id; const defaultModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = defaultModel; let changed = false;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = defaultModel; if (!models.some(model => model.id === settings.visionModel)) { settings.visionModel = defaultModel; changed = true; }
saveSettings(); if (!models.some(model => model.id === settings.taskModel)) { settings.taskModel = defaultModel; changed = true; }
modelStatus = `${models.length} saved model${models.length === 1 ? '' : 's'} loaded.`; if (changed) await saveSettings();
modelStatus = `${models.length} model${models.length === 1 ? '' : 's'} loaded.`;
} catch { } catch {
models = fallbackModels; models = fallbackModels;
modelStatus = 'Could not load saved models. Showing defaults.'; modelStatus = 'Could not load saved models. Showing defaults.';
@ -284,15 +316,6 @@
} }
} }
function saveSettings() {
saveJson(settingsKey, settings);
}
function applyPlanConfig(config) {
plans = config.plans || [];
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
}
async function loadPlans() { async function loadPlans() {
try { try {
const response = await fetch('/api/plans'); const response = await fetch('/api/plans');
@ -314,18 +337,12 @@
return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' }; return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' };
} }
function saveSelectedModels() { async function saveSelectedModels() {
saveSettings(); await saveSettings();
modelStatus = 'Models saved.'; modelStatus = 'Models saved.';
modelError = false; modelError = false;
} }
function selectModel(modelId, target = 'task') {
if (target === 'vision') settings.visionModel = modelId;
else settings.taskModel = modelId;
saveSelectedModels();
}
async function searchModels() { async function searchModels() {
if (modelSearch.trim().length < 2) { if (modelSearch.trim().length < 2) {
discoveredModels = []; discoveredModels = [];
@ -362,7 +379,7 @@
body: JSON.stringify(model), body: JSON.stringify(model),
}); });
if (!response.ok) { if (!response.ok) {
modelStatus = await response.text(); modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
modelError = true; modelError = true;
return; return;
} }
@ -370,7 +387,7 @@
models = data.models?.length ? data.models : models; models = data.models?.length ? data.models : models;
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id; settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
settings.taskModel = model.id; settings.taskModel = model.id;
saveSettings(); await saveSettings();
modelStatus = `Added ${model.name || model.id}.`; modelStatus = `Added ${model.name || model.id}.`;
modelError = false; modelError = false;
} }
@ -382,7 +399,7 @@
body: JSON.stringify({ id: modelId }), body: JSON.stringify({ id: modelId }),
}); });
if (!response.ok) { if (!response.ok) {
modelStatus = await response.text(); modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
modelError = true; modelError = true;
return; return;
} }
@ -390,7 +407,7 @@
models = data.models?.length ? data.models : fallbackModels; models = data.models?.length ? data.models : fallbackModels;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id; if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id; if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
saveSettings(); await saveSettings();
modelStatus = 'Model removed.'; modelStatus = 'Model removed.';
modelError = false; modelError = false;
} }
@ -565,16 +582,20 @@ Image estimate: ${imageEstimate}` },
imageName: selectedImageName, imageName: selectedImageName,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
entries = editingEntryId ? entries.map(entry => entry.id === editingEntryId ? { ...entry, ...meal } : entry) : [...entries, meal]; const response = await fetch('/api/entries', {
saveJson(entriesKey, entries); method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(meal),
});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Failed to save meal');
applyEntriesData(await response.json());
description = ''; description = '';
measure = ''; measure = '';
selectedImageDataUrl = ''; selectedImageDataUrl = '';
selectedImageName = ''; selectedImageName = '';
editingEntryId = ''; editingEntryId = '';
estimateDraft = emptyEstimate();
setDefaultMealDateTime(); setDefaultMealDateTime();
setMealStatus('Saved. You can edit it from Diary or run analysis again.'); setMealStatus(`Saved. ${estimate.mealName} · ${estimate.calories} kcal`);
} catch (error) { } catch (error) {
setMealStatus(error.message, true); setMealStatus(error.message, true);
} finally { } finally {
@ -599,10 +620,12 @@ Image estimate: ${imageEstimate}` },
selectPage('log'); selectPage('log');
} }
function saveMealEdits() { async function saveMealEdits() {
if (!editingEntryId) return; if (!editingEntryId) return;
entries = entries.map(entry => entry.id === editingEntryId ? { const existing = entries.find(e => e.id === editingEntryId);
...entry, if (!existing) return;
const updated = {
...existing,
...estimateDraft, ...estimateDraft,
date: mealDate, date: mealDate,
time: mealTime, time: mealTime,
@ -610,14 +633,24 @@ Image estimate: ${imageEstimate}` },
description, description,
measure, measure,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} : entry); };
saveJson(entriesKey, entries); try {
editingEntryId = ''; const response = await fetch('/api/entries', {
estimateDraft = emptyEstimate(); method: 'POST',
description = ''; headers: { 'content-type': 'application/json' },
measure = ''; body: JSON.stringify(updated),
setDefaultMealDateTime(); });
setMealStatus('Edits saved.'); if (!response.ok) throw new Error('Failed to save edits');
applyEntriesData(await response.json());
editingEntryId = '';
estimateDraft = emptyEstimate();
description = '';
measure = '';
setDefaultMealDateTime();
setMealStatus('Edits saved.');
} catch (error) {
setMealStatus(error.message, true);
}
} }
function cancelEdit() { function cancelEdit() {
@ -635,28 +668,35 @@ Image estimate: ${imageEstimate}` },
selectedEntryIds = selectedEntryIds.includes(id) ? selectedEntryIds.filter(item => item !== id) : [...selectedEntryIds, id]; selectedEntryIds = selectedEntryIds.includes(id) ? selectedEntryIds.filter(item => item !== id) : [...selectedEntryIds, id];
} }
function moveEntriesToTrash(ids) { async function moveEntriesToTrash(ids) {
const idSet = new Set(ids); const idList = Array.isArray(ids) ? ids : [ids];
const moving = entries.filter(entry => idSet.has(entry.id)).map(entry => ({ ...entry, trashedAt: new Date().toISOString() })); if (!idList.length) return;
if (!moving.length) return; try {
trash = [...moving, ...trash]; const response = await fetch('/api/entries/delete', {
entries = entries.filter(entry => !idSet.has(entry.id)); method: 'POST',
selectedEntryIds = selectedEntryIds.filter(id => !idSet.has(id)); headers: { 'content-type': 'application/json' },
saveJson(entriesKey, entries); body: JSON.stringify({ ids: idList }),
saveJson(trashKey, trash); });
if (!response.ok) return;
applyEntriesData(await response.json());
selectedEntryIds = selectedEntryIds.filter(id => !idList.includes(id));
} catch {}
} }
function moveDayToTrash(date) { function moveDayToTrash(date) {
moveEntriesToTrash(entries.filter(entry => entry.date === date).map(entry => entry.id)); moveEntriesToTrash(entries.filter(entry => entry.date === date).map(entry => entry.id));
} }
function restoreEntry(id) { async function restoreEntry(id) {
const found = trash.find(entry => entry.id === id); try {
if (!found) return; const response = await fetch('/api/entries/restore', {
entries = [{ ...found, trashedAt: undefined }, ...entries]; method: 'POST',
trash = trash.filter(entry => entry.id !== id); headers: { 'content-type': 'application/json' },
saveJson(entriesKey, entries); body: JSON.stringify({ id }),
saveJson(trashKey, trash); });
if (!response.ok) return;
applyEntriesData(await response.json());
} catch {}
} }
async function generatePlan() { async function generatePlan() {
@ -665,6 +705,7 @@ Image estimate: ${imageEstimate}` },
planError = false; planError = false;
try { try {
if (!settings.heightCm || !settings.weightKg) throw new Error('Enter height and current weight first.'); if (!settings.heightCm || !settings.weightKg) throw new Error('Enter height and current weight first.');
await saveSettings();
const content = await chatCompletion({ const content = await chatCompletion({
model: settings.taskModel, model: settings.taskModel,
temperature: 0.2, temperature: 0.2,
@ -676,7 +717,6 @@ Image estimate: ${imageEstimate}` },
const plan = { id: entryId(), title: `Weight-loss plan ${plans.length + 1}`, version: plans.length + 1, content, createdAt: new Date().toISOString() }; const plan = { id: entryId(), title: `Weight-loss plan ${plans.length + 1}`, version: plans.length + 1, content, createdAt: new Date().toISOString() };
await savePlan(plan); await savePlan(plan);
planStatus = 'Plan generated.'; planStatus = 'Plan generated.';
saveSettings();
} catch (error) { } catch (error) {
planStatus = error.message; planStatus = error.message;
planError = true; planError = true;
@ -739,67 +779,42 @@ Image estimate: ${imageEstimate}` },
async function drawCharts() { async function drawCharts() {
await tick(); await tick();
drawMacroChart();
drawBars(calorieCanvas, week, 'calories'); drawBars(calorieCanvas, week, 'calories');
drawBars(produceCanvas, week, 'produce'); drawBars(produceCanvas, week, 'produce');
} }
function drawMacroChart() {
if (!macroCanvas) return;
const ctx = macroCanvas.getContext('2d');
ctx.clearRect(0, 0, macroCanvas.width, macroCanvas.height);
const values = [today.protein, today.carbs, today.fat];
const colors = ['#2563eb', '#f59e0b', '#7c3aed'];
const labels = [`Protein ${today.protein}g`, `Carbs ${today.carbs}g`, `Fat ${today.fat}g`];
const sum = values.reduce((a, b) => a + b, 0) || 1;
let start = -Math.PI / 2;
values.forEach((value, index) => {
const arc = (value / sum) * Math.PI * 2;
ctx.beginPath();
ctx.lineWidth = 30;
ctx.strokeStyle = colors[index];
ctx.arc(120, 130, 70, start, start + arc);
ctx.stroke();
start += arc;
});
ctx.fillStyle = '#1f2937';
ctx.font = '700 20px Inter, sans-serif';
ctx.fillText('Today', 240, 88);
ctx.font = '15px Inter, sans-serif';
labels.forEach((label, index) => {
ctx.fillStyle = colors[index];
ctx.fillRect(240, 112 + index * 34, 14, 14);
ctx.fillStyle = '#1f2937';
ctx.fillText(label, 264, 125 + index * 34);
});
}
function drawBars(canvas, days, mode) { function drawBars(canvas, days, mode) {
if (!canvas) return; if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.offsetWidth || 400;
const cssH = canvas.offsetHeight || 200;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.scale(dpr, dpr);
const values = days.map(day => mode === 'calories' ? day.calories : day.fruit + day.vegetables); const values = days.map(day => mode === 'calories' ? day.calories : day.fruit + day.vegetables);
const max = Math.max(1, ...values); const max = Math.max(1, ...values);
const left = 26; const left = 4;
const bottom = canvas.height - 44; const bottom = cssH - 32;
const height = canvas.height - 76; const barHeight = cssH - 52;
const gap = 14; const gap = 8;
const bar = (canvas.width - left * 2 - gap * 6) / 7; const bar = (cssW - left * 2 - gap * 6) / 7;
ctx.textAlign = 'center';
days.forEach((day, index) => { days.forEach((day, index) => {
const x = left + index * (bar + gap); const x = left + index * (bar + gap);
const h = values[index] / max * height; const h = values[index] / max * barHeight;
ctx.fillStyle = '#e0e7ff'; ctx.fillStyle = '#e2e8f0';
roundRect(ctx, x, bottom - height, bar, height, 10); roundRect(ctx, x, bottom - barHeight, bar, barHeight, 6);
ctx.fillStyle = mode === 'calories' ? '#2563eb' : '#f59e0b'; ctx.fillStyle = mode === 'calories' ? '#2563eb' : '#f59e0b';
roundRect(ctx, x, bottom - h, bar, h, 10); roundRect(ctx, x, bottom - h, bar, h, 6);
if (mode === 'produce') { if (mode === 'produce' && day.vegetables > 0) {
const vegH = day.vegetables / max * height; const vegH = day.vegetables / max * barHeight;
ctx.fillStyle = '#10b981'; ctx.fillStyle = '#10b981';
roundRect(ctx, x + bar * .52, bottom - vegH, bar * .48, vegH, 8); roundRect(ctx, x + bar * 0.52, bottom - vegH, bar * 0.48, vegH, 5);
} }
ctx.fillStyle = '#6b7280'; ctx.fillStyle = '#64748b';
ctx.font = '13px Inter, sans-serif'; ctx.font = `${Math.max(10, Math.min(13, bar * 0.55))}px Inter, sans-serif`;
ctx.fillText(day.label, x, bottom + 24); ctx.fillText(day.label, x + bar / 2, bottom + 20);
}); });
} }
@ -865,55 +880,113 @@ Image estimate: ${imageEstimate}` },
</main> </main>
{:else} {:else}
<div class="min-h-screen bg-slate-50 text-slate-900 lg:grid lg:grid-cols-[auto_1fr]"> <div class="min-h-screen bg-slate-50 text-slate-900 lg:grid lg:grid-cols-[auto_1fr]">
<aside class:translate-x-0={menuOpen} class:collapsed-sidebar={sidebarCollapsed} class="sidebar-shell fixed inset-y-0 left-0 z-40 w-72 -translate-x-full overflow-hidden border-r border-slate-200 bg-white shadow-xl transition-all duration-200 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 lg:shadow-none lg:w-60"> <aside class:translate-x-0={menuOpen} class:collapsed-sidebar={sidebarCollapsed} class="sidebar-shell fixed inset-y-0 left-0 z-40 w-64 -translate-x-full overflow-hidden border-r border-slate-200 bg-white shadow-xl transition-all duration-200 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 lg:shadow-none lg:w-56">
<div class="sidebar-header flex h-[68px] items-center justify-between border-b border-slate-200 px-4"> <div class="sidebar-header flex h-16 items-center justify-between border-b border-slate-200 px-4">
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<div class="sidebar-logo grid h-11 w-11 flex-none place-items-center rounded-2xl bg-blue-600 text-sm font-black text-white">CA</div> <div class="sidebar-logo grid h-9 w-9 flex-none place-items-center rounded-xl bg-blue-600 text-xs font-black text-white">CA</div>
<div class="sidebar-brand min-w-0"> <div class="sidebar-brand min-w-0">
<h1 class="truncate text-lg font-black text-slate-950">Calorie AI</h1> <div class="truncate text-base font-black text-slate-950">Calorie AI</div>
<p class="text-xs font-semibold text-slate-500">Food diary</p> <div class="text-xs text-slate-500">Food diary</div>
</div>
</div> </div>
<button class="collapse-button hidden h-10 w-10 place-items-center rounded-xl bg-slate-100 text-slate-700 hover:bg-slate-200 lg:grid" type="button" on:click={() => sidebarCollapsed = !sidebarCollapsed} aria-label="Collapse menu"></button>
<button class="rounded-lg bg-slate-100 px-3 py-1.5 text-sm font-bold lg:hidden" type="button" on:click={() => menuOpen = false}>Close</button>
</div> </div>
<nav class="p-2" aria-label="Main menu"> <button class="collapse-button hidden h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-700 lg:grid" type="button" on:click={() => sidebarCollapsed = !sidebarCollapsed} aria-label="Collapse menu">
{#if trackerPages.length}<div class="sidebar-section-label px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Tracker</div>{/if} <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
{#each trackerPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}><span class="tab-icon">{item.icon}</span><span class="tab-label">{item.label}</span></button>{/each} </button>
{#if adminPages.length}<div class="sidebar-section-label px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Admin</div>{/if} <button class="rounded-md px-2.5 py-1 text-xs font-bold text-slate-600 hover:bg-slate-100 lg:hidden" type="button" on:click={() => menuOpen = false}>Close</button>
{#each adminPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}><span class="tab-icon">{item.icon}</span><span class="tab-label">{item.label}</span></button>{/each} </div>
</nav> <nav class="p-2" aria-label="Main menu">
{#if trackerPages.length}<div class="sidebar-section-label px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">Tracker</div>{/if}
{#each trackerPages as item}
<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}>
<span class="tab-icon"><Icon name={item.icon} /></span>
<span class="tab-label">{item.label}</span>
</button>
{/each}
{#if adminPages.length}<div class="sidebar-section-label px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">Admin</div>{/if}
{#each adminPages as item}
<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}>
<span class="tab-icon"><Icon name={item.icon} /></span>
<span class="tab-label">{item.label}</span>
</button>
{/each}
</nav>
</aside> </aside>
{#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if} {#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if}
<div class="min-w-0"> <div class="min-w-0">
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 shadow-sm backdrop-blur"> <header class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 shadow-sm backdrop-blur">
<div class="flex h-[68px] items-center justify-between gap-3 px-4 lg:px-6"> <div class="flex h-16 items-center justify-between gap-3 px-4 lg:px-6">
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<button class="grid h-10 w-10 place-items-center rounded-xl bg-blue-600 text-white hover:bg-blue-700 lg:hidden" type="button" on:click={() => menuOpen = true} aria-label="Open menu"></button> <button class="grid h-9 w-9 place-items-center rounded-xl bg-blue-600 text-white hover:bg-blue-700 lg:hidden" type="button" on:click={() => menuOpen = true} aria-label="Open menu">
<div class="min-w-0"> <svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>
<div class="truncate text-lg font-black text-slate-950">{pages.find(item => item.id === page)?.label || 'Dashboard'}</div> </button>
</div> <div class="truncate text-base font-bold text-slate-900">{pages.find(item => item.id === page)?.label || 'Dashboard'}</div>
</div> </div>
<button class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-bold text-slate-700 hover:border-blue-300 hover:text-blue-700" type="button" on:click={logout}>Sign out</button> <button class="rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-semibold text-slate-600 hover:border-slate-300 hover:text-slate-900" type="button" on:click={logout}>Sign out</button>
</div> </div>
</header> </header>
<main class="min-w-0 flex-1 p-4 lg:p-6"> <main class="min-w-0 flex-1 p-4 lg:p-6">
{#if page === 'dashboard'} {#if page === 'dashboard'}
<section class="space-y-4"> <section class="space-y-4">
<div><h2 class="text-xl font-bold">Dashboard</h2><p class="text-sm text-slate-500">Todays totals and recent trends.</p></div> <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="grid gap-3 xl:grid-cols-[1.4fr_0.6fr_0.6fr_0.8fr]"> <article class="sm:col-span-2 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-5 text-white shadow-sm">
<article class="rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-6 text-white shadow-sm"><div class="text-xs font-bold uppercase tracking-wide text-blue-100">Today</div><div id="todayCalories" class="mt-2 text-5xl font-black">{today.calories} kcal</div><div id="todayMacroText" class="mt-3">P {today.protein}g | C {today.carbs}g | F {today.fat}g</div><div id="todayProduceText" class="text-sm text-blue-100">Fruit {today.fruit.toFixed(1)} | Veg {today.vegetables.toFixed(1)}</div></article> <div class="text-xs font-semibold uppercase tracking-widest text-blue-200">Today</div>
<Stat label="Meals" value={today.meals} note="Today" /> <div id="todayCalories" class="mt-1 text-4xl font-black tracking-tight">{today.calories} kcal</div>
<Stat label="7-day avg" value={`${weekAverage} kcal`} note="Calories/day" /> <div id="todayMacroText" class="mt-2 text-sm font-medium text-blue-100">P {today.protein}g &nbsp;·&nbsp; C {today.carbs}g &nbsp;·&nbsp; F {today.fat}g</div>
<Stat label="Produce" value={`${produceToday.toFixed(1)} / 5`} note="Fruit + vegetables" /> <div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} &nbsp;·&nbsp; Veg {today.vegetables.toFixed(1)}</div>
</article>
<Stat label="Meals today" value={today.meals} note={today.meals === 1 ? 'meal logged' : 'meals logged'} />
<Stat label="7-day average" value={`${weekAverage} kcal`} note="Calories per day" />
</div> </div>
<div class="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
<section class="card"><h3 class="card-title">Quick actions</h3><div class="grid gap-2 p-4"><button class="btn-primary" type="button" on:click={() => selectPage('log')}>Log a meal</button><button class="btn-secondary" type="button" on:click={() => selectPage('diary')}>Review diary</button><button class="btn-secondary" type="button" on:click={() => selectPage('plans')}>Plans</button><button class="btn-secondary" type="button" on:click={() => selectPage('settings')}>Settings</button></div></section> <div class="grid gap-4 lg:grid-cols-2">
<section class="card"><h3 class="card-title">Macros today</h3><div class="p-4"><canvas id="macroChart" bind:this={macroCanvas} width="520" height="260"></canvas></div></section> <section class="card">
<h3 class="card-title">Macros today</h3>
<div class="space-y-3 p-4">
{#each [['Protein', today.protein, '#2563eb', 'bg-blue-500'], ['Carbs', today.carbs, '#f59e0b', 'bg-amber-400'], ['Fat', today.fat, '#7c3aed', 'bg-violet-500']] as [label, val, , cls]}
<div>
<div class="mb-1.5 flex justify-between text-sm">
<span class="font-semibold text-slate-700">{label}</span>
<span class="text-slate-500">{val}g &nbsp;·&nbsp; {Math.round(val / macroTotal * 100)}%</span>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full transition-all {cls}" style="width:{Math.round(val / macroTotal * 100)}%"></div>
</div>
</div>
{/each}
<div class="border-t border-slate-100 pt-2 text-xs text-slate-400">{today.calories} kcal total &nbsp;·&nbsp; produce {produceToday.toFixed(1)} / 5</div>
</div>
</section>
<section class="card">
<h3 class="card-title">Today's meals</h3>
<div class="divide-y divide-slate-100">
{#each todayEntries as entry}
<div class="flex items-center justify-between px-4 py-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-slate-800">{entry.mealName || entry.description}</div>
<div class="text-xs text-slate-400">{entry.mealType} · {entry.time}</div>
</div>
<div class="ml-3 shrink-0 text-sm font-bold text-slate-600">{entry.calories} kcal</div>
</div>
{:else}
<div class="px-4 py-8 text-center text-sm text-slate-400">No meals logged today</div>
{/each}
</div>
{#if todayEntries.length}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>+ Log another meal</button>
</div>
{:else}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>Log your first meal today</button>
</div>
{/if}
</section>
</div> </div>
</section> </section>
{:else if page === 'log'} {:else if page === 'log'}
<MealForm <MealForm
{mealTypes} {mealTypes}
@ -933,8 +1006,39 @@ Image estimate: ${imageEstimate}` },
{saveMealEdits} {saveMealEdits}
{cancelEdit} {cancelEdit}
/> />
{:else if page === 'analytics'} {:else if page === 'analytics'}
<section class="space-y-4"><div><h2 class="text-xl font-bold">Analytics</h2><p class="text-sm text-slate-500">Seven-day calorie and produce trends.</p></div><div class="grid gap-4 xl:grid-cols-2"><section class="card"><h3 class="card-title">Calories, last 7 days</h3><div class="p-4"><canvas id="calorieChart" bind:this={calorieCanvas} width="720" height="260"></canvas></div></section><section class="card"><h3 class="card-title">Fruit and vegetables</h3><div class="p-4"><canvas id="produceChart" bind:this={produceCanvas} width="720" height="240"></canvas></div></section></div></section> <section class="space-y-4">
<div>
<h2 class="text-xl font-bold">Analytics</h2>
<p class="text-sm text-slate-500">Seven-day calorie and produce trends.</p>
</div>
<div class="grid gap-4 xl:grid-cols-2">
<section class="card">
<h3 class="card-title">Calories — last 7 days</h3>
<div class="p-4" style="height:220px">
<canvas id="calorieChart" bind:this={calorieCanvas} class="w-full h-full"></canvas>
</div>
</section>
<section class="card">
<h3 class="card-title">Fruit and vegetables</h3>
<div class="p-4" style="height:220px">
<canvas id="produceChart" bind:this={produceCanvas} class="w-full h-full"></canvas>
</div>
</section>
</div>
<div class="grid gap-3 sm:grid-cols-3">
{#each week.slice().reverse().slice(0, 3) as day}
<article class="card p-4">
<div class="text-xs font-semibold text-slate-400">{day.date}</div>
<div class="mt-1 text-xl font-black text-slate-800">{day.calories} kcal</div>
<div class="text-xs text-slate-500">P {day.protein}g · C {day.carbs}g · F {day.fat}g</div>
<div class="text-xs text-slate-400">Fruit {day.fruit.toFixed(1)} · Veg {day.vegetables.toFixed(1)}</div>
</article>
{/each}
</div>
</section>
{:else if page === 'diary'} {:else if page === 'diary'}
<DiaryPage <DiaryPage
{mealTypes} {mealTypes}
@ -950,6 +1054,7 @@ Image estimate: ${imageEstimate}` },
{moveEntriesToTrash} {moveEntriesToTrash}
{restoreEntry} {restoreEntry}
/> />
{:else if page === 'trash'} {:else if page === 'trash'}
<DiaryPage <DiaryPage
{mealTypes} {mealTypes}
@ -966,6 +1071,7 @@ Image estimate: ${imageEstimate}` },
{restoreEntry} {restoreEntry}
trashMode={true} trashMode={true}
/> />
{:else if page === 'plans'} {:else if page === 'plans'}
<PlansPage <PlansPage
bind:settings bind:settings
@ -980,6 +1086,7 @@ Image estimate: ${imageEstimate}` },
{selectPlan} {selectPlan}
{deletePlan} {deletePlan}
/> />
{:else if page === 'settings'} {:else if page === 'settings'}
<SettingsPage <SettingsPage
bind:settings bind:settings
@ -1016,13 +1123,13 @@ Image estimate: ${imageEstimate}` },
{/if} {/if}
<style> <style>
.tab-button { display: flex; width: 100%; align-items: center; gap: 0.65rem; border-left: 3px solid transparent; border-radius: 0.55rem; padding: 0.65rem 1rem; text-align: left; font-size: 0.875rem; font-weight: 600; color: #475569; } .tab-button { display: flex; width: 100%; align-items: center; gap: 0.6rem; border-left: 3px solid transparent; border-radius: 0.5rem; padding: 0.55rem 0.85rem; text-align: left; font-size: 0.875rem; font-weight: 600; color: #64748b; transition: background 0.1s, color 0.1s; }
.tab-button:hover { background: #f8fafc; color: #0f172a; } .tab-button:hover { background: #f8fafc; color: #0f172a; }
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; } .active-tab { border-left-color: #2563eb; background: #eff6ff; color: #2563eb; }
.tab-icon { display: grid; width: 1.35rem; height: 1.35rem; flex: 0 0 1.35rem; place-items: center; font-weight: 900; } .tab-icon { display: flex; width: 1.25rem; height: 1.25rem; flex: 0 0 1.25rem; align-items: center; justify-content: center; }
:global(.collapsed-sidebar) { width: 4.75rem !important; } :global(.collapsed-sidebar) { width: 4.25rem !important; }
:global(.collapsed-sidebar .sidebar-header) { justify-content: center; padding-left: 0; padding-right: 0; } :global(.collapsed-sidebar .sidebar-header) { justify-content: center; padding-left: 0; padding-right: 0; }
:global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label), :global(.collapsed-sidebar .sidebar-brand), :global(.collapsed-sidebar .sidebar-logo) { display: none; } :global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label), :global(.collapsed-sidebar .sidebar-brand) { display: none; }
:global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0.75rem; padding-right: 0.75rem; } :global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0; padding-right: 0; }
:global(.collapsed-sidebar .collapse-button) { height: 2.5rem; width: 2.5rem; border-radius: 0.75rem; } :global(.collapsed-sidebar .collapse-button) { display: grid; }
</style> </style>

View file

@ -22,22 +22,29 @@
<section class="space-y-4"> <section class="space-y-4">
<div> <div>
<h2 class="text-xl font-bold">{trashMode ? 'Trash' : 'Diary'}</h2> <h2 class="text-xl font-bold">{trashMode ? 'Trash' : 'Diary'}</h2>
<p class="text-sm text-slate-500">{trashMode ? 'Restore meals moved out of the diary.' : 'Search, edit, select, or move meals to trash.'}</p> <p class="text-sm text-slate-500">{trashMode ? 'Restore meals moved out of the diary.' : 'Search, filter, edit, or remove meals.'}</p>
</div> </div>
{#if trashMode} {#if trashMode}
<div id="trashEntries" class="grid gap-3"> <div id="trashEntries" class="grid gap-2">
{#if trash.length} {#if trash.length}
{#each trash as entry} {#each trash as entry}
<article class="entry card p-4"> <article class="entry card p-4">
<div class="flex flex-col justify-between gap-2 md:flex-row"> <div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
<div><strong>{entry.mealName}</strong><div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div></div> <div class="min-w-0">
<button class="btn-secondary" type="button" on:click={() => restoreEntry(entry.id)}>Restore</button> <div class="font-semibold text-slate-800">{entry.mealName || entry.description}</div>
<div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div>
<div class="text-sm text-slate-400">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · {entry.calories} kcal</div>
</div>
<button class="btn-secondary shrink-0" type="button" on:click={() => restoreEntry(entry.id)}>Restore</button>
</div> </div>
</article> </article>
{/each} {/each}
{:else}<div class="empty-state">Trash is empty.</div>{/if} {:else}
<div class="empty-state">Trash is empty.</div>
{/if}
</div> </div>
{:else} {:else}
<section class="card"> <section class="card">
<div class="grid gap-3 p-4 md:grid-cols-[1fr_180px_170px]"> <div class="grid gap-3 p-4 md:grid-cols-[1fr_180px_170px]">
@ -47,10 +54,10 @@
</div> </div>
{#if selectedEntryIds.length} {#if selectedEntryIds.length}
<div class="flex flex-col gap-2 border-t border-slate-200 bg-blue-50 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-2 border-t border-slate-200 bg-blue-50 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm font-bold text-blue-900">{selectedEntryIds.length} selected</div> <div class="text-sm font-semibold text-blue-900">{selectedEntryIds.length} selected</div>
<div class="flex flex-col gap-2 sm:flex-row"> <div class="flex flex-col gap-2 sm:flex-row">
{#if oneSelected}<button class="btn-secondary" type="button" on:click={() => editEntry(selectedRows[0])}>Edit selected</button>{/if} {#if oneSelected}<button class="btn-secondary" type="button" on:click={() => editEntry(selectedRows[0])}>Edit selected</button>{/if}
<button class="rounded-lg bg-red-600 px-4 py-2 text-sm font-black text-white hover:bg-red-700" type="button" on:click={() => moveEntriesToTrash(selectedEntryIds)}>Move selected to trash</button> <button class="rounded-lg bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700" type="button" on:click={() => moveEntriesToTrash(selectedEntryIds)}>Move selected to trash</button>
</div> </div>
</div> </div>
{/if} {/if}
@ -60,27 +67,40 @@
{#if diaryRows.length} {#if diaryRows.length}
{#each diaryDays as day} {#each diaryDays as day}
<section class="card"> <section class="card">
<div class="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-2"> <div class="flex items-center justify-between border-b border-slate-100 bg-slate-50 px-4 py-2.5">
<h3 class="font-bold">{day}</h3> <h3 class="text-sm font-bold text-slate-700">{day}</h3>
<button class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-bold text-slate-600 hover:border-blue-300 hover:text-blue-700" type="button" on:click={() => diaryDate = day}>Filter day</button> <button class="rounded text-xs font-semibold text-slate-400 hover:text-blue-600" type="button" on:click={() => diaryDate = day}>Filter day</button>
</div> </div>
<div class="grid gap-2 p-3"> <div class="divide-y divide-slate-100">
{#each diaryRows.filter(entry => entry.date === day) as entry} {#each diaryRows.filter(entry => entry.date === day) as entry}
<article class="entry rounded-xl border border-slate-200 p-3"> <article class="entry px-4 py-3">
<div class="flex flex-col justify-between gap-2 md:flex-row"> <div class="flex items-start gap-3">
<label class="flex min-w-0 gap-3"><input type="checkbox" checked={selectedEntryIds.includes(entry.id)} on:change={() => toggleSelected(entry.id)}><span class="min-w-0"><strong>{entry.mealName}</strong><div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div></span></label> <input type="checkbox" class="mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 accent-blue-600" checked={selectedEntryIds.includes(entry.id)} on:change={() => toggleSelected(entry.id)}>
<div class="font-bold">{entry.calories} kcal</div> <div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<span class="font-semibold text-slate-800">{entry.mealName || entry.description}</span>
<span class="ml-2 text-sm font-bold text-slate-600">{entry.calories} kcal</span>
</div>
<div class="flex shrink-0 gap-1">
<button class="rounded px-2 py-0.5 text-xs font-semibold text-blue-600 hover:bg-blue-50" type="button" on:click={() => editEntry(entry)}>Edit</button>
<button class="rounded px-2 py-0.5 text-xs font-semibold text-red-500 hover:bg-red-50" type="button" on:click={() => moveEntriesToTrash([entry.id])}>Delete</button>
</div>
</div>
<div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div>
<div class="mt-0.5 text-xs text-slate-400">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · Fruit {Number(entry.fruitServings || 0).toFixed(1)} · Veg {Number(entry.vegetableServings || 0).toFixed(1)}</div>
{#if entry.description}<div class="mt-1 truncate text-xs text-slate-400">{entry.description}</div>{/if}
{#if entry.notes}<div class="text-xs text-slate-400">{entry.notes}</div>{/if}
</div>
</div> </div>
<div class="mt-2 text-sm">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · Fruit {Number(entry.fruitServings || 0).toFixed(1)} · Vegetables {Number(entry.vegetableServings || 0).toFixed(1)}</div>
{#if entry.description}<div class="mt-2 text-sm text-slate-500">{entry.description}</div>{/if}
{#if entry.foodGroups}<div class="text-sm text-slate-500">Groups: {entry.foodGroups}</div>{/if}
{#if entry.notes}<div class="text-sm text-slate-500">{entry.notes}</div>{/if}
</article> </article>
{/each} {/each}
</div> </div>
</section> </section>
{/each} {/each}
{:else}<div class="empty-state">No meals match the current filters.</div>{/if} {:else}
<div class="empty-state">No meals match the current filters.</div>
{/if}
</div> </div>
{/if} {/if}
</section> </section>

22
web/src/Icon.svelte Normal file
View file

@ -0,0 +1,22 @@
<script>
export let name;
export let size = 18;
</script>
<svg width={size} height={size} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" style="flex-shrink:0">
{#if name === 'dashboard'}
<path d="M2 3a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm10 0a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1V3zm0 8a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1h-4a1 1 0 01-1-1v-6zM2 12a1 1 0 011-1h6a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5z"/>
{:else if name === 'plus'}
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
{:else if name === 'analytics'}
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
{:else if name === 'diary'}
<path fill-rule="evenodd" d="M3 4a1 1 0 000 2h14a1 1 0 100-2H3zm0 4a1 1 0 000 2h14a1 1 0 100-2H3zm0 4a1 1 0 000 2h10a1 1 0 100-2H3z" clip-rule="evenodd"/>
{:else if name === 'trash'}
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm4 0a1 1 0 112 0v4a1 1 0 11-2 0V8z" clip-rule="evenodd"/>
{:else if name === 'plans'}
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h4a1 1 0 100-2H7z" clip-rule="evenodd"/>
{:else if name === 'settings'}
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
{/if}
</svg>

View file

@ -14,19 +14,35 @@
export let deletePlan; export let deletePlan;
$: selectedPlan = plans.find(plan => plan.id === selectedPlanId) || plans[0]; $: selectedPlan = plans.find(plan => plan.id === selectedPlanId) || plans[0];
let faqOpen = null;
const faqs = [
{
q: 'How does the AI build my plan?',
a: 'It reads your height, weight, target, activity level, and pace, then looks at your most recent logged meals to understand your actual eating patterns. The result is a calorie range, protein target, and practical habits tailored to you.',
},
{
q: 'How often should I update my plan?',
a: 'Update it whenever something changes — a new weigh-in, a shift in activity, or after a few weeks of logging. Use the fine-tune box to tell the AI what changed and it will revise the plan without starting from scratch.',
},
{
q: 'Is this medical or clinical advice?',
a: 'No. This is an AI-generated nutrition estimate based on self-reported data. It is not a substitute for advice from a registered dietitian or doctor. If you have a medical condition, are pregnant, or have a history of disordered eating, consult a qualified health professional first.',
},
{
q: 'Are my fine-tune notes stored?',
a: 'No. Notes you add in the fine-tune box are sent to the AI to update the plan and then discarded. Only the resulting plan text is saved.',
},
{
q: 'Can I go back to an older plan?',
a: 'Yes. Every version is kept in Plan history. Click any entry there to make it the active plan and it will appear in the Current plan panel.',
},
];
const knownHeadings = new Set([ const knownHeadings = new Set([
'plan', 'plan', 'personalized weight-loss plan', 'your details', 'daily calorie range',
'personalized weight-loss plan', 'protein target', 'healthy habits', 'weekly review steps', 'safety notes',
'your details', 'getting started', 'targets', 'habits',
'daily calorie range',
'protein target',
'healthy habits',
'weekly review steps',
'safety notes',
'getting started',
'targets',
'habits',
]); ]);
function cleanLine(line) { function cleanLine(line) {
@ -61,7 +77,7 @@
<section class="space-y-4"> <section class="space-y-4">
<div> <div>
<h2 class="text-xl font-bold">Plans</h2> <h2 class="text-xl font-bold">Plans</h2>
<p class="text-sm text-slate-500">Generate and fine-tune weight-loss plans from your profile, meals, and activities. Notes are used only for the update and are not stored.</p> <p class="text-sm text-slate-500">Your personal weight-loss plan, updated as you log meals and progress. Powered by AI, built from your profile.</p>
</div> </div>
<div class="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]"> <div class="grid gap-4 xl:grid-cols-[0.8fr_1.2fr]">
@ -73,7 +89,10 @@
<Field label="Target weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.targetWeightKg}></Field> <Field label="Target weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.targetWeightKg}></Field>
<Field label="Activity"><select class="input" bind:value={settings.activityLevel}><option>Low</option><option>Moderate</option><option>High</option></select></Field> <Field label="Activity"><select class="input" bind:value={settings.activityLevel}><option>Low</option><option>Moderate</option><option>High</option></select></Field>
<Field label="Pace"><select class="input" bind:value={settings.pace}><option>Gentle</option><option>Steady</option><option>Aggressive</option></select></Field> <Field label="Pace"><select class="input" bind:value={settings.pace}><option>Gentle</option><option>Steady</option><option>Aggressive</option></select></Field>
<button class="btn-primary" type="button" on:click={generatePlan} disabled={planLoading}>{planLoading ? 'Generating...' : 'Generate new plan'}</button> {#if !settings.heightCm || !settings.weightKg}
<p class="text-xs text-amber-600 font-medium">Fill in height and current weight to generate a plan.</p>
{/if}
<button class="btn-primary" type="button" on:click={generatePlan} disabled={planLoading || !settings.heightCm || !settings.weightKg}>{planLoading ? 'Generating...' : 'Generate new plan'}</button>
{#if planStatus}<p class:text-red-600={planError} class="text-sm font-semibold text-green-700">{planStatus}</p>{/if} {#if planStatus}<p class:text-red-600={planError} class="text-sm font-semibold text-green-700">{planStatus}</p>{/if}
</div> </div>
</section> </section>
@ -100,29 +119,51 @@
{/each} {/each}
</div> </div>
<div class="border-t border-slate-200 pt-4"> <div class="border-t border-slate-200 pt-4">
<Field label="Fine-tune with meals, activities, or progress notes"><textarea class="input min-h-28" bind:value={tuneNote} placeholder="Example: I walked 7,000 steps daily this week, felt hungry at night, and ate two higher-calorie dinners."></textarea></Field> <Field label="Fine-tune with meals, activities, or progress notes">
<button class="btn-primary mt-3" type="button" on:click={() => tunePlan(selectedPlan.id)} disabled={planLoading}>{planLoading ? 'Updating...' : 'Update plan with AI'}</button> <textarea class="input min-h-28" bind:value={tuneNote} placeholder="Example: walked 7,000 steps daily this week, felt hungry at night, lost 0.3 kg"></textarea>
</Field>
<p class="mt-1 text-xs text-slate-400">These notes are sent to the AI to update your plan and are not stored afterwards.</p>
<button class="btn-primary mt-3" type="button" on:click={() => tunePlan(selectedPlan.id)} disabled={planLoading || !tuneNote?.trim()}>{planLoading ? 'Updating...' : 'Update plan with AI'}</button>
</div> </div>
{:else} {:else}
<div class="empty-state">No plans yet. Enter your profile and generate your first plan.</div> <div class="empty-state">No plans yet. Fill in your profile and click Generate.</div>
{/if} {/if}
</div> </div>
</section> </section>
</div> </div>
{#if plans.length > 1}
<section class="card"> <section class="card">
<h3 class="card-title">Plan history</h3> <h3 class="card-title">Plan history</h3>
<div class="grid gap-2 p-4 md:grid-cols-2 xl:grid-cols-3"> <div class="grid gap-2 p-4 md:grid-cols-2 xl:grid-cols-3">
{#if plans.length} {#each plans as plan}
{#each plans as plan} <button class="rounded-xl border px-3 py-2 text-left text-sm hover:border-blue-300 hover:bg-blue-50" class:border-blue-500={plan.id === selectedPlanId} type="button" on:click={() => selectPlan(plan.id)}>
<button class="rounded-xl border px-3 py-2 text-left text-sm hover:border-blue-300 hover:bg-blue-50" class:border-blue-500={plan.id === selectedPlanId} type="button" on:click={() => selectPlan(plan.id)}> <strong>{plan.title}</strong>
<strong>{plan.title}</strong> <div class="text-xs text-slate-500">Version {plan.version || 1} · {new Date(plan.createdAt).toLocaleString()}</div>
<div class="text-xs text-slate-500">Version {plan.version || 1} · {new Date(plan.createdAt).toLocaleString()}</div> </button>
{/each}
</div>
</section>
{/if}
<section class="card">
<h3 class="card-title">Frequently asked questions</h3>
<div class="divide-y divide-slate-100">
{#each faqs as faq, i}
<div>
<button
class="flex w-full items-center justify-between gap-4 px-4 py-3 text-left text-sm font-semibold text-slate-800 hover:bg-slate-50"
type="button"
on:click={() => faqOpen = faqOpen === i ? null : i}
>
<span>{faq.q}</span>
<svg class="h-4 w-4 flex-none text-slate-400 transition-transform {faqOpen === i ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
</button> </button>
{/each} {#if faqOpen === i}
{:else} <p class="px-4 pb-4 text-sm leading-6 text-slate-600">{faq.a}</p>
<p class="text-sm text-slate-500">Old plans will appear here and remain available after updates.</p> {/if}
{/if} </div>
{/each}
</div> </div>
</section> </section>
</section> </section>

View file

@ -74,9 +74,55 @@ async function mockPlans(page) {
}); });
} }
async function mockEntries(page) {
let entries = [];
let trash = [];
await page.route('**/api/entries', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) });
}
const entry = route.request().postDataJSON();
const idx = entries.findIndex(e => e.id === entry.id);
if (idx >= 0) entries[idx] = { ...entries[idx], ...entry };
else entries = [...entries, entry];
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) });
});
await page.route('**/api/entries/delete', async (route) => {
const { ids } = route.request().postDataJSON();
const idSet = new Set(ids);
const moving = entries.filter(e => idSet.has(e.id)).map(e => ({ ...e, trashedAt: new Date().toISOString() }));
entries = entries.filter(e => !idSet.has(e.id));
trash = [...moving, ...trash];
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) });
});
await page.route('**/api/entries/restore', async (route) => {
const { id } = route.request().postDataJSON();
const found = trash.find(e => e.id === id);
if (found) {
const { trashedAt, ...restored } = found;
entries = [restored, ...entries];
trash = trash.filter(e => e.id !== id);
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ entries, trash }) });
});
}
async function mockSettings(page) {
let settings = {};
await page.route('**/api/settings', async (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(settings) });
}
settings = { ...settings, ...route.request().postDataJSON() };
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(settings) });
});
}
async function login(page) { async function login(page) {
await mockModels(page); await mockModels(page);
await mockPlans(page); await mockPlans(page);
await mockEntries(page);
await mockSettings(page);
await page.goto('/'); await page.goto('/');
await expect(page).toHaveURL(/\/login$/); await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();