Refactor Android app to production grade with improved UI and sync
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:
parent
f42e8c8784
commit
9f660d50bc
29 changed files with 1654 additions and 549 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)))
|
||||
|
|
|
|||
|
|
@ -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() } ?: ""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
app/src/main/java/com/danvics/calorieai/ui/ConnectScreen.kt
Normal file
66
app/src/main/java/com/danvics/calorieai/ui/ConnectScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
102
app/src/main/java/com/danvics/calorieai/ui/PlansScreen.kt
Normal file
102
app/src/main/java/com/danvics/calorieai/ui/PlansScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
5
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
5
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
20
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
20
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
4
app/src/main/res/values/colors.xml
Normal file
4
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#2563EB</color>
|
||||
</resources>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
146
web/server.js
146
web/server.js
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">Today’s 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 · C {today.carbs}g · F {today.fat}g</div>
|
||||
<div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} · 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 · {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 · 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>
|
||||
|
|
|
|||
|
|
@ -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
22
web/src/Icon.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue