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
with:
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: |
gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug
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
env:

View file

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

View file

@ -5,10 +5,13 @@
<application
android:allowBackup="true"
android:label="Calorie AI"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<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.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import com.danvics.calorieai.ai.AiClient
import com.danvics.calorieai.ai.ImagePayload
import com.danvics.calorieai.ai.NutritionParser
import com.danvics.calorieai.data.*
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.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@ -23,91 +27,285 @@ import java.util.UUID
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val repo = AppRepository(getSharedPreferences("calorie_ai", MODE_PRIVATE))
val ai = AiClient()
setContent { AppRoot(repo, ai) }
val repo = ApiRepository(getSharedPreferences("calorie_ai", MODE_PRIVATE))
setContent { AppRoot(repo) }
}
}
@Composable
private fun AppRoot(repo: AppRepository, ai: AiClient) {
private fun AppRoot(repo: ApiRepository) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var entries by remember { mutableStateOf(repo.entries()) }
var trash by remember { mutableStateOf(repo.trash()) }
var settings by remember { mutableStateOf(repo.settings()) }
val parser = remember { NutritionParser() }
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 status by remember { mutableStateOf("") }
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? ->
if (uri != null) selectedImage = context.contentResolver.openInputStream(uri)?.use { input ->
val bytes = input.readBytes()
ImagePayload.fromBytes(uri.lastPathSegment ?: "meal image", context.contentResolver.getType(uri) ?: "image/jpeg", bytes)
if (uri != null) selectedImage = context.contentResolver.openInputStream(uri)?.use { stream ->
ImagePayload.fromBytes(uri.lastPathSegment ?: "image", context.contentResolver.getType(uri) ?: "image/jpeg", stream.readBytes())
}
}
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 persistTrash(next: List<MealEntry>) { trash = next; repo.saveTrash(next) }
fun persistSettings(next: AppSettings) { settings = next; repo.saveSettings(next) }
fun handleUnauth() { connected = false; appState = AppState() }
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(
entries = entries,
trash = trash,
settings = settings,
appState = appState,
serverUrl = repo.localConfig().serverUrl,
status = status,
busy = busy,
editing = editing,
selectedImageName = selectedImage?.name.orEmpty(),
planStatus = planStatus,
planBusy = planBusy,
syncing = syncing,
onSync = { syncState() },
onPickImage = { imagePicker.launch("image/*") },
onTakePhoto = { camera.launch(null) },
onCancelEdit = { editing = null; status = "" },
onCancelEdit = { editing = null; selectedImage = null; status = "" },
onSaveManualEdit = { updated ->
persistEntries(entries.map { if (it.id == updated.id) updated else it })
editing = null
status = "Meal updated."
scope.launch(Dispatchers.IO) {
runCatching { repo.upsertEntry(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 ->
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
status = "Analyzing meal..."
scope.launch {
scope.launch(Dispatchers.IO) {
runCatching {
withContext(Dispatchers.IO) { ai.estimateMeal(settings, draft.description, draft.measure, selectedImage) }
}.onSuccess { estimate ->
val visionEstimate = if (selectedImage != null && appState.settings.visionModel.isNotBlank()) {
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(
id = editing?.id ?: UUID.randomUUID().toString(),
date = draft.date.ifBlank { LocalDate.now().toString() },
time = draft.time.ifBlank { LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) },
imageIncluded = selectedImage != null,
imageName = selectedImage?.name.orEmpty(),
visionEstimate = visionEstimate,
estimate = estimate
)
persistEntries(if (editing == null) entries + meal else entries.map { if (it.id == meal.id) meal else it })
editing = meal
status = "Saved. You can edit values or analyze again."
}.onFailure { status = "Analysis failed: ${it.message}" }
busy = false
repo.upsertEntry(meal) to meal
}.onSuccess { (pair, meal) ->
val (entries, trash) = pair
withContext(Dispatchers.Main) {
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 },
onMoveToTrash = { ids ->
val moving = entries.filter { it.id in ids }.map { it.copy(trashedAt = LocalDate.now().toString()) }
persistEntries(entries.filterNot { it.id in ids })
persistTrash(moving + trash)
},
onRestore = { id ->
trash.find { it.id == id }?.let { found ->
persistTrash(trash.filterNot { it.id == id })
persistEntries(listOf(found.copy(trashedAt = "")) + entries)
scope.launch(Dispatchers.IO) {
runCatching { repo.deleteEntries(ids.toList()) }
.onSuccess { (entries, trash) -> withContext(Dispatchers.Main) { appState = appState.copy(entries = entries, trash = trash) } }
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
}
},
onSettingsChange = ::persistSettings,
onSearchModels = { query -> withContext(Dispatchers.IO) { ai.searchModels(settings, query) } },
onGeneratePlan = { withContext(Dispatchers.IO) { ai.weightPlan(settings) } }
onClearTrash = {
scope.launch(Dispatchers.IO) {
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
import android.util.Base64
import android.graphics.Bitmap
import com.danvics.calorieai.data.AppSettings
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 android.util.Base64
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) {
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 org.json.JSONArray
import org.json.JSONObject
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
class AppRepository(private val prefs: SharedPreferences) {
fun entries(): List<MealEntry> = readEntries("entries")
fun trash(): List<MealEntry> = readEntries("trash")
class UnauthorizedException : Exception("Session expired. Please reconnect.")
fun saveEntries(entries: List<MealEntry>) = writeEntries("entries", entries)
fun saveTrash(entries: List<MealEntry>) = writeEntries("trash", entries)
class ApiRepository(private val prefs: SharedPreferences) {
private var config = loadConfig()
fun settings(): AppSettings = AppSettings(
apiBaseUrl = prefs.getString("api_base_url", "") ?: "",
apiKey = prefs.getString("api_key", "") ?: "",
visionModel = prefs.getString("vision_model", "gpt-4o-mini") ?: "gpt-4o-mini",
nutritionModel = prefs.getString("nutrition_model", prefs.getString("task_model", "gpt-4o-mini")) ?: "gpt-4o-mini",
heightCm = prefs.getString("height_cm", "") ?: "",
weightKg = prefs.getString("weight_kg", "") ?: "",
targetWeightKg = prefs.getString("target_weight_kg", "") ?: "",
activityLevel = prefs.getString("activity_level", "Moderate") ?: "Moderate",
pace = prefs.getString("pace", "Steady") ?: "Steady",
models = readModels()
fun localConfig() = config
fun isConnected() = config.isConnected()
fun login(serverUrl: String, username: String, password: String): String {
val base = serverUrl.trimEnd('/')
val body = JSONObject().put("username", username).put("password", password).toString()
val conn = openConn("$base/api/login", "POST", null).apply {
doOutput = true
outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
}
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) {
prefs.edit()
.putString("api_base_url", settings.apiBaseUrl)
.putString("api_key", settings.apiKey)
.putString("vision_model", settings.visionModel)
.putString("nutrition_model", settings.nutritionModel)
.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 parseEntries(arr: JSONArray?): List<MealEntry> =
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toMealEntry() }
fun clearTrash(): Pair<List<MealEntry>, List<MealEntry>> {
val root = JSONObject(post("/api/entries/clear-trash", "{}"))
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
}
private fun readEntries(key: String): List<MealEntry> = runCatching {
val array = JSONArray(prefs.getString(key, "[]"))
(0 until array.length()).mapNotNull { index -> array.optJSONObject(index)?.toMealEntry() }
}.getOrDefault(emptyList())
private fun parseSettings(json: JSONObject) = ServerSettings(
visionModel = json.optString("visionModel"),
taskModel = json.optString("taskModel"),
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>) {
prefs.edit().putString(key, JSONArray(entries.map { it.toJson() }).toString()).apply()
private fun parsePlans(json: JSONObject): PlansState {
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 {
val array = JSONArray(prefs.getString("models", "[]"))
(0 until array.length()).mapNotNull { array.optJSONObject(it) }.map { ModelOption(it.optString("id"), it.optString("name", it.optString("id"))) }
}.getOrDefault(emptyList()).ifEmpty { listOf(ModelOption("gpt-4o-mini")) }
private fun parseModels(json: JSONObject): List<ModelOption> {
val arr = json.optJSONArray("models") ?: return emptyList()
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }
.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()
.put("id", id).put("date", date).put("time", time).put("mealType", mealType)
.put("description", description).put("measure", measure).put("imageIncluded", imageIncluded)
.put("imageName", imageName).put("visionEstimate", visionEstimate).put("trashedAt", trashedAt)
.put("mealName", estimate.mealName).put("calories", estimate.calories)
.put("proteinGrams", estimate.proteinGrams).put("carbsGrams", estimate.carbsGrams).put("fatGrams", estimate.fatGrams)
.put("fruitServings", estimate.fruitServings).put("vegetableServings", estimate.vegetableServings)
.put("proteinGrams", estimate.proteinGrams).put("carbsGrams", estimate.carbsGrams)
.put("fatGrams", estimate.fatGrams).put("fruitServings", estimate.fruitServings)
.put("vegetableServings", estimate.vegetableServings)
.put("foodGroups", estimate.foodGroups).put("notes", estimate.notes).put("rawAi", estimate.raw)
fun JSONObject.toMealEntry(): MealEntry = MealEntry(
@ -75,8 +206,10 @@ fun JSONObject.toMealEntry(): MealEntry = MealEntry(
visionEstimate = optString("visionEstimate"),
trashedAt = optString("trashedAt"),
estimate = NutritionEstimate(
mealName = optString("mealName", "Meal"), calories = optInt("calories"), proteinGrams = optInt("proteinGrams"),
carbsGrams = optInt("carbsGrams"), fatGrams = optInt("fatGrams"), fruitServings = optDouble("fruitServings"),
vegetableServings = optDouble("vegetableServings"), foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi")
mealName = optString("mealName", "Meal").ifBlank { "Meal" },
calories = optInt("calories"), proteinGrams = optInt("proteinGrams"),
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 AppSettings(
val apiBaseUrl: String = "",
val apiKey: String = "",
val visionModel: String = "gpt-4o-mini",
val nutritionModel: String = "gpt-4o-mini",
data class LocalConfig(val serverUrl: String = "", val sessionCookie: String = "") {
fun isConnected() = serverUrl.isNotBlank() && sessionCookie.isNotBlank()
}
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 weightKg: String = "",
val targetWeightKg: String = "",
val calorieTarget: String = "",
val activityLevel: String = "Moderate",
val pace: String = "Steady",
val models: List<ModelOption> = listOf(ModelOption("gpt-4o-mini"))
val pace: String = "Steady"
)
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
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
fun CalorieAiApp(
entries: List<MealEntry>,
trash: List<MealEntry>,
settings: AppSettings,
appState: AppState,
serverUrl: String,
status: String,
busy: Boolean,
editing: MealEntry?,
selectedImageName: String,
planStatus: String,
planBusy: Boolean,
syncing: Boolean,
onSync: () -> Unit,
onPickImage: () -> Unit,
onTakePhoto: () -> Unit,
onCancelEdit: () -> Unit,
@ -27,25 +40,62 @@ fun CalorieAiApp(
onAnalyze: (MealEntry) -> Unit,
onEdit: (MealEntry) -> Unit,
onMoveToTrash: (Set<String>) -> Unit,
onClearTrash: () -> Unit,
onRestore: (String) -> Unit,
onSettingsChange: (AppSettings) -> Unit,
onSearchModels: suspend (String) -> List<ModelOption>,
onGeneratePlan: suspend () -> String
onSettingsChange: (ServerSettings) -> Unit,
onGeneratePlan: () -> Unit,
onTunePlan: (String, String) -> Unit,
onSelectPlan: (String) -> Unit,
onDeletePlan: (String) -> Unit,
onDisconnect: () -> Unit
) {
CalorieTheme {
var screen by remember { mutableStateOf(Screen.Dashboard) }
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 = {
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 ->
Box(Modifier.padding(padding).fillMaxSize()) {
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.Diary -> DiaryScreen(entries, onEdit = { onEdit(it); screen = Screen.Log }, onMoveToTrash = onMoveToTrash)
Screen.Trash -> TrashScreen(trash, onRestore)
Screen.Settings -> SettingsScreen(settings, onSettingsChange, onSearchModels, onGeneratePlan)
Screen.Diary -> DiaryScreen(
entries = appState.entries,
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) {
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.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
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()
}
}
@ -20,20 +21,86 @@ fun SectionCard(title: String, modifier: Modifier = Modifier, content: @Composab
@Composable
fun StatTile(label: String, value: String, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) {
Column(Modifier.padding(16.dp)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
Text(value, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black)
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Black)
}
}
}
@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(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) }) else null,
singleLine = singleLine,
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 com.danvics.calorieai.data.MealEntry
import com.danvics.calorieai.data.MealStats
import com.danvics.calorieai.data.ServerSettings
import java.time.LocalDate
@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())
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()) {
StatTile("Today", "${today.calories} kcal", Modifier.weight(1f))
StatTile("Meals", today.meals.toString(), Modifier.weight(1f))
StatTile("Meals today", 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))
StatTile("Produce", "%.1f".format(today.fruit + today.vegetables), Modifier.weight(1f))
if (calorieTarget > 0) {
SectionCard("Daily calorie goal") {
CalorieGoalBar(today.calories, calorieTarget)
}
}
SectionCard("Quick actions") {
Button(onClick = onLog, modifier = Modifier.fillMaxWidth()) { Text("Log a meal") }
OutlinedButton(onClick = onSettings, modifier = Modifier.fillMaxWidth()) { Text("Settings") }
if (macroTotal > 0) {
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 ->
Text("${day.label}: ${day.calories} kcal, produce ${"%.1f".format(day.fruit + day.vegetables)}")
val todayEntries = entries.filter { it.date == LocalDate.now().toString() }.sortedBy { it.time }
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.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.unit.dp
import com.danvics.calorieai.data.MealEntry
@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 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) } }
Text("Diary", style = MaterialTheme.typography.headlineMedium)
Field("Search meals", query, { query = it })
Button(enabled = selected.isNotEmpty(), onClick = { onMoveToTrash(selected); selected = emptySet() }, modifier = Modifier.fillMaxWidth()) { Text("Move selected to trash") }
val rows = entries.filter {
query.isBlank() || listOf(it.description, it.estimate.mealName, it.estimate.foodGroups, it.mealType)
.any { value -> value.contains(query, ignoreCase = true) }
}
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) ->
SectionCard(date) {
OutlinedButton(onClick = { onMoveToTrash(dayRows.map { it.id }.toSet()) }, modifier = Modifier.fillMaxWidth()) { Text("Move day to trash") }
dayRows.forEach { meal ->
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Checkbox(checked = meal.id in selected, onCheckedChange = { checked -> selected = if (checked) selected + meal.id else selected - meal.id })
Column(Modifier.weight(1f)) {
Text(meal.estimate.mealName, fontWeight = FontWeight.Bold)
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")
if (meal.description.isNotBlank()) Text(meal.description)
OutlinedButton(onClick = { onEdit(meal) }) { Text("Edit") }
dayRows.sortedByDescending { it.time }.forEach { meal ->
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = meal.id in selected,
onCheckedChange = { checked -> selected = if (checked) selected + meal.id else selected - meal.id }
)
Column(Modifier.weight(1f)) {
Text(meal.estimate.mealName, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium)
Text(
"${meal.time} · ${meal.mealType} · ${meal.estimate.calories} kcal",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
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
fun TrashScreen(trash: List<MealEntry>, onRestore: (String) -> Unit) = Page {
Text("Trash", style = MaterialTheme.typography.headlineMedium)
if (trash.isEmpty()) Text("Trash is empty.")
private fun TrashContent(
trash: List<MealEntry>,
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 ->
SectionCard(meal.estimate.mealName) {
Text("${meal.date} ${meal.time} · ${meal.estimate.calories} kcal")
OutlinedButton(onClick = { onRestore(meal.id) }) { Text("Restore") }
Card(modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(16.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
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.format.DateTimeFormatter
private val MEAL_TYPES = listOf("Breakfast", "Lunch", "Dinner", "Snack", "Other")
@Composable
fun LogMealScreen(
editing: MealEntry?,
@ -30,43 +32,62 @@ fun LogMealScreen(
var measure by remember(editing?.id) { mutableStateOf(editing?.measure ?: "") }
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") {
Field("Date", date, { date = it })
Field("Time", time, { time = it })
Field("Meal type", mealType, { mealType = it })
Field("Description", description, { description = it }, singleLine = false)
Field("Portion or measure", measure, { measure = it })
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Date", date, { date = it }, modifier = Modifier.weight(1f), placeholder = "YYYY-MM-DD")
Field("Time", time, { time = it }, modifier = Modifier.weight(1f), placeholder = "HH:MM")
}
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()) {
OutlinedButton(onClick = onTakePhoto, modifier = Modifier.weight(1f)) { Text("Take photo") }
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(
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()
) { 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) {
SectionCard("Editable estimate") {
SectionCard("Edit estimate") {
Field("Meal name", estimate.mealName, { estimate = estimate.copy(mealName = it) })
Field("Calories", estimate.calories.toString(), { estimate = estimate.copy(calories = it.toIntOrNull() ?: 0) })
Field("Protein grams", estimate.proteinGrams.toString(), { estimate = estimate.copy(proteinGrams = it.toIntOrNull() ?: 0) })
Field("Carbs grams", estimate.carbsGrams.toString(), { estimate = estimate.copy(carbsGrams = it.toIntOrNull() ?: 0) })
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) })
Field("Vegetable servings", estimate.vegetableServings.toString(), { estimate = estimate.copy(vegetableServings = it.toDoubleOrNull() ?: 0.0) })
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Calories", estimate.calories.toString(), { estimate = estimate.copy(calories = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f))
Field("Protein g", estimate.proteinGrams.toString(), { estimate = estimate.copy(proteinGrams = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
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("Notes", estimate.notes, { estimate = estimate.copy(notes = it) }, singleLine = false)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { onSaveManualEdit(draft(editing, date, time, mealType, description, measure, estimate)) }) { Text("Save edits") }
OutlinedButton(onClick = onCancelEdit) { Text("Cancel") }
Button(
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)

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.ui.Modifier
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.AppSettings
import com.danvics.calorieai.data.ModelOption
import kotlinx.coroutines.launch
import com.danvics.calorieai.data.ServerSettings
private val ACTIVITY_LEVELS = listOf("Low", "Moderate", "High")
private val PACE_OPTIONS = listOf("Gentle", "Steady", "Aggressive")
@Composable
fun SettingsScreen(
settings: AppSettings,
onSettingsChange: (AppSettings) -> Unit,
onSearchModels: suspend (String) -> List<ModelOption>,
onGeneratePlan: suspend () -> String
settings: ServerSettings,
serverUrl: String,
onSettingsChange: (ServerSettings) -> Unit,
onDisconnect: () -> Unit
) = Page {
val scope = rememberCoroutineScope()
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("") }
Text("Settings", style = MaterialTheme.typography.headlineMedium)
SectionCard("AI connection") {
Field("API base URL", draft.apiBaseUrl, { draft = draft.copy(apiBaseUrl = it) })
Field("API key", draft.apiKey, { draft = draft.copy(apiKey = it) })
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}") }
}
SectionCard("Profile") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Height (cm)", draft.heightCm, { draft = draft.copy(heightCm = it) }, modifier = Modifier.weight(1f), placeholder = "175")
Field("Weight (kg)", draft.weightKg, { draft = draft.copy(weightKg = it) }, modifier = Modifier.weight(1f), placeholder = "80")
}
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) })
Field("Current weight kg", draft.weightKg, { draft = draft.copy(weightKg = it) })
Field("Target weight kg", draft.targetWeightKg, { draft = draft.copy(targetWeightKg = it) })
Field("Activity", draft.activityLevel, { draft = draft.copy(activityLevel = it) })
Field("Pace", draft.pace, { draft = draft.copy(pace = it) })
Button(onClick = { onSettingsChange(draft); scope.launch { plan = onGeneratePlan() } }, modifier = Modifier.fillMaxWidth()) { Text("Generate plan") }
if (plan.isNotBlank()) Text(plan)
SectionCard("Server") {
Text("Connected to $serverUrl", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("AI models and API keys are configured on the server.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedButton(
onClick = onDisconnect,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Disconnect") }
}
}

View file

@ -1,19 +1,36 @@
package com.danvics.calorieai.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val Colors = lightColorScheme(
private val LightColors = lightColorScheme(
primary = Color(0xFF2563EB),
secondary = Color(0xFF10B981),
tertiary = Color(0xFFF59E0B),
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
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"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">#F8F4EC</item>
<item name="android:navigationBarColor">#F8F4EC</item>
<item name="android:colorAccent">#2F7D59</item>
<item name="android:windowBackground">@android:color/white</item>
<item name="android:statusBarColor">@color/ic_launcher_background</item>
<item name="android:navigationBarColor">@android:color/white</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:fontFamily">sans-serif</item>
<item name="android:colorAccent">#2563EB</item>
</style>
</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 modelsFile = path.join(dataDir, 'models.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 sessionSeconds = 60 * 60 * 24 * 7;
let auth;
@ -275,6 +277,114 @@ async function deletePlanRoute(req, res) {
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) {
return Array.from(new Map((models || []).filter(model => model && model.id).map(model => [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() {
auth = await loadAuthConfig();
modelConfig = loadModelConfig();
@ -658,6 +739,13 @@ async function start() {
app.post('/api/plans', wrap(createPlan));
app.post('/api/plans/select', wrap(selectPlanRoute));
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.use((req, res) => {
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);

View file

@ -1,5 +1,6 @@
<script>
import { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import Field from './Field.svelte';
import Stat from './Stat.svelte';
import MealForm from './MealForm.svelte';
@ -7,17 +8,14 @@
import SettingsPage from './SettingsPage.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 = [
{ id: 'dashboard', label: 'Dashboard', icon: '', group: 'Tracker' },
{ id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' },
{ id: 'analytics', label: 'Analytics', icon: '', group: 'Tracker' },
{ id: 'diary', label: 'Diary', icon: '', group: 'Tracker' },
{ id: 'trash', label: 'Trash', icon: '×', group: 'Tracker' },
{ id: 'plans', label: 'Plans', icon: '', group: 'Tracker' },
{ id: 'settings', label: 'Settings', icon: '', group: 'Admin' },
{ id: 'dashboard', label: 'Dashboard', icon: 'dashboard', group: 'Tracker' },
{ id: 'log', label: 'Log meal', icon: 'plus', group: 'Tracker' },
{ id: 'analytics', label: 'Analytics', icon: 'analytics', group: 'Tracker' },
{ id: 'diary', label: 'Diary', icon: 'diary', group: 'Tracker' },
{ id: 'trash', label: 'Trash', icon: 'trash', group: 'Tracker' },
{ id: 'plans', label: 'Plans', icon: 'plans', group: 'Tracker' },
{ id: 'settings', label: 'Settings', icon: 'settings', group: 'Admin' },
];
const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
const fallbackModels = [
@ -26,6 +24,16 @@
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
{ 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 username = 'admin';
@ -52,7 +60,7 @@
let modelStatus = '';
let modelError = 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 newPassword = '';
let confirmPassword = '';
@ -89,7 +97,6 @@
let selectedEntryIds = [];
let trash = [];
let macroCanvas;
let calorieCanvas;
let produceCanvas;
@ -99,6 +106,8 @@
$: week = last7Days();
$: weekAverage = Math.round(week.reduce((sum, day) => sum + day.calories, 0) / week.length);
$: 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
.filter(entry => !diaryType || entry.mealType === diaryType)
.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));
onMount(async () => {
entries = readJson(entriesKey, []);
trash = readJson(trashKey, []);
settings = { ...settings, ...readJson(settingsKey, settings) };
await loadSession();
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime();
await loadModels();
await loadPlans();
await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadModels(), loadPlans()]);
await drawCharts();
});
@ -143,14 +148,6 @@
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() {
const now = new Date();
mealDate = dateKey(now);
@ -264,8 +261,42 @@
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() {
modelStatus = 'Loading models...';
modelStatus = '';
modelError = false;
try {
const response = await fetch('/api/models', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
@ -273,10 +304,11 @@
const data = await response.json();
models = data.models?.length ? data.models : fallbackModels;
const defaultModel = data.defaultModel || models[0].id;
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = defaultModel;
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = defaultModel;
saveSettings();
modelStatus = `${models.length} saved model${models.length === 1 ? '' : 's'} loaded.`;
let changed = false;
if (!models.some(model => model.id === settings.visionModel)) { settings.visionModel = defaultModel; changed = true; }
if (!models.some(model => model.id === settings.taskModel)) { settings.taskModel = defaultModel; changed = true; }
if (changed) await saveSettings();
modelStatus = `${models.length} model${models.length === 1 ? '' : 's'} loaded.`;
} catch {
models = fallbackModels;
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() {
try {
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: '' };
}
function saveSelectedModels() {
saveSettings();
async function saveSelectedModels() {
await saveSettings();
modelStatus = 'Models saved.';
modelError = false;
}
function selectModel(modelId, target = 'task') {
if (target === 'vision') settings.visionModel = modelId;
else settings.taskModel = modelId;
saveSelectedModels();
}
async function searchModels() {
if (modelSearch.trim().length < 2) {
discoveredModels = [];
@ -362,7 +379,7 @@
body: JSON.stringify(model),
});
if (!response.ok) {
modelStatus = await response.text();
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
modelError = true;
return;
}
@ -370,7 +387,7 @@
models = data.models?.length ? data.models : models;
settings.visionModel = models.some(item => item.id === settings.visionModel) ? settings.visionModel : models[0].id;
settings.taskModel = model.id;
saveSettings();
await saveSettings();
modelStatus = `Added ${model.name || model.id}.`;
modelError = false;
}
@ -382,7 +399,7 @@
body: JSON.stringify({ id: modelId }),
});
if (!response.ok) {
modelStatus = await response.text();
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
modelError = true;
return;
}
@ -390,7 +407,7 @@
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.taskModel)) settings.taskModel = data.defaultModel || models[0].id;
saveSettings();
await saveSettings();
modelStatus = 'Model removed.';
modelError = false;
}
@ -565,16 +582,20 @@ Image estimate: ${imageEstimate}` },
imageName: selectedImageName,
updatedAt: new Date().toISOString(),
};
entries = editingEntryId ? entries.map(entry => entry.id === editingEntryId ? { ...entry, ...meal } : entry) : [...entries, meal];
saveJson(entriesKey, entries);
const response = await fetch('/api/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 = '';
measure = '';
selectedImageDataUrl = '';
selectedImageName = '';
editingEntryId = '';
estimateDraft = emptyEstimate();
setDefaultMealDateTime();
setMealStatus('Saved. You can edit it from Diary or run analysis again.');
setMealStatus(`Saved. ${estimate.mealName} · ${estimate.calories} kcal`);
} catch (error) {
setMealStatus(error.message, true);
} finally {
@ -599,10 +620,12 @@ Image estimate: ${imageEstimate}` },
selectPage('log');
}
function saveMealEdits() {
async function saveMealEdits() {
if (!editingEntryId) return;
entries = entries.map(entry => entry.id === editingEntryId ? {
...entry,
const existing = entries.find(e => e.id === editingEntryId);
if (!existing) return;
const updated = {
...existing,
...estimateDraft,
date: mealDate,
time: mealTime,
@ -610,14 +633,24 @@ Image estimate: ${imageEstimate}` },
description,
measure,
updatedAt: new Date().toISOString(),
} : entry);
saveJson(entriesKey, entries);
editingEntryId = '';
estimateDraft = emptyEstimate();
description = '';
measure = '';
setDefaultMealDateTime();
setMealStatus('Edits saved.');
};
try {
const response = await fetch('/api/entries', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(updated),
});
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() {
@ -635,28 +668,35 @@ Image estimate: ${imageEstimate}` },
selectedEntryIds = selectedEntryIds.includes(id) ? selectedEntryIds.filter(item => item !== id) : [...selectedEntryIds, id];
}
function moveEntriesToTrash(ids) {
const idSet = new Set(ids);
const moving = entries.filter(entry => idSet.has(entry.id)).map(entry => ({ ...entry, trashedAt: new Date().toISOString() }));
if (!moving.length) return;
trash = [...moving, ...trash];
entries = entries.filter(entry => !idSet.has(entry.id));
selectedEntryIds = selectedEntryIds.filter(id => !idSet.has(id));
saveJson(entriesKey, entries);
saveJson(trashKey, trash);
async function moveEntriesToTrash(ids) {
const idList = Array.isArray(ids) ? ids : [ids];
if (!idList.length) return;
try {
const response = await fetch('/api/entries/delete', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ids: idList }),
});
if (!response.ok) return;
applyEntriesData(await response.json());
selectedEntryIds = selectedEntryIds.filter(id => !idList.includes(id));
} catch {}
}
function moveDayToTrash(date) {
moveEntriesToTrash(entries.filter(entry => entry.date === date).map(entry => entry.id));
}
function restoreEntry(id) {
const found = trash.find(entry => entry.id === id);
if (!found) return;
entries = [{ ...found, trashedAt: undefined }, ...entries];
trash = trash.filter(entry => entry.id !== id);
saveJson(entriesKey, entries);
saveJson(trashKey, trash);
async function restoreEntry(id) {
try {
const response = await fetch('/api/entries/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id }),
});
if (!response.ok) return;
applyEntriesData(await response.json());
} catch {}
}
async function generatePlan() {
@ -665,6 +705,7 @@ Image estimate: ${imageEstimate}` },
planError = false;
try {
if (!settings.heightCm || !settings.weightKg) throw new Error('Enter height and current weight first.');
await saveSettings();
const content = await chatCompletion({
model: settings.taskModel,
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() };
await savePlan(plan);
planStatus = 'Plan generated.';
saveSettings();
} catch (error) {
planStatus = error.message;
planError = true;
@ -739,67 +779,42 @@ Image estimate: ${imageEstimate}` },
async function drawCharts() {
await tick();
drawMacroChart();
drawBars(calorieCanvas, week, 'calories');
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) {
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');
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 max = Math.max(1, ...values);
const left = 26;
const bottom = canvas.height - 44;
const height = canvas.height - 76;
const gap = 14;
const bar = (canvas.width - left * 2 - gap * 6) / 7;
const left = 4;
const bottom = cssH - 32;
const barHeight = cssH - 52;
const gap = 8;
const bar = (cssW - left * 2 - gap * 6) / 7;
ctx.textAlign = 'center';
days.forEach((day, index) => {
const x = left + index * (bar + gap);
const h = values[index] / max * height;
ctx.fillStyle = '#e0e7ff';
roundRect(ctx, x, bottom - height, bar, height, 10);
const h = values[index] / max * barHeight;
ctx.fillStyle = '#e2e8f0';
roundRect(ctx, x, bottom - barHeight, bar, barHeight, 6);
ctx.fillStyle = mode === 'calories' ? '#2563eb' : '#f59e0b';
roundRect(ctx, x, bottom - h, bar, h, 10);
if (mode === 'produce') {
const vegH = day.vegetables / max * height;
roundRect(ctx, x, bottom - h, bar, h, 6);
if (mode === 'produce' && day.vegetables > 0) {
const vegH = day.vegetables / max * barHeight;
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.font = '13px Inter, sans-serif';
ctx.fillText(day.label, x, bottom + 24);
ctx.fillStyle = '#64748b';
ctx.font = `${Math.max(10, Math.min(13, bar * 0.55))}px Inter, sans-serif`;
ctx.fillText(day.label, x + bar / 2, bottom + 20);
});
}
@ -865,55 +880,113 @@ Image estimate: ${imageEstimate}` },
</main>
{:else}
<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">
<div class="sidebar-header flex h-[68px] items-center justify-between border-b border-slate-200 px-4">
<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-brand min-w-0">
<h1 class="truncate text-lg font-black text-slate-950">Calorie AI</h1>
<p class="text-xs font-semibold text-slate-500">Food diary</p>
</div>
<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-16 items-center justify-between border-b border-slate-200 px-4">
<div class="flex min-w-0 items-center gap-3">
<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="truncate text-base font-black text-slate-950">Calorie AI</div>
<div class="text-xs text-slate-500">Food diary</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>
<nav class="p-2" aria-label="Main 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}
{#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}
{#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}
{#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}
</nav>
<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">
<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>
</button>
<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>
</div>
<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>
{#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">
<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">
<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>
<div class="min-w-0">
<div class="truncate text-lg font-black text-slate-950">{pages.find(item => item.id === page)?.label || 'Dashboard'}</div>
</div>
<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">
<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>
</button>
<div class="truncate text-base font-bold text-slate-900">{pages.find(item => item.id === page)?.label || 'Dashboard'}</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>
</header>
<main class="min-w-0 flex-1 p-4 lg:p-6">
{#if page === 'dashboard'}
<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 xl:grid-cols-[1.4fr_0.6fr_0.6fr_0.8fr]">
<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>
<Stat label="Meals" value={today.meals} note="Today" />
<Stat label="7-day avg" value={`${weekAverage} kcal`} note="Calories/day" />
<Stat label="Produce" value={`${produceToday.toFixed(1)} / 5`} note="Fruit + vegetables" />
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<article class="sm:col-span-2 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-5 text-white shadow-sm">
<div class="text-xs font-semibold uppercase tracking-widest text-blue-200">Today</div>
<div id="todayCalories" class="mt-1 text-4xl font-black tracking-tight">{today.calories} kcal</div>
<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>
<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 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>
<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>
<div class="grid gap-4 lg:grid-cols-2">
<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>
</section>
{:else if page === 'log'}
<MealForm
{mealTypes}
@ -933,8 +1006,39 @@ Image estimate: ${imageEstimate}` },
{saveMealEdits}
{cancelEdit}
/>
{: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'}
<DiaryPage
{mealTypes}
@ -950,6 +1054,7 @@ Image estimate: ${imageEstimate}` },
{moveEntriesToTrash}
{restoreEntry}
/>
{:else if page === 'trash'}
<DiaryPage
{mealTypes}
@ -966,6 +1071,7 @@ Image estimate: ${imageEstimate}` },
{restoreEntry}
trashMode={true}
/>
{:else if page === 'plans'}
<PlansPage
bind:settings
@ -980,6 +1086,7 @@ Image estimate: ${imageEstimate}` },
{selectPlan}
{deletePlan}
/>
{:else if page === 'settings'}
<SettingsPage
bind:settings
@ -1016,13 +1123,13 @@ Image estimate: ${imageEstimate}` },
{/if}
<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; }
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; }
.tab-icon { display: grid; width: 1.35rem; height: 1.35rem; flex: 0 0 1.35rem; place-items: center; font-weight: 900; }
:global(.collapsed-sidebar) { width: 4.75rem !important; }
.active-tab { border-left-color: #2563eb; background: #eff6ff; color: #2563eb; }
.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.25rem !important; }
: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-button) { justify-content: center; padding-left: 0.75rem; padding-right: 0.75rem; }
:global(.collapsed-sidebar .collapse-button) { height: 2.5rem; width: 2.5rem; border-radius: 0.75rem; }
: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; padding-right: 0; }
:global(.collapsed-sidebar .collapse-button) { display: grid; }
</style>

View file

@ -22,22 +22,29 @@
<section class="space-y-4">
<div>
<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>
{#if trashMode}
<div id="trashEntries" class="grid gap-3">
<div id="trashEntries" class="grid gap-2">
{#if trash.length}
{#each trash as entry}
<article class="entry card p-4">
<div class="flex flex-col justify-between gap-2 md:flex-row">
<div><strong>{entry.mealName}</strong><div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div></div>
<button class="btn-secondary" type="button" on:click={() => restoreEntry(entry.id)}>Restore</button>
<div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
<div class="min-w-0">
<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>
</article>
{/each}
{:else}<div class="empty-state">Trash is empty.</div>{/if}
{:else}
<div class="empty-state">Trash is empty.</div>
{/if}
</div>
{:else}
<section class="card">
<div class="grid gap-3 p-4 md:grid-cols-[1fr_180px_170px]">
@ -47,10 +54,10 @@
</div>
{#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="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">
{#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>
{/if}
@ -60,27 +67,40 @@
{#if diaryRows.length}
{#each diaryDays as day}
<section class="card">
<div class="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-2">
<h3 class="font-bold">{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>
<div class="flex items-center justify-between border-b border-slate-100 bg-slate-50 px-4 py-2.5">
<h3 class="text-sm font-bold text-slate-700">{day}</h3>
<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 class="grid gap-2 p-3">
<div class="divide-y divide-slate-100">
{#each diaryRows.filter(entry => entry.date === day) as entry}
<article class="entry rounded-xl border border-slate-200 p-3">
<div class="flex flex-col justify-between gap-2 md:flex-row">
<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>
<div class="font-bold">{entry.calories} kcal</div>
<article class="entry px-4 py-3">
<div class="flex items-start gap-3">
<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="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 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>
{/each}
</div>
</section>
{/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>
{/if}
</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;
$: 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([
'plan',
'personalized weight-loss plan',
'your details',
'daily calorie range',
'protein target',
'healthy habits',
'weekly review steps',
'safety notes',
'getting started',
'targets',
'habits',
'plan', 'personalized weight-loss plan', 'your details', 'daily calorie range',
'protein target', 'healthy habits', 'weekly review steps', 'safety notes',
'getting started', 'targets', 'habits',
]);
function cleanLine(line) {
@ -61,7 +77,7 @@
<section class="space-y-4">
<div>
<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 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="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>
<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}
</div>
</section>
@ -100,29 +119,51 @@
{/each}
</div>
<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>
<button class="btn-primary mt-3" type="button" on:click={() => tunePlan(selectedPlan.id)} disabled={planLoading}>{planLoading ? 'Updating...' : 'Update plan with AI'}</button>
<Field label="Fine-tune with meals, activities, or progress notes">
<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>
{: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}
</div>
</section>
</div>
{#if plans.length > 1}
<section class="card">
<h3 class="card-title">Plan history</h3>
<div class="grid gap-2 p-4 md:grid-cols-2 xl:grid-cols-3">
{#if plans.length}
{#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)}>
<strong>{plan.title}</strong>
<div class="text-xs text-slate-500">Version {plan.version || 1} · {new Date(plan.createdAt).toLocaleString()}</div>
{#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)}>
<strong>{plan.title}</strong>
<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>
{/each}
{:else}
<p class="text-sm text-slate-500">Old plans will appear here and remain available after updates.</p>
{/if}
{#if faqOpen === i}
<p class="px-4 pb-4 text-sm leading-6 text-slate-600">{faq.a}</p>
{/if}
</div>
{/each}
</div>
</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) {
await mockModels(page);
await mockPlans(page);
await mockEntries(page);
await mockSettings(page);
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();