Compare commits

...

17 commits
v1.1 ... main

Author SHA1 Message Date
Daniel
dd70da8c71 Add Capacitor Health Connect sync
All checks were successful
Android CI / build (push) Successful in 2m6s
Web CI / test (push) Successful in 1m14s
Android Release / release-apk (push) Successful in 1m52s
2026-05-25 16:31:51 +02:00
Daniel
38d9f50c31 Fix Calorie AI release publishing
All checks were successful
Android CI / build (push) Successful in 2m19s
Web CI / test (push) Successful in 1m12s
Android Release / release-apk (push) Successful in 1m45s
2026-05-25 04:11:00 +02:00
Daniel
ef6b3a494c Use Node 22 in Calorie AI workflows
Some checks failed
Android CI / build (push) Successful in 2m26s
Web CI / test (push) Successful in 1m14s
Android Release / release-apk (push) Failing after 1m36s
2026-05-25 04:03:47 +02:00
Daniel
176a9fc3bb Make calorie logging reliable
Some checks failed
Android CI / build (push) Successful in 3m0s
Web CI / test (push) Successful in 1m31s
Android Release / release-apk (push) Failing after 45s
2026-05-25 03:55:59 +02:00
Daniel
33d50258ce Improve plan UX and split web UI components
Some checks failed
Android CI / build (push) Successful in 1m52s
Web CI / test (push) Successful in 54s
Android Release / release-apk (push) Failing after 21s
2026-05-21 03:40:47 +02:00
Daniel
f73b58a158 Add Health Connect activity sync
Some checks failed
Android CI / build (push) Successful in 1m43s
Web CI / test (push) Successful in 58s
Android Release / release-apk (push) Failing after 22s
2026-05-21 01:31:15 +02:00
Daniel
fcef0d48db Add personalized plans with Postgres storage
Some checks failed
Android CI / build (push) Successful in 1m34s
Web CI / test (push) Successful in 58s
Android Release / release-apk (push) Failing after 22s
2026-05-20 23:43:03 +02:00
Daniel
f14b433085 Replace Kotlin app with Capacitor WebView wrapper
Some checks failed
Android CI / build (push) Successful in 1m33s
Web CI / test (push) Failing after 1m23s
Android Release / release-apk (push) Failing after 22s
The native Kotlin/Compose app is replaced by a Capacitor shell that
loads the existing Svelte web app from the self-hosted server URL,
set via APP_SERVER_URL secret at build time.

- web/capacitor.config.ts: appId com.danvics.calorieai, reads
  APP_SERVER_URL env var so the WebView points at the live server
- web/android/: Capacitor-generated Android project (gradlew included)
- AndroidManifest.xml: added CAMERA, READ_MEDIA_IMAGES, READ_EXTERNAL_STORAGE
- MealForm.svelte: split file input into Camera (capture=environment)
  and Gallery buttons so both options are accessible natively
- release.yml: npm ci → npm run build → cap sync → gradlew assembleDebug;
  removed manual Gradle installation, uses bundled gradlew instead
- package.json: Capacitor packages as devDependencies (not shipped in
  the Docker server image)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 18:14:53 +02:00
Daniel
8da55652e0 Keep plan title consistent across versions
All generated and tuned plans now share the title "Weight-loss plan"
with version number tracked separately, matching the PlansPage display
which already shows "Version N" beneath the title.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:05:18 +02:00
Daniel
40e65ca70c Add diet history context to AI meal analysis on both platforms
- Web: buildTodayHistory() computes today's logged meals and passes them
  to requestMealEstimate so the AI can give personalized notes
- Web: system prompt updated to instruct AI to use notes field for
  dietary observations (macro balance, variety, goal progress)
- Android: same diet history + notes guidance already applied to MainActivity
- Tests: fix NutritionParserTest to use exact key names (proteinGrams,
  carbsGrams, fatGrams) matching the standardised AI prompt format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:12:25 +02:00
Daniel
03d4178113 Bump to v1.3
All checks were successful
Android CI / build (push) Successful in 1m40s
Web CI / test (push) Successful in 52s
Android Release / release-apk (push) Successful in 1m40s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:39:49 +02:00
Daniel
a4e44201a2 Fix AI nutrition extraction: robust parser + better prompt
- NutritionParser now handles short key aliases (protein/carbs/fat instead of
  proteinGrams/carbsGrams/fatGrams), string-typed numbers, surrounding prose,
  and multi-line markdown fences reliably
- safeInt/safeDouble helpers check multiple key names in priority order and
  handle Number, String, and missing values without throwing
- Nutrition prompt now includes an explicit JSON example so all models return
  the expected format regardless of their default style
- Same fixes applied to web app parseEstimate (aliases + fence stripping)
- Extended NutritionParserTest to cover aliases, string numbers, surrounding text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:39:33 +02:00
Daniel
b93e479be4 Fix camera, add photo preview, native date/time pickers, auto-navigate dashboard after save
- Replace TakePicturePreview with TakePicture + FileProvider for reliable full-res camera capture
- Add FileProvider to manifest with cache-path for camera photos
- Show selected photo as a thumbnail preview in the Log screen
- Replace plain date/time text fields with clickable fields that open native Android pickers
- After AI analysis or manual edit save, auto-navigate to Dashboard so results are visible immediately
- Clear editing/image state after successful save so Log screen resets for next entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 15:18:26 +02:00
Daniel
af35b70399 Fix image picker crash, add 7-day bar chart, bump to v1.2
All checks were successful
Android CI / build (push) Successful in 1m58s
Web CI / test (push) Successful in 49s
Android Release / release-apk (push) Successful in 1m35s
- Image reading moved to IO thread with runCatching to prevent main thread crash and OOM on large gallery images
- 7-day calorie bar chart added to dashboard using Canvas
- Version bumped to 1.2 (code 3)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 06:10:54 +02:00
Daniel
709d6c1f6f Fix Playwright tests: add h1 heading role to app header, remove duplicate Plans h2
All checks were successful
Android CI / build (push) Successful in 1m44s
Web CI / test (push) Successful in 46s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 05:56:15 +02:00
Daniel
99bd1bff42 Fix UX issues across all screens
Some checks failed
Android CI / build (push) Successful in 1m59s
Web CI / test (push) Failing after 1m6s
ConnectScreen:
- Keyboard types: Uri for URL, Password for password
- IME chain: Next→Next→Done across all three fields
- Pressing Done on password submits the form
- Password visibility toggle (show/hide button)

PlansScreen:
- Delete plan now requires confirmation dialog
- Plan content bumped to bodyMedium (was too small)
- Plan history uses Card with primaryContainer highlight for active plan
- "Active" badge on selected plan instead of just bold text

SettingsScreen:
- "Profile saved" status auto-clears after 3 seconds

CalorieAiApp:
- Top bar title shows "Edit meal" when editing an existing entry

DiaryScreen:
- "Deselect" button next to "Trash" when items are selected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 05:44:34 +02:00
Daniel
19d8899057 Replace two-button image picker with bottom sheet chooser
Some checks failed
Android CI / build (push) Successful in 1m42s
Web CI / test (push) Has been cancelled
Single "Add photo" button opens a ModalBottomSheet with camera
and gallery options, matching standard mobile UX patterns.
Switches to material-icons-extended for CameraAlt/PhotoLibrary icons.
Button label changes to "Change photo" once an image is selected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 05:42:37 +02:00
95 changed files with 4880 additions and 664 deletions

View file

@ -12,8 +12,6 @@ on:
jobs:
release-apk:
runs-on: android-ci
env:
GRADLE_VERSION: 8.10.2
steps:
- name: Check out repository
run: |
@ -23,16 +21,28 @@ jobs:
- name: Install tools
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl nodejs unzip
curl -fsSL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o /tmp/gradle.zip
unzip -q /tmp/gradle.zip -d /opt
ln -s "/opt/gradle-${GRADLE_VERSION}/bin/gradle" /usr/local/bin/gradle
apt-get install -y --no-install-recommends ca-certificates curl unzip
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y --no-install-recommends nodejs
node --version
npm --version
- name: Build installable APK
- name: Build web app and sync Capacitor
env:
APP_SERVER_URL: ${{ secrets.APP_SERVER_URL }}
run: |
gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug
mkdir -p dist
cp app/build/outputs/apk/debug/*.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk"
cd web
npm ci
npm run build
npx cap sync android
- name: Build APK
run: |
cd web/android
chmod +x gradlew
./gradlew --no-daemon assembleDebug
mkdir -p ../../dist
cp app/build/outputs/apk/debug/*.apk "../../dist/calorie-ai-${GITHUB_REF_NAME}.apk"
- name: Publish Forgejo release asset
env:
@ -44,10 +54,10 @@ jobs:
auth_header="Authorization: token ${TOKEN}"
release_json="$(curl -fsS -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" || true)"
if [ -z "$release_json" ]; then
release_json="$(node -e 'console.log(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `Calorie AI ${process.env.GITHUB_REF_NAME}`, body: "Installable Android debug APK for Calorie AI.", draft: false, prerelease: false }))' | curl -fsS -X POST -H "$auth_header" -H 'Content-Type: application/json' --data-binary @- "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases")"
release_json="$(node -e 'console.log(JSON.stringify({ tag_name: process.env.GITHUB_REF_NAME, name: `Calorie AI ${process.env.GITHUB_REF_NAME}`, body: "Capacitor Android APK for Calorie AI.", draft: false, prerelease: false }))' | curl -fsS -X POST -H "$auth_header" -H 'Content-Type: application/json' --data-binary @- "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases")"
fi
release_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => console.log(JSON.parse(data).id));')"
asset_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => { const release = JSON.parse(data); const asset = (release.assets || []).find(item => item.name === `calorie-ai-${process.env.GITHUB_REF_NAME}.apk`); if (asset) console.log(asset.id); });')"
asset_id="$(printf '%s' "$release_json" | node -e 'let data=""; process.stdin.on("data", c => data += c); process.stdin.on("end", () => { const release = JSON.parse(data); const expected = "calorie-ai-" + process.env.GITHUB_REF_NAME + ".apk"; const asset = (release.assets || []).find(item => item.name === expected); if (asset) console.log(asset.id); });')"
if [ -n "$asset_id" ]; then
curl -fsS -X DELETE -H "$auth_header" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}"
fi

View file

@ -19,7 +19,14 @@ jobs:
- name: Install Node and browsers
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl nodejs npm
apt-get install -y --no-install-recommends ca-certificates curl postgresql postgresql-client
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y --no-install-recommends nodejs
node --version
npm --version
service postgresql start
su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='calorie_ai'\" | grep -q 1 || psql -c \"CREATE USER calorie_ai WITH PASSWORD 'calorie_ai';\""
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='calorie_ai_test'\" | grep -q 1 || createdb -O calorie_ai calorie_ai_test"
cd web
npm install
npx playwright install --with-deps chromium
@ -31,4 +38,5 @@ jobs:
CALORIE_AI_WEB_USER=admin \
CALORIE_AI_WEB_PASSWORD=test-password \
CALORIE_AI_SESSION_SECRET=test-session-secret \
CALORIE_AI_TEST_DATABASE_URL=postgresql://calorie_ai:calorie_ai@127.0.0.1:5432/calorie_ai_test \
npm test

1
.gitignore vendored
View file

@ -8,5 +8,6 @@ local.properties
web/node_modules/
web/test-results/
web/playwright-report/
web/.test-data/
web/data/
web/.env

View file

@ -8,8 +8,8 @@ Native Android and Dockerized web calorie tracker with meal images, AI nutrition
- Analyze meals through OpenAI-compatible chat completions.
- Uses a vision model for food image calorie and portion estimates.
- Uses an advice model to normalize calories, protein, carbs, fat, fruit servings, vegetable servings, food groups, and notes.
- Stores daily meal entries locally on Android or in browser local storage.
- Stores generated web weight-loss plans on the server in `web/data/plans.json`.
- Stores meal entries, settings, plans, and synced activity in PostgreSQL.
- Imports Garmin Connect and gym app activity through Android Health Connect.
- Shows daily totals plus charts for macros, 7-day calories, and fruit/vegetable intake.
- Admin settings control API base URL, API key, image model, and nutrition/advice model. The two model fields can contain the same model name.
@ -48,6 +48,16 @@ Recommended Obtainium setup:
The APK is a debug build, so Android may require allowing installs from Obtainium and accepting the debug signing certificate.
## Garmin And Gym Activity
Calorie AI imports external activity through Android Health Connect:
- Garmin: enable Health Connect sync in Garmin Connect, then open Calorie AI on Android and use `Settings -> Sync Health Connect`.
- Gym machines: use the machine's companion app or gym app to write workouts to Health Connect, then sync from Calorie AI.
- Imported data includes daily steps, active calories, and exercise sessions when Health Connect exposes them.
Direct Garmin Health API sync requires Garmin partner approval and API credentials, so the supported path is Health Connect first.
## Web Frontend
The web frontend lives in `web/`. It serves an authenticated browser UI and a tiny Node proxy at `/api/chat` so OpenAI-compatible endpoints are called server-side instead of directly from the browser.
@ -65,7 +75,9 @@ Then open:
http://127.0.0.1:8095
```
The Docker web server creates credentials on first boot in `web/data/auth.json` if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
The Docker web stack includes PostgreSQL. Runtime data lives under `web/data/postgres`, and legacy JSON/SQLite data in `web/data` is migrated on startup.
The Docker web server creates credentials on first boot if `CALORIE_AI_WEB_PASSWORD` and `CALORIE_AI_SESSION_SECRET` are not supplied. To pin credentials, copy `web/.env.example` to `web/.env` and set strong values before starting Docker Compose.
## AI Endpoint
@ -80,6 +92,4 @@ Example base URLs:
- `https://api.openai.com/v1`
- An emulator host-loopback URL ending in `:11434/v1` for a local OpenAI-compatible service
Default admin PIN is `admin`. Change it in the Admin AI Settings panel after first launch.
The web frontend stores AI settings in browser local storage after web login, while generated plan versions are stored server-side. The web login is separate from the Android admin PIN.
The web and Android clients use the same authenticated server APIs for entries, settings, plans, models, and synced activity.

View file

@ -12,8 +12,8 @@ android {
applicationId 'com.danvics.calorieai'
minSdk 26
targetSdk 35
versionCode 2
versionName '1.1'
versionCode 11
versionName '1.11'
}
buildFeatures {
@ -44,9 +44,10 @@ 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.material:material-icons-extended'
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.health.connect:connect-client:1.1.0-alpha12'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
debugImplementation 'androidx.compose.ui:ui-tooling'

View file

@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.health.READ_STEPS" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<application
android:allowBackup="true"
@ -17,5 +20,14 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -1,16 +1,22 @@
package com.danvics.calorieai
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.health.connect.client.contracts.HealthPermissionsRequestContract
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import com.danvics.calorieai.ai.ImagePayload
import com.danvics.calorieai.ai.NutritionParser
import com.danvics.calorieai.data.*
import com.danvics.calorieai.integrations.HealthConnectAvailability
import com.danvics.calorieai.integrations.HealthConnectSync
import com.danvics.calorieai.ui.CalorieAiApp
import com.danvics.calorieai.ui.CalorieTheme
import com.danvics.calorieai.ui.ConnectScreen
@ -19,6 +25,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
@ -37,6 +44,12 @@ private fun AppRoot(repo: ApiRepository) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val parser = remember { NutritionParser() }
val healthConnect = remember { HealthConnectSync(context) }
val cameraImageFile = remember { File(context.cacheDir, "cam_capture.jpg") }
val cameraImageUri: Uri = remember {
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", cameraImageFile)
}
var connected by remember { mutableStateOf(repo.isConnected()) }
var connectError by remember { mutableStateOf("") }
@ -45,23 +58,102 @@ private fun AppRoot(repo: ApiRepository) {
var appState by remember { mutableStateOf(AppState()) }
var editing by remember { mutableStateOf<MealEntry?>(null) }
var selectedImage by remember { mutableStateOf<ImagePayload?>(null) }
var selectedImageBitmap by remember { mutableStateOf<Bitmap?>(null) }
var status by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var planStatus by remember { mutableStateOf("") }
var planBusy by remember { mutableStateOf(false) }
var syncing by remember { mutableStateOf(false) }
var healthConnectStatus by remember { mutableStateOf("Health Connect not synced.") }
var mealSaveCount by remember { mutableStateOf(0) }
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) selectedImage = context.contentResolver.openInputStream(uri)?.use { stream ->
ImagePayload.fromBytes(uri.lastPathSegment ?: "image", context.contentResolver.getType(uri) ?: "image/jpeg", stream.readBytes())
fun performHealthConnectSync() {
healthConnectStatus = "Syncing Health Connect..."
scope.launch(Dispatchers.IO) {
runCatching {
val activities = healthConnect.readActivities()
repo.syncActivities(activities)
}.onSuccess { activities ->
withContext(Dispatchers.Main) {
appState = appState.copy(activities = activities)
healthConnectStatus = "Synced ${activities.size} Health Connect record${if (activities.size == 1) "" else "s"}."
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
if (e is UnauthorizedException) {
connected = false
appState = AppState()
} else {
healthConnectStatus = "Health Connect sync failed: ${e.message}"
}
}
}
}
}
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicturePreview()) { bitmap ->
if (bitmap != null) selectedImage = ImagePayload.fromBitmap("camera photo", bitmap)
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) {
scope.launch(Dispatchers.IO) {
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
if (bytes != null) {
val bitmap = decodeSampledBitmap(bytes)
val payload = ImagePayload.fromBytes(
uri.lastPathSegment ?: "image",
context.contentResolver.getType(uri) ?: "image/jpeg",
bytes
)
withContext(Dispatchers.Main) {
selectedImage = payload
selectedImageBitmap = bitmap
}
}
}
}
}
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
scope.launch(Dispatchers.IO) {
val bytes = cameraImageFile.readBytes()
if (bytes.isNotEmpty()) {
val bitmap = decodeSampledBitmap(bytes)
val payload = ImagePayload.fromBytes("photo.jpg", "image/jpeg", bytes)
withContext(Dispatchers.Main) {
selectedImage = payload
selectedImageBitmap = bitmap
}
}
}
}
}
val healthPermissionLauncher = rememberLauncherForActivityResult(HealthPermissionsRequestContract()) { granted ->
if (granted.containsAll(healthConnect.permissions)) performHealthConnectSync()
else healthConnectStatus = "Health Connect permission was not granted."
}
fun handleUnauth() { connected = false; appState = AppState() }
fun syncHealthConnect() {
when (healthConnect.availability()) {
HealthConnectAvailability.Unavailable -> {
healthConnectStatus = "Health Connect is not available on this device."
}
HealthConnectAvailability.UpdateRequired -> {
healthConnectStatus = "Install or update Health Connect, then try again."
}
HealthConnectAvailability.Available -> {
scope.launch(Dispatchers.IO) {
val granted = runCatching { healthConnect.hasPermissions() }.getOrDefault(false)
withContext(Dispatchers.Main) {
if (granted) performHealthConnectSync()
else healthPermissionLauncher.launch(healthConnect.permissions)
}
}
}
}
}
fun syncState() {
syncing = true
scope.launch(Dispatchers.IO) {
@ -77,21 +169,21 @@ private fun AppRoot(repo: ApiRepository) {
if (!connected) {
CalorieTheme {
ConnectScreen(error = connectError, connecting = connecting) { url, user, pass ->
connecting = true
connectError = ""
scope.launch(Dispatchers.IO) {
runCatching { repo.login(url, user, pass) }
.onSuccess { cookie ->
repo.saveConfig(url, cookie)
withContext(Dispatchers.Main) { connecting = false; connected = true }
}
.onFailure { e ->
withContext(Dispatchers.Main) { connecting = false; connectError = e.message ?: "Connection failed" }
}
ConnectScreen(error = connectError, connecting = connecting) { url, user, pass ->
connecting = true
connectError = ""
scope.launch(Dispatchers.IO) {
runCatching { repo.login(url, user, pass) }
.onSuccess { cookie ->
repo.saveConfig(url, cookie)
withContext(Dispatchers.Main) { connecting = false; connected = true }
}
.onFailure { e ->
withContext(Dispatchers.Main) { connecting = false; connectError = e.message ?: "Connection failed" }
}
}
}
}
}
return
}
@ -102,13 +194,20 @@ private fun AppRoot(repo: ApiRepository) {
busy = busy,
editing = editing,
selectedImageName = selectedImage?.name.orEmpty(),
selectedImageBitmap = selectedImageBitmap,
planStatus = planStatus,
planBusy = planBusy,
syncing = syncing,
mealSaveCount = mealSaveCount,
onSync = { syncState() },
onPickImage = { imagePicker.launch("image/*") },
onTakePhoto = { camera.launch(null) },
onCancelEdit = { editing = null; selectedImage = null; status = "" },
onTakePhoto = { camera.launch(cameraImageUri) },
onCancelEdit = {
editing = null
selectedImage = null
selectedImageBitmap = null
status = ""
},
onSaveManualEdit = { updated ->
scope.launch(Dispatchers.IO) {
runCatching { repo.upsertEntry(updated) }
@ -116,7 +215,10 @@ private fun AppRoot(repo: ApiRepository) {
withContext(Dispatchers.Main) {
appState = appState.copy(entries = entries, trash = trash)
editing = null
selectedImage = null
selectedImageBitmap = null
status = "Meal updated."
mealSaveCount++
}
}
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() else status = "Save failed: ${e.message}" } }
@ -131,7 +233,8 @@ private fun AppRoot(repo: ApiRepository) {
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 todayHistory = buildTodayHistory(appState.entries, draft.date.ifBlank { LocalDate.now().toString() })
val nutritionJson = repo.chat(buildNutritionBody(appState.settings.taskModel, draft.description, draft.measure, visionEstimate, todayHistory))
val estimate = parser.parse(nutritionJson)
val meal = draft.copy(
id = editing?.id ?: UUID.randomUUID().toString(),
@ -147,9 +250,11 @@ private fun AppRoot(repo: ApiRepository) {
val (entries, trash) = pair
withContext(Dispatchers.Main) {
appState = appState.copy(entries = entries, trash = trash)
editing = meal
editing = null
selectedImage = null
selectedImageBitmap = null
status = "Saved. ${meal.estimate.mealName} · ${meal.estimate.calories} kcal"
mealSaveCount++
busy = false
}
}.onFailure { e ->
@ -192,18 +297,22 @@ private fun AppRoot(repo: ApiRepository) {
},
onGeneratePlan = {
val settings = appState.settings
if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) {
planStatus = "Enter height and current weight in Settings first."
val calculated = calculateCalorieTarget(settings)
if (calculated == null) {
planStatus = "Enter sex, age, height, and current weight in Settings first."
return@CalorieAiApp
}
val planSettings = if (settings.calorieTarget.isBlank()) settings.copy(calorieTarget = calculated.goalCalories.toString()) else settings
planBusy = true
planStatus = "Generating plan..."
scope.launch(Dispatchers.IO) {
runCatching {
if (planSettings != settings) repo.saveSettings(planSettings)
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 recentActivity = recentActivitySummary(appState.activities, 10)
val content = repo.chat(buildPlanBody(planSettings.taskModel, planSettings, recentMeals, recentActivity))
val plan = Plan(
id = UUID.randomUUID().toString(),
title = "Plan ${appState.plans.plans.size + 1}",
@ -213,7 +322,7 @@ private fun AppRoot(repo: ApiRepository) {
)
repo.savePlan(plan)
}.onSuccess { plans ->
withContext(Dispatchers.Main) { appState = appState.copy(plans = plans); planStatus = "Plan generated."; planBusy = false }
withContext(Dispatchers.Main) { appState = appState.copy(plans = plans, settings = planSettings); planStatus = "Plan generated."; planBusy = false }
}.onFailure { e ->
withContext(Dispatchers.Main) {
if (e is UnauthorizedException) handleUnauth()
@ -232,7 +341,8 @@ private fun AppRoot(repo: ApiRepository) {
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 recentActivity = recentActivitySummary(appState.activities, 15)
val content = repo.chat(buildTuneBody(appState.settings.taskModel, currentPlan.content, note, recentMeals, recentActivity))
val version = appState.plans.plans.maxOfOrNull { it.version }?.plus(1) ?: 2
val updated = Plan(
id = UUID.randomUUID().toString(),
@ -268,6 +378,8 @@ private fun AppRoot(repo: ApiRepository) {
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
}
},
healthConnectStatus = healthConnectStatus,
onHealthConnectSync = { syncHealthConnect() },
onDisconnect = {
repo.disconnect()
connected = false
@ -278,6 +390,14 @@ private fun AppRoot(repo: ApiRepository) {
)
}
private fun decodeSampledBitmap(bytes: ByteArray, maxWidth: Int = 600): Bitmap {
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
var sample = 1
while (opts.outWidth / sample > maxWidth) sample *= 2
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample })!!
}
private fun buildVisionBody(model: String, description: String, measure: String, image: ImagePayload): JSONObject =
JSONObject().put("model", model).put("temperature", 0.15)
.put("messages", JSONArray().put(JSONObject().put("role", "user").put("content",
@ -286,26 +406,68 @@ private fun buildVisionBody(model: String, description: String, measure: String,
.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 =
private fun buildTodayHistory(entries: List<MealEntry>, date: String): String {
val todayEntries = entries.filter { it.date == date && it.trashedAt.isBlank() }
if (todayEntries.isEmpty()) return "No meals logged yet today."
val rows = todayEntries.joinToString("\n") {
"- ${it.estimate.mealName}: ${it.estimate.calories} kcal, P${it.estimate.proteinGrams}g C${it.estimate.carbsGrams}g F${it.estimate.fatGrams}g"
}
val totals = todayEntries.fold(intArrayOf(0, 0, 0, 0)) { acc, e ->
acc[0] += e.estimate.calories; acc[1] += e.estimate.proteinGrams
acc[2] += e.estimate.carbsGrams; acc[3] += e.estimate.fatGrams; acc
}
return "$rows\nTotal so far: ${totals[0]} kcal, P${totals[1]}g C${totals[2]}g F${totals[3]}g"
}
private fun recentActivitySummary(activities: List<ActivityRecord>, limit: Int): String =
activities.take(limit).joinToString("; ") {
val steps = if (it.steps > 0) ", ${it.steps} steps" else ""
"${it.startAt.take(10)} ${it.title}: ${it.calories.toInt()} active kcal$steps (${it.sourceName})"
}.ifBlank { "none synced yet" }
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String, todayHistory: 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", "system").put("content",
"You are a nutrition estimator. Output ONLY a raw JSON object — no markdown fences, no explanation, no text before or after.\n" +
"You MUST use these exact key names: mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\n" +
"calories, proteinGrams, carbsGrams, fatGrams must be integers. fruitServings and vegetableServings must be numbers. foodGroups must be a short comma-separated string.\n" +
"Use the notes field for a brief personalised observation based on the user's diet history (e.g. macro balance, produce intake, daily total context).\n" +
"Output format: {\"mealName\":\"...\",\"calories\":0,\"proteinGrams\":0,\"carbsGrams\":0,\"fatGrams\":0,\"fruitServings\":0,\"vegetableServings\":0,\"foodGroups\":\"...\",\"notes\":\"...\"}"
))
.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"
"Estimate nutrition for this meal.\nDescription: $description\nPortion: $measure\nImage analysis: $visionEstimate\n\nUser's meals logged today:\n$todayHistory"
)))
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String, recentActivity: 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", "system").put("content", "Create safe, concise weight-management 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. Make it feel like a personalized plan built from onboarding answers, not a generic article."))
.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"
"Create a personalized plan using these details:\n" +
"Goal: ${settings.goal}\n" +
"Sex: ${settings.sex}\n" +
"Age: ${settings.age}\n" +
"Height: ${settings.heightCm} cm\n" +
"Current weight: ${settings.weightKg} kg\n" +
"Target weight: ${settings.targetWeightKg.ifBlank { "not set" }} kg\n" +
"Activity: ${settings.activityLevel}\n" +
"Pace: ${settings.pace}\n" +
"Calculated targets: ${calorieTargetSummary(settings)}\n" +
"Motivation: ${settings.motivation.ifBlank { "not set" }}\n" +
"Biggest challenge: ${settings.challenge.ifBlank { "not set" }}\n" +
"Tracking style: ${settings.trackingStyle.ifBlank { "not set" }}\n" +
"Calorie-counting experience: ${settings.calorieCountingExperience.ifBlank { "not set" }}\n" +
"Fasting interest: ${settings.fastingInterest.ifBlank { "not set" }}\n" +
"Recent meals: $recentMeals\n" +
"Recent activity: $recentActivity\n\n" +
"Include: daily calorie target, maintenance calories, protein target, meal logging strategy, activity adjustment rules, challenge-specific tactics, weekly review steps, and safety notes."
)))
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String): JSONObject =
private fun buildTuneBody(model: String, currentContent: String, note: String, recentMeals: String, recentActivity: 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"
"Current plan:\n$currentContent\n\nUpdate notes:\n$note\n\nRecent meals:\n$recentMeals\n\nRecent activity:\n$recentActivity"
)))

View file

@ -7,10 +7,12 @@ import org.json.JSONObject
class NutritionParser {
fun parse(content: String): NutritionEstimate {
var cleaned = content.trim()
.removePrefix("```json")
.removePrefix("```")
.removeSuffix("```")
.trim()
// Strip leading markdown fence line (e.g. "```json\n")
val fenceEnd = cleaned.indexOf('\n')
if (cleaned.startsWith("```") && fenceEnd >= 0) cleaned = cleaned.substring(fenceEnd + 1)
if (cleaned.endsWith("```")) cleaned = cleaned.dropLast(3)
cleaned = cleaned.trim()
// Extract the JSON object in case there's any surrounding prose
val start = cleaned.indexOf('{')
val end = cleaned.lastIndexOf('}')
if (start >= 0 && end > start) cleaned = cleaned.substring(start, end + 1)
@ -21,8 +23,8 @@ class NutritionParser {
proteinGrams = json.optInt("proteinGrams").coerceAtLeast(0),
carbsGrams = json.optInt("carbsGrams").coerceAtLeast(0),
fatGrams = json.optInt("fatGrams").coerceAtLeast(0),
fruitServings = json.optDouble("fruitServings", json.optDouble("fruitsServings", 0.0)).coerceAtLeast(0.0),
vegetableServings = json.optDouble("vegetableServings", json.optDouble("vegetablesServings", 0.0)).coerceAtLeast(0.0),
fruitServings = json.optDouble("fruitServings", 0.0).coerceAtLeast(0.0),
vegetableServings = json.optDouble("vegetableServings", 0.0).coerceAtLeast(0.0),
foodGroups = stringifyGroups(json.opt("foodGroups")),
notes = json.optString("notes", ""),
raw = content

View file

@ -43,12 +43,14 @@ class ApiRepository(private val prefs: SharedPreferences) {
fun getAppState(): AppState {
val entries = JSONObject(get("/api/entries"))
val activities = JSONObject(get("/api/activities"))
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")),
activities = parseActivities(activities.optJSONArray("activities")),
plans = plans,
settings = settings,
models = models
@ -75,12 +77,20 @@ class ApiRepository(private val prefs: SharedPreferences) {
fun saveSettings(settings: ServerSettings): ServerSettings =
parseSettings(JSONObject(post("/api/settings", JSONObject()
.put("sex", settings.sex)
.put("age", settings.age)
.put("heightCm", settings.heightCm)
.put("weightKg", settings.weightKg)
.put("targetWeightKg", settings.targetWeightKg)
.put("goal", settings.goal)
.put("calorieTarget", settings.calorieTarget)
.put("activityLevel", settings.activityLevel)
.put("pace", settings.pace)
.put("motivation", settings.motivation)
.put("challenge", settings.challenge)
.put("trackingStyle", settings.trackingStyle)
.put("calorieCountingExperience", settings.calorieCountingExperience)
.put("fastingInterest", settings.fastingInterest)
.toString())))
fun getPlans(): PlansState = parsePlans(JSONObject(get("/api/plans")))
@ -101,6 +111,14 @@ class ApiRepository(private val prefs: SharedPreferences) {
fun deletePlan(id: String): PlansState =
parsePlans(JSONObject(post("/api/plans/delete", JSONObject().put("id", id).toString())))
fun syncActivities(activities: List<ActivityRecord>): List<ActivityRecord> {
val body = JSONObject()
.put("source", "health_connect")
.put("activities", JSONArray(activities.map { it.toJson() }))
.toString()
return parseActivities(JSONObject(post("/api/activities/sync", body)).optJSONArray("activities"))
}
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()
@ -139,6 +157,9 @@ class ApiRepository(private val prefs: SharedPreferences) {
private fun parseEntries(arr: JSONArray?): List<MealEntry> =
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toMealEntry() }
private fun parseActivities(arr: JSONArray?): List<ActivityRecord> =
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toActivityRecord() }
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"))
@ -147,12 +168,20 @@ class ApiRepository(private val prefs: SharedPreferences) {
private fun parseSettings(json: JSONObject) = ServerSettings(
visionModel = json.optString("visionModel"),
taskModel = json.optString("taskModel"),
sex = json.optString("sex"),
age = json.optString("age"),
heightCm = json.optString("heightCm"),
weightKg = json.optString("weightKg"),
targetWeightKg = json.optString("targetWeightKg"),
goal = json.optString("goal", "Lose weight").ifBlank { "Lose weight" },
calorieTarget = json.optString("calorieTarget"),
activityLevel = json.optString("activityLevel", "Moderate").ifBlank { "Moderate" },
pace = json.optString("pace", "Steady").ifBlank { "Steady" }
pace = json.optString("pace", "Steady").ifBlank { "Steady" },
motivation = json.optString("motivation"),
challenge = json.optString("challenge"),
trackingStyle = json.optString("trackingStyle"),
calorieCountingExperience = json.optString("calorieCountingExperience"),
fastingInterest = json.optString("fastingInterest")
)
private fun parsePlans(json: JSONObject): PlansState {
@ -213,3 +242,30 @@ fun JSONObject.toMealEntry(): MealEntry = MealEntry(
foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi")
)
)
fun ActivityRecord.toJson(): JSONObject = JSONObject()
.put("id", id)
.put("source", source)
.put("sourceName", sourceName)
.put("type", type)
.put("title", title)
.put("startAt", startAt)
.put("endAt", endAt)
.put("calories", calories)
.put("steps", steps)
.put("distanceMeters", distanceMeters)
.put("syncedAt", syncedAt)
fun JSONObject.toActivityRecord(): ActivityRecord = ActivityRecord(
id = optString("id"),
source = optString("source", "external"),
sourceName = optString("sourceName", optString("source", "External")),
type = optString("type", "Activity"),
title = optString("title", optString("type", "Activity")),
startAt = optString("startAt"),
endAt = optString("endAt"),
calories = optDouble("calories"),
steps = optLong("steps"),
distanceMeters = optDouble("distanceMeters"),
syncedAt = optString("syncedAt")
)

View file

@ -0,0 +1,66 @@
package com.danvics.calorieai.data
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.roundToInt
data class CalorieTarget(
val bmr: Int,
val maintenanceCalories: Int,
val goalCalories: Int,
val proteinTarget: Int,
val weeklyKg: Double,
val weeksToTarget: Int
)
fun calculateCalorieTarget(settings: ServerSettings): CalorieTarget? {
val weight = settings.weightKg.toDoubleOrNull()?.takeIf { it > 0 } ?: return null
val height = settings.heightCm.toDoubleOrNull()?.takeIf { it > 0 } ?: return null
val age = settings.age.toDoubleOrNull()?.takeIf { it > 0 } ?: return null
if (settings.sex.isBlank()) return null
val bmr = 10 * weight + 6.25 * height - 5 * age + if (settings.sex == "Male") 5 else -161
val maintenance = roundTo(bmr * activityFactor(settings.activityLevel))
val goalCalories = maxOf(if (settings.sex == "Male") 1500 else 1200, roundTo(maintenance.toDouble() + paceDelta(settings.pace, settings.goal)))
val protein = roundTo(weight * if (settings.goal == "Build muscle") 1.8 else if (settings.goal == "Maintain weight") 1.3 else 1.6, 5)
val weeklyKg = weeklyChangeKg(settings.pace, settings.goal)
val targetWeight = settings.targetWeightKg.toDoubleOrNull() ?: 0.0
val remaining = if (targetWeight > 0) targetWeight - weight else 0.0
val weeks = if (remaining != 0.0 && weeklyKg != 0.0 && remaining.sign() == weeklyKg.sign()) ceil(abs(remaining / weeklyKg)).toInt() else 0
return CalorieTarget(roundTo(bmr), maintenance, goalCalories, protein, weeklyKg, weeks)
}
fun calorieTargetSummary(settings: ServerSettings): String {
val target = calculateCalorieTarget(settings) ?: return "Add sex, age, height, weight, and activity level to calculate a daily target."
val pace = when {
target.weeklyKg > 0 -> "gain about ${"%.2f".format(target.weeklyKg)} kg/week"
target.weeklyKg < 0 -> "lose about ${"%.2f".format(abs(target.weeklyKg))} kg/week"
else -> "maintain weight"
}
val weeks = if (target.weeksToTarget > 0) " Estimated time to target: ${target.weeksToTarget} weeks." else ""
return "${target.goalCalories} kcal/day, ${target.proteinTarget} g protein/day, $pace.$weeks"
}
private fun activityFactor(level: String) = when (level) {
"Low" -> 1.2
"Moderate" -> 1.55
"High" -> 1.725
else -> 1.375
}
private fun paceDelta(pace: String, goal: String) = when (goal) {
"Gain weight", "Build muscle" -> if (pace == "Aggressive") 500 else if (pace == "Steady") 350 else 250
"Maintain weight" -> 0
else -> if (pace == "Aggressive") -750 else if (pace == "Steady") -500 else -250
}
private fun weeklyChangeKg(pace: String, goal: String): Double {
if (goal == "Maintain weight") return 0.0
val amount = if (pace == "Aggressive") 0.75 else if (pace == "Steady") 0.5 else 0.25
return if (goal == "Gain weight" || goal == "Build muscle") amount else -amount
}
private fun roundTo(value: Double, step: Int = 10) = (value / step).roundToInt() * step
private fun Double.sign() = if (this > 0) 1 else if (this < 0) -1 else 0

View file

@ -41,6 +41,20 @@ data class DayTotals(
data class ModelOption(val id: String, val name: String = id)
data class ActivityRecord(
val id: String,
val source: String,
val sourceName: String,
val type: String,
val title: String,
val startAt: String,
val endAt: String,
val calories: Double = 0.0,
val steps: Long = 0,
val distanceMeters: Double = 0.0,
val syncedAt: String = ""
)
data class LocalConfig(val serverUrl: String = "", val sessionCookie: String = "") {
fun isConnected() = serverUrl.isNotBlank() && sessionCookie.isNotBlank()
}
@ -62,17 +76,26 @@ data class PlansState(
data class ServerSettings(
val visionModel: String = "",
val taskModel: String = "",
val sex: String = "",
val age: String = "",
val heightCm: String = "",
val weightKg: String = "",
val targetWeightKg: String = "",
val goal: String = "Lose weight",
val calorieTarget: String = "",
val activityLevel: String = "Moderate",
val pace: String = "Steady"
val pace: String = "Steady",
val motivation: String = "",
val challenge: String = "",
val trackingStyle: String = "",
val calorieCountingExperience: String = "",
val fastingInterest: String = ""
)
data class AppState(
val entries: List<MealEntry> = emptyList(),
val trash: List<MealEntry> = emptyList(),
val activities: List<ActivityRecord> = emptyList(),
val plans: PlansState = PlansState(),
val settings: ServerSettings = ServerSettings(),
val models: List<ModelOption> = emptyList()

View file

@ -0,0 +1,98 @@
package com.danvics.calorieai.integrations
import android.content.Context
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.request.AggregateRequest
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import com.danvics.calorieai.data.ActivityRecord
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
class HealthConnectSync(private val context: Context) {
val permissions = setOf(
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
)
fun availability(): HealthConnectAvailability = when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_AVAILABLE -> HealthConnectAvailability.Available
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> HealthConnectAvailability.UpdateRequired
else -> HealthConnectAvailability.Unavailable
}
suspend fun hasPermissions(): Boolean {
if (availability() != HealthConnectAvailability.Available) return false
val granted = HealthConnectClient.getOrCreate(context).permissionController.getGrantedPermissions()
return granted.containsAll(permissions)
}
suspend fun readActivities(days: Long = 14): List<ActivityRecord> {
if (!hasPermissions()) return emptyList()
val client = HealthConnectClient.getOrCreate(context)
val zone = ZoneId.systemDefault()
val today = LocalDate.now(zone)
val syncedAt = Instant.now().toString()
val daily = (0 until days).mapNotNull { offset ->
val date = today.minusDays(offset)
val start = date.atStartOfDay(zone).toInstant()
val end = date.plusDays(1).atStartOfDay(zone).toInstant()
val aggregate = client.aggregate(
AggregateRequest(
metrics = setOf(StepsRecord.COUNT_TOTAL, ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL),
timeRangeFilter = TimeRangeFilter.between(start, end),
)
)
val steps = aggregate[StepsRecord.COUNT_TOTAL] ?: 0L
val calories = aggregate[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories ?: 0.0
if (steps == 0L && calories <= 0.0) null else ActivityRecord(
id = "health-connect-day-${date}",
source = "health_connect",
sourceName = "Health Connect",
type = "Daily activity",
title = "Daily activity",
startAt = start.toString(),
endAt = end.toString(),
calories = calories,
steps = steps,
syncedAt = syncedAt,
)
}
val rangeStart = today.minusDays(days - 1).atStartOfDay(zone).toInstant()
val rangeEnd = today.plusDays(1).atStartOfDay(zone).toInstant()
val sessions = client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(rangeStart, rangeEnd),
pageSize = 200,
)
).records.map { session ->
val source = session.metadata.dataOrigin.packageName
ActivityRecord(
id = "health-connect-session-${session.metadata.id}",
source = "health_connect",
sourceName = source.ifBlank { "Health Connect" },
type = "Exercise ${session.exerciseType}",
title = session.title ?: "Exercise session",
startAt = session.startTime.toString(),
endAt = session.endTime.toString(),
syncedAt = syncedAt,
)
}
return (daily + sessions).sortedByDescending { it.startAt }
}
}
enum class HealthConnectAvailability {
Available,
UpdateRequired,
Unavailable,
}

View file

@ -1,5 +1,6 @@
package com.danvics.calorieai.ui
import android.graphics.Bitmap
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@ -29,9 +30,11 @@ fun CalorieAiApp(
busy: Boolean,
editing: MealEntry?,
selectedImageName: String,
selectedImageBitmap: Bitmap?,
planStatus: String,
planBusy: Boolean,
syncing: Boolean,
mealSaveCount: Int,
onSync: () -> Unit,
onPickImage: () -> Unit,
onTakePhoto: () -> Unit,
@ -47,14 +50,24 @@ fun CalorieAiApp(
onTunePlan: (String, String) -> Unit,
onSelectPlan: (String) -> Unit,
onDeletePlan: (String) -> Unit,
healthConnectStatus: String,
onHealthConnectSync: () -> Unit,
onDisconnect: () -> Unit
) {
CalorieTheme {
var screen by remember { mutableStateOf(Screen.Dashboard) }
LaunchedEffect(mealSaveCount) {
if (mealSaveCount > 0) screen = Screen.Dashboard
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(screen.label, style = MaterialTheme.typography.titleLarge) },
title = {
val title = if (screen == Screen.Log && editing != null) "Edit meal" else screen.label
Text(title, style = MaterialTheme.typography.titleLarge)
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
actions = {
if (syncing) {
@ -84,8 +97,19 @@ fun CalorieAiApp(
) { padding ->
Box(Modifier.padding(padding).fillMaxSize()) {
when (screen) {
Screen.Dashboard -> DashboardScreen(appState.entries, appState.settings, onLog = { screen = Screen.Log })
Screen.Log -> LogMealScreen(editing, selectedImageName, status, busy, onPickImage, onTakePhoto, onAnalyze, onSaveManualEdit, onCancelEdit)
Screen.Dashboard -> DashboardScreen(appState.entries, appState.activities, appState.settings, onLog = { screen = Screen.Log })
Screen.Log -> LogMealScreen(
editing = editing,
selectedImageName = selectedImageName,
selectedImageBitmap = selectedImageBitmap,
status = status,
busy = busy,
onPickImage = onPickImage,
onTakePhoto = onTakePhoto,
onAnalyze = onAnalyze,
onSaveManualEdit = onSaveManualEdit,
onCancelEdit = onCancelEdit
)
Screen.Diary -> DiaryScreen(
entries = appState.entries,
trash = appState.trash,
@ -95,7 +119,7 @@ fun CalorieAiApp(
onClearTrash = onClearTrash
)
Screen.Plans -> PlansScreen(appState.settings, appState.plans, planStatus, planBusy, onGeneratePlan, onTunePlan, onSelectPlan, onDeletePlan)
Screen.Settings -> SettingsScreen(appState.settings, serverUrl, onSettingsChange, onDisconnect)
Screen.Settings -> SettingsScreen(appState.settings, serverUrl, healthConnectStatus, onSettingsChange, onHealthConnectSync, onDisconnect)
}
}
}

View file

@ -1,12 +1,18 @@
package com.danvics.calorieai.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.DayTotals
@Composable
fun SectionCard(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
@ -78,6 +84,38 @@ fun MacroBar(label: String, grams: Int, totalGrams: Int, color: Color) {
}
}
@Composable
fun WeekBarChart(week: List<DayTotals>, modifier: Modifier = Modifier) {
val primary = MaterialTheme.colorScheme.primary
val track = MaterialTheme.colorScheme.surfaceVariant
val maxCal = week.maxOfOrNull { it.calories }.takeIf { it != null && it > 0 } ?: 1
Column(modifier) {
Canvas(modifier = Modifier.fillMaxWidth().height(96.dp)) {
val slotW = size.width / week.size
val barW = slotW * 0.55f
val maxH = size.height
week.forEachIndexed { i, day ->
val x = i * slotW + (slotW - barW) / 2
val fillH = (day.calories.toFloat() / maxCal) * maxH
drawRoundRect(track, Offset(x, 0f), Size(barW, maxH), CornerRadius(6f))
if (fillH > 0f) drawRoundRect(primary, Offset(x, maxH - fillH), Size(barW, fillH), CornerRadius(6f))
}
}
Spacer(Modifier.height(4.dp))
Row(Modifier.fillMaxWidth()) {
week.forEach { day ->
Text(
day.label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
}
}
}
@Composable
fun CalorieGoalBar(consumed: Int, target: Int) {
val fraction = (consumed.toFloat() / target).coerceIn(0f, 1f)

View file

@ -1,12 +1,22 @@
package com.danvics.calorieai.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@Composable
@ -14,6 +24,8 @@ fun ConnectScreen(error: String, connecting: Boolean, onConnect: (String, String
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("admin") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
@ -33,7 +45,9 @@ fun ConnectScreen(error: String, connecting: Boolean, onConnect: (String, String
label = { Text("Server URL") },
placeholder = { Text("http://192.168.1.10:3000") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
@ -41,7 +55,9 @@ fun ConnectScreen(error: String, connecting: Boolean, onConnect: (String, String
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
@ -50,7 +66,21 @@ fun ConnectScreen(error: String, connecting: Boolean, onConnect: (String, String
label = { Text("Password") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation()
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
focusManager.clearFocus()
if (serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank() && !connecting)
onConnect(serverUrl.trim(), username.trim(), password)
}),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (passwordVisible) "Hide password" else "Show password"
)
}
}
)
Spacer(Modifier.height(20.dp))
Button(

View file

@ -6,18 +6,23 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.ActivityRecord
import com.danvics.calorieai.data.MealEntry
import com.danvics.calorieai.data.MealStats
import com.danvics.calorieai.data.ServerSettings
import com.danvics.calorieai.data.calculateCalorieTarget
import java.time.LocalDate
@Composable
fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: () -> Unit) = Page {
fun DashboardScreen(entries: List<MealEntry>, activities: List<ActivityRecord>, settings: ServerSettings, onLog: () -> Unit) = Page {
val today = MealStats.totalsForDate(entries, LocalDate.now().toString())
val todayActivities = activities.filter { it.startAt.take(10) == LocalDate.now().toString() }
val activityCalories = todayActivities.sumOf { it.calories }.toInt()
val activitySteps = todayActivities.sumOf { it.steps }
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
val calorieTarget = settings.calorieTarget.toIntOrNull() ?: calculateCalorieTarget(settings)?.goalCalories ?: 0
Card(
modifier = Modifier.fillMaxWidth(),
@ -36,6 +41,11 @@ fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: (
StatTile("7-day avg", "$avgCalories kcal", Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
StatTile("Activity", "$activityCalories kcal", Modifier.weight(1f))
StatTile("Steps", activitySteps.toString(), Modifier.weight(1f))
}
if (calorieTarget > 0) {
SectionCard("Daily calorie goal") {
CalorieGoalBar(today.calories, calorieTarget)
@ -66,6 +76,8 @@ fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: (
if (week.any { it.calories > 0 }) {
SectionCard("Last 7 days") {
WeekBarChart(week, Modifier.fillMaxWidth())
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
week.reversed().forEach { day ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(day.label, style = MaterialTheme.typography.bodyMedium)
@ -74,4 +86,17 @@ fun DashboardScreen(entries: List<MealEntry>, settings: ServerSettings, onLog: (
}
}
}
SectionCard("Synced activity") {
if (todayActivities.isEmpty()) {
Text("No activity synced yet. Connect Garmin or gym apps through Health Connect.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
todayActivities.take(5).forEach { activity ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(activity.title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Text("${activity.calories.toInt()} kcal", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}

View file

@ -52,16 +52,17 @@ private fun EntriesContent(
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)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
"${selected.size} selected",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.align(Alignment.CenterVertically).weight(1f)
modifier = Modifier.weight(1f)
)
TextButton(onClick = { selected = emptySet() }) { Text("Deselect") }
Button(
onClick = { onMoveToTrash(selected); selected = emptySet() },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) { Text("Move to trash") }
) { Text("Trash") }
}
}

View file

@ -1,22 +1,40 @@
package com.danvics.calorieai.ui
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.MealEntry
import com.danvics.calorieai.data.NutritionEstimate
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Calendar
private val MEAL_TYPES = listOf("Breakfast", "Lunch", "Dinner", "Snack", "Other")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogMealScreen(
editing: MealEntry?,
selectedImageName: String,
selectedImageBitmap: Bitmap?,
status: String,
busy: Boolean,
onPickImage: () -> Unit,
@ -25,38 +43,139 @@ fun LogMealScreen(
onSaveManualEdit: (MealEntry) -> Unit,
onCancelEdit: () -> Unit
) = Page {
val context = LocalContext.current
var date by remember(editing?.id) { mutableStateOf(editing?.date ?: LocalDate.now().toString()) }
var time by remember(editing?.id) { mutableStateOf(editing?.time ?: LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"))) }
var mealType by remember(editing?.id) { mutableStateOf(editing?.mealType ?: "Breakfast") }
var description by remember(editing?.id) { mutableStateOf(editing?.description ?: "") }
var measure by remember(editing?.id) { mutableStateOf(editing?.measure ?: "") }
var estimate by remember(editing?.id) { mutableStateOf(editing?.estimate ?: NutritionEstimate()) }
var showImageSheet by remember { mutableStateOf(false) }
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
if (showDatePicker) {
val cal = Calendar.getInstance()
runCatching {
val d = LocalDate.parse(date)
cal.set(d.year, d.monthValue - 1, d.dayOfMonth)
}
DisposableEffect(Unit) {
val dialog = DatePickerDialog(
context,
{ _, year, month, day -> date = "%04d-%02d-%02d".format(year, month + 1, day) },
cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)
)
dialog.setOnDismissListener { showDatePicker = false }
dialog.show()
onDispose { if (dialog.isShowing) dialog.dismiss() }
}
}
if (showTimePicker) {
val h = time.split(":").getOrNull(0)?.toIntOrNull() ?: LocalTime.now().hour
val m = time.split(":").getOrNull(1)?.toIntOrNull() ?: LocalTime.now().minute
DisposableEffect(Unit) {
val dialog = TimePickerDialog(
context,
{ _, hour, minute -> time = "%02d:%02d".format(hour, minute) },
h, m, true
)
dialog.setOnDismissListener { showTimePicker = false }
dialog.show()
onDispose { if (dialog.isShowing) dialog.dismiss() }
}
}
if (showImageSheet) {
ModalBottomSheet(onDismissRequest = { showImageSheet = false }) {
Column(Modifier.padding(bottom = 32.dp)) {
Text(
"Add photo",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
HorizontalDivider()
ListItem(
headlineContent = { Text("Take photo") },
leadingContent = { Icon(Icons.Default.CameraAlt, contentDescription = null) },
modifier = Modifier.clickable { showImageSheet = false; onTakePhoto() }
)
ListItem(
headlineContent = { Text("Choose from gallery") },
leadingContent = { Icon(Icons.Default.PhotoLibrary, contentDescription = null) },
modifier = Modifier.clickable { showImageSheet = false; onPickImage() }
)
}
}
}
SectionCard("Meal details") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Field("Date", date, { date = it }, modifier = Modifier.weight(1f), placeholder = "YYYY-MM-DD")
Field("Time", time, { time = it }, modifier = Modifier.weight(1f), placeholder = "HH:MM")
Box(Modifier.weight(1f)) {
OutlinedTextField(
value = date,
onValueChange = {},
readOnly = true,
label = { Text("Date") },
trailingIcon = { Icon(Icons.Default.CalendarToday, contentDescription = null, modifier = Modifier.size(18.dp)) },
modifier = Modifier.fillMaxWidth()
)
Box(Modifier.matchParentSize().clickable { showDatePicker = true })
}
Box(Modifier.weight(1f)) {
OutlinedTextField(
value = time,
onValueChange = {},
readOnly = true,
label = { Text("Time") },
trailingIcon = { Icon(Icons.Default.Schedule, contentDescription = null, modifier = Modifier.size(18.dp)) },
modifier = Modifier.fillMaxWidth()
)
Box(Modifier.matchParentSize().clickable { showTimePicker = true })
}
}
DropdownField("Meal type", MEAL_TYPES, mealType, { mealType = it })
Field("Description", description, { description = it }, singleLine = false, placeholder = "e.g. grilled salmon, rice, broccoli")
Field("Portion or measure", measure, { measure = it }, placeholder = "e.g. one plate, 450g")
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 (selectedImageBitmap != null) {
Image(
bitmap = selectedImageBitmap.asImageBitmap(),
contentDescription = "Meal photo preview",
modifier = Modifier.fillMaxWidth().height(180.dp).clip(MaterialTheme.shapes.medium),
contentScale = ContentScale.Crop
)
}
if (selectedImageName.isNotBlank()) {
Text("Image: $selectedImageName", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedButton(
onClick = { showImageSheet = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(
if (selectedImageName.isNotBlank()) Icons.Default.CameraAlt else Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(if (selectedImageName.isNotBlank()) "Change photo" else "Add photo")
}
Button(
enabled = !busy && (description.isNotBlank() || selectedImageName.isNotBlank()),
onClick = { onAnalyze(buildDraft(editing, date, time, mealType, description, measure, estimate)) },
modifier = Modifier.fillMaxWidth()
) { Text(if (busy) "Analyzing..." else if (editing == null) "Analyze and save" else "Analyze again") }
if (status.isNotBlank()) {
Text(
status,
style = MaterialTheme.typography.bodySmall,
color = if (status.startsWith("Analysis failed") || status.startsWith("Set API")) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
color = if (status.startsWith("Analysis failed") || status.startsWith("No AI") || status.startsWith("Save failed"))
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.primary
)
}
}
@ -89,5 +208,15 @@ fun LogMealScreen(
}
}
private fun buildDraft(editing: MealEntry?, date: String, time: String, mealType: String, description: String, measure: String, estimate: NutritionEstimate) =
MealEntry(editing?.id.orEmpty(), date, time, mealType, description, measure, editing?.imageIncluded ?: false, editing?.imageName.orEmpty(), editing?.visionEstimate.orEmpty(), estimate)
private fun buildDraft(
editing: MealEntry?,
date: String,
time: String,
mealType: String,
description: String,
measure: String,
estimate: NutritionEstimate
) = MealEntry(
editing?.id.orEmpty(), date, time, mealType, description, measure,
editing?.imageIncluded ?: false, editing?.imageName.orEmpty(), editing?.visionEstimate.orEmpty(), estimate
)

View file

@ -3,11 +3,14 @@ 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.PlansState
import com.danvics.calorieai.data.ServerSettings
import com.danvics.calorieai.data.calculateCalorieTarget
import com.danvics.calorieai.data.calorieTargetSummary
@Composable
fun PlansScreen(
@ -21,22 +24,44 @@ fun PlansScreen(
onDelete: (String) -> Unit
) = Page {
val selectedPlan = plansState.plans.find { it.id == plansState.selectedPlanId } ?: plansState.plans.firstOrNull()
val calorieTarget = calculateCalorieTarget(settings)
var tuneNote by remember { mutableStateOf("") }
var showDeleteConfirm by remember { mutableStateOf(false) }
if (showDeleteConfirm && selectedPlan != null) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text("Delete plan?") },
text = { Text("\"${selectedPlan.title}\" will be permanently deleted.") },
confirmButton = {
TextButton(
onClick = { onDelete(selectedPlan.id); showDeleteConfirm = false },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") }
}
)
}
SectionCard("Generate plan") {
Text(
"Your plan is built from your profile and recent meals, then updated as you log and progress.",
"Your plan is built from your body stats, goal, habits, challenges, and recent meals.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (calorieTarget != null) {
Text(calorieTargetSummary(settings), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
Button(
enabled = !planBusy && settings.heightCm.isNotBlank() && settings.weightKg.isNotBlank(),
enabled = !planBusy && calorieTarget != null,
onClick = onGenerate,
modifier = Modifier.fillMaxWidth()
) { Text(if (planBusy) "Generating..." else "Generate new plan") }
if (settings.heightCm.isBlank() || settings.weightKg.isBlank()) {
if (calorieTarget == null) {
Text(
"Fill in your height and weight in Settings first.",
"Fill in sex, age, height, and weight in Settings first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
@ -47,16 +72,25 @@ fun PlansScreen(
}
if (selectedPlan != null) {
SectionCard("${selectedPlan.title}") {
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))
Text(
selectedPlan.content,
style = MaterialTheme.typography.bodyMedium,
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight,
modifier = Modifier.padding(14.dp)
)
}
Field("Update notes", tuneNote, { tuneNote = it }, singleLine = false, placeholder = "e.g. walked 7k steps daily, felt hungry at night, lost 0.5 kg")
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(),
@ -64,7 +98,7 @@ fun PlansScreen(
modifier = Modifier.weight(1f)
) { Text(if (planBusy) "Updating..." else "Update plan") }
OutlinedButton(
onClick = { onDelete(selectedPlan.id) },
onClick = { showDeleteConfirm = true },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) { Text("Delete") }
@ -72,28 +106,54 @@ fun PlansScreen(
}
} else if (!planBusy) {
SectionCard("No plan yet") {
Text("Generate your first plan to get started.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
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(
val isSelected = plan.id == plansState.selectedPlanId
Card(
onClick = { onSelect(plan.id) },
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface
)
) {
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
)
Row(
Modifier.padding(12.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(Modifier.weight(1f)) {
Text(
plan.title,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurface
)
Text(
"Version ${plan.version} · ${plan.createdAt.take(10)}",
style = MaterialTheme.typography.labelSmall,
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isSelected) {
Text(
"Active",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}

View file

@ -5,22 +5,45 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.danvics.calorieai.data.calculateCalorieTarget
import com.danvics.calorieai.data.calorieTargetSummary
import com.danvics.calorieai.data.ServerSettings
import kotlinx.coroutines.delay
private val SEX_OPTIONS = listOf("Female", "Male")
private val GOAL_OPTIONS = listOf("Lose weight", "Maintain weight", "Gain weight", "Build muscle")
private val ACTIVITY_LEVELS = listOf("Low", "Moderate", "High")
private val PACE_OPTIONS = listOf("Gentle", "Steady", "Aggressive")
private val MOTIVATIONS = listOf("Improve my overall health", "Feel more confident", "Increase my fitness level", "Prepare for an event", "Improve my relationship with food")
private val CHALLENGES = listOf("Resisting cravings", "Staying motivated", "Reducing portion sizes", "Knowing what to eat", "Being too busy")
private val TRACKING_STYLES = listOf("Photo log meals", "Log before eating", "Meal prep and plan ahead", "Track calories closely", "Build a streak")
private val CALORIE_EXPERIENCE = listOf("New to calorie counting", "Tried before", "Experienced")
private val FASTING_INTEREST = listOf("Interested", "Tried it before", "Not interested")
@Composable
fun SettingsScreen(
settings: ServerSettings,
serverUrl: String,
healthConnectStatus: String,
onSettingsChange: (ServerSettings) -> Unit,
onHealthConnectSync: () -> Unit,
onDisconnect: () -> Unit
) = Page {
var draft by remember(settings) { mutableStateOf(settings) }
var status by remember { mutableStateOf("") }
LaunchedEffect(status) {
if (status.isNotBlank()) {
delay(3000)
status = ""
}
}
SectionCard("Profile") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DropdownField("Sex", SEX_OPTIONS, draft.sex, { draft = draft.copy(sex = it) }, modifier = Modifier.weight(1f))
Field("Age", draft.age, { draft = draft.copy(age = it) }, modifier = Modifier.weight(1f), placeholder = "36")
}
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")
@ -29,12 +52,38 @@ fun SettingsScreen(
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("Main goal", GOAL_OPTIONS, draft.goal, { draft = draft.copy(goal = it) })
DropdownField("Activity level", ACTIVITY_LEVELS, draft.activityLevel, { draft = draft.copy(activityLevel = it) })
DropdownField("Pace", PACE_OPTIONS, draft.pace, { draft = draft.copy(pace = it) })
val calculated = calculateCalorieTarget(draft)
if (calculated != null) {
AssistChip(onClick = { draft = draft.copy(calorieTarget = calculated.goalCalories.toString()) }, label = { Text("Use ${calculated.goalCalories} kcal/day") })
Text(calorieTargetSummary(draft), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Text("Add sex, age, height, and weight to calculate calories.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
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("Personalization") {
DropdownField("Why this matters", MOTIVATIONS, draft.motivation, { draft = draft.copy(motivation = it) })
DropdownField("Biggest challenge", CHALLENGES, draft.challenge, { draft = draft.copy(challenge = it) })
DropdownField("Tracking style", TRACKING_STYLES, draft.trackingStyle, { draft = draft.copy(trackingStyle = it) })
DropdownField("Calorie-counting experience", CALORIE_EXPERIENCE, draft.calorieCountingExperience, { draft = draft.copy(calorieCountingExperience = it) })
DropdownField("Fasting interest", FASTING_INTEREST, draft.fastingInterest, { draft = draft.copy(fastingInterest = it) })
}
SectionCard("Garmin and gym machines") {
Text(
"Sync Garmin Connect, supported gym equipment apps, and other wearables through Android Health Connect.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = onHealthConnectSync, modifier = Modifier.fillMaxWidth()) { Text("Sync Health Connect") }
Text(healthConnectStatus, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
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)

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="camera_photos" path="." />
</paths>

View file

@ -6,9 +6,16 @@ import org.junit.Test
class NutritionParserTest {
private val parser = NutritionParser()
@Test fun parsesCleanJson() {
val estimate = parser.parse("{\"mealName\":\"Chicken salad\",\"calories\":450,\"proteinGrams\":38,\"carbsGrams\":12,\"fatGrams\":22,\"fruitServings\":0,\"vegetableServings\":2,\"foodGroups\":\"protein, vegetables\",\"notes\":\"\"}")
assertEquals("Chicken salad", estimate.mealName)
assertEquals(450, estimate.calories)
assertEquals(38, estimate.proteinGrams)
assertEquals(2.0, estimate.vegetableServings, 0.001)
}
@Test fun parsesJsonInsideMarkdownFence() {
val estimate = parser.parse("```json\n{\"mealName\":\"Rice bowl\",\"calories\":640,\"proteinGrams\":42,\"carbsGrams\":70,\"fatGrams\":18,\"fruitServings\":0.5,\"vegetableServings\":1.5,\"foodGroups\":[\"grain\",\"vegetable\"]}\n```")
assertEquals("Rice bowl", estimate.mealName)
assertEquals(640, estimate.calories)
assertEquals(42, estimate.proteinGrams)
@ -16,11 +23,31 @@ class NutritionParserTest {
assertEquals(1.5, estimate.vegetableServings, 0.001)
}
@Test fun parsesExactKeys() {
val estimate = parser.parse("{\"mealName\":\"Pasta\",\"calories\":580,\"proteinGrams\":28,\"carbsGrams\":80,\"fatGrams\":14,\"fruitServings\":0,\"vegetableServings\":1,\"foodGroups\":\"grain\",\"notes\":\"\"}")
assertEquals("Pasta", estimate.mealName)
assertEquals(580, estimate.calories)
assertEquals(28, estimate.proteinGrams)
assertEquals(80, estimate.carbsGrams)
assertEquals(14, estimate.fatGrams)
}
@Test fun parsesJsonWithSurroundingText() {
val estimate = parser.parse("Here is the estimate:\n{\"mealName\":\"Toast\",\"calories\":200,\"proteinGrams\":6,\"carbsGrams\":35,\"fatGrams\":4,\"fruitServings\":0,\"vegetableServings\":0,\"foodGroups\":\"grains\",\"notes\":\"2 slices\"}\nThese are estimates only.")
assertEquals("Toast", estimate.mealName)
assertEquals(200, estimate.calories)
}
@Test fun clampsNegativeNumbers() {
val estimate = parser.parse("{\"calories\":-10,\"proteinGrams\":-3,\"fruitServings\":-1}")
assertEquals(0, estimate.calories)
assertEquals(0, estimate.proteinGrams)
assertEquals(0.0, estimate.fruitServings, 0.001)
}
@Test fun handlesStringNumbers() {
val estimate = parser.parse("{\"mealName\":\"Oatmeal\",\"calories\":\"320\",\"proteinGrams\":\"12\",\"carbsGrams\":\"55\",\"fatGrams\":\"8\"}")
assertEquals(320, estimate.calories)
assertEquals(12, estimate.proteinGrams)
}
}

View file

@ -4,6 +4,7 @@ WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY index.html vite.config.js server.js ./
COPY server ./server
COPY src ./src
COPY public ./public
RUN npm run build && npm prune --omit=dev

101
web/android/.gitignore vendored Normal file
View file

@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

2
web/android/app/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View file

@ -0,0 +1,64 @@
apply plugin: 'com.android.application'
apply plugin: 'org.jetbrains.kotlin.android'
android {
namespace = "com.danvics.calorieai"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.danvics.calorieai"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 11
versionName "1.11"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = '21'
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation 'androidx.health.connect:connect-client:1.1.0-alpha12'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View file

@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
web/android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity-alias
android:name=".ViewPermissionUsageActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="androidx.health.permission.READ_STEPS" />
<uses-permission android:name="androidx.health.permission.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="androidx.health.permission.READ_EXERCISE" />
</manifest>

View file

@ -0,0 +1,171 @@
package com.danvics.calorieai
import androidx.activity.result.ActivityResultLauncher
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.records.metadata.DataOrigin
import androidx.health.connect.client.request.AggregateRequest
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import androidx.health.connect.client.PermissionController
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
@CapacitorPlugin(name = "HealthConnect")
class HealthConnectPlugin : Plugin() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val permissions = setOf(
HealthPermission.getReadPermission(StepsRecord::class),
HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class),
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
)
private var permissionLauncher: ActivityResultLauncher<Set<String>>? = null
private var pendingSyncCall: PluginCall? = null
override fun load() {
permissionLauncher = activity.registerForActivityResult(
PermissionController.createRequestPermissionResultContract()
) { granted ->
val call = pendingSyncCall ?: return@registerForActivityResult
pendingSyncCall = null
if (granted.containsAll(permissions)) readAndResolve(call) else call.reject("Health Connect permission was not granted.")
}
}
@PluginMethod
fun sync(call: PluginCall) {
when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_AVAILABLE -> ensurePermissionsThenSync(call)
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> call.reject("Install or update Health Connect, then try again.")
else -> call.reject("Health Connect is not available on this device.")
}
}
private fun ensurePermissionsThenSync(call: PluginCall) {
scope.launch {
runCatching {
val granted = HealthConnectClient.getOrCreate(context).permissionController.getGrantedPermissions()
if (granted.containsAll(permissions)) {
readActivities(call)
} else {
pendingSyncCall = call
permissionLauncher?.launch(permissions) ?: call.reject("Health Connect permissions are not ready.")
}
}.onFailure { error -> call.reject(error.message ?: "Health Connect sync failed.") }
}
}
private fun readAndResolve(call: PluginCall) {
scope.launch {
runCatching { readActivities(call) }
.onFailure { error -> call.reject(error.message ?: "Health Connect sync failed.") }
}
}
private suspend fun readActivities(call: PluginCall) {
val days = (call.getInt("days") ?: 14).coerceIn(1, 90).toLong()
val client = HealthConnectClient.getOrCreate(context)
val zone = ZoneId.systemDefault()
val today = LocalDate.now(zone)
val syncedAt = Instant.now().toString()
val activities = JSArray()
for (offset in 0 until days) {
val date = today.minusDays(offset)
val start = date.atStartOfDay(zone).toInstant()
val end = date.plusDays(1).atStartOfDay(zone).toInstant()
val aggregate = client.aggregate(
AggregateRequest(
metrics = setOf(StepsRecord.COUNT_TOTAL, ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL),
timeRangeFilter = TimeRangeFilter.between(start, end),
)
)
val steps = aggregate[StepsRecord.COUNT_TOTAL] ?: 0L
val calories = aggregate[ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL]?.inKilocalories ?: 0.0
if (steps > 0L || calories > 0.0) {
activities.put(
activityJson(
id = "health-connect-day-$date",
sourceName = "Health Connect",
type = "Daily activity",
title = "Daily activity",
startAt = start.toString(),
endAt = end.toString(),
calories = calories,
steps = steps,
syncedAt = syncedAt,
)
)
}
}
val rangeStart = today.minusDays(days - 1).atStartOfDay(zone).toInstant()
val rangeEnd = today.plusDays(1).atStartOfDay(zone).toInstant()
client.readRecords(
ReadRecordsRequest(
recordType = ExerciseSessionRecord::class,
timeRangeFilter = TimeRangeFilter.between(rangeStart, rangeEnd),
pageSize = 200,
)
).records.forEach { session ->
activities.put(
activityJson(
id = "health-connect-session-${session.metadata.id}",
sourceName = sourceName(session.metadata.dataOrigin),
type = "Exercise ${session.exerciseType}",
title = session.title ?: "Exercise session",
startAt = session.startTime.toString(),
endAt = session.endTime.toString(),
syncedAt = syncedAt,
)
)
}
call.resolve(JSObject().put("activities", activities))
}
private fun activityJson(
id: String,
sourceName: String,
type: String,
title: String,
startAt: String,
endAt: String,
calories: Double = 0.0,
steps: Long = 0L,
syncedAt: String,
): JSObject = JSObject()
.put("id", id)
.put("source", "health_connect")
.put("sourceName", sourceName)
.put("type", type)
.put("title", title)
.put("startAt", startAt)
.put("endAt", endAt)
.put("calories", calories)
.put("steps", steps)
.put("distanceMeters", 0)
.put("syncedAt", syncedAt)
private fun sourceName(origin: DataOrigin): String = origin.packageName.ifBlank { "Health Connect" }
override fun handleOnDestroy() {
super.handleOnDestroy()
scope.cancel()
}
}

View file

@ -0,0 +1,12 @@
package com.danvics.calorieai;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(HealthConnectPlugin.class);
super.onCreate(savedInstanceState);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

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

View file

@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Calorie AI</string>
<string name="title_activity_main">Calorie AI</string>
<string name="package_name">com.danvics.calorieai</string>
<string name="custom_url_scheme">com.danvics.calorieai</string>
</resources>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View file

@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

30
web/android/build.gradle Normal file
View file

@ -0,0 +1,30 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View file

@ -0,0 +1,3 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')

View file

@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
web/android/gradlew vendored Executable file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
web/android/gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View file

@ -0,0 +1,16 @@
ext {
minSdkVersion = 26
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.2.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '14.0.1'
}

20
web/capacitor.config.ts Normal file
View file

@ -0,0 +1,20 @@
import { CapacitorConfig } from '@capacitor/cli';
const serverUrl = process.env.APP_SERVER_URL;
const config: CapacitorConfig = {
appId: 'com.danvics.calorieai',
appName: 'Calorie AI',
webDir: 'dist',
...(serverUrl ? {
server: {
url: serverUrl,
cleartext: true,
},
} : {}),
android: {
allowMixedContent: true,
},
};
export default config;

View file

@ -1,21 +1,36 @@
services:
postgres:
image: postgres:16-alpine
container_name: calorie-ai-postgres
restart: unless-stopped
environment:
POSTGRES_DB: calorie_ai
POSTGRES_USER: calorie_ai
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "127.0.0.1:55432:5432"
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U calorie_ai -d calorie_ai"]
interval: 5s
timeout: 5s
retries: 20
calorie-ai-web:
build: .
container_name: calorie-ai-web
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- /home/danvics/docker/quiz/backend/.env
environment:
LITELLM_API_BASE: http://litellm:4000/v1
CALORIE_AI_DATABASE_URL: postgresql://calorie_ai@postgres:5432/calorie_ai
LITELLM_API_BASE: https://llm.danvics.com/v1
LITELLM_MODEL: openrouter-claude-haiku-4.5
ports:
- "127.0.0.1:8095:8080"
volumes:
- ./data:/app/data
networks:
- default
- litellm_default
networks:
litellm_default:
external: true

1192
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,16 +9,21 @@
"test": "playwright test"
},
"devDependencies": {
"@capacitor/android": "^8.3.4",
"@capacitor/cli": "^8.3.4",
"@capacitor/core": "^8.3.4",
"@playwright/test": "^1.56.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0",
"@playwright/test": "^1.56.1",
"svelte": "^5.55.8",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"vite": "^8.0.13"
},
"dependencies": {
"argon2": "^0.44.0",
"express": "^5.2.1",
"nodemailer": "^7.0.11"
"nodemailer": "^7.0.11",
"pg": "^8.21.0"
}
}

View file

@ -12,6 +12,8 @@ module.exports = defineConfig({
CALORIE_AI_WEB_USER: 'admin',
CALORIE_AI_WEB_PASSWORD: 'test-password',
CALORIE_AI_SESSION_SECRET: 'test-session-secret',
CALORIE_AI_DATABASE_URL: process.env.CALORIE_AI_TEST_DATABASE_URL || 'postgresql://calorie_ai@127.0.0.1:55432/calorie_ai',
CALORIE_AI_DATA_DIR: '.test-data',
},
},
use: {

View file

@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path');
const argon2 = require('argon2');
const nodemailer = require('nodemailer');
const { createStore, defaultSettings } = require('./server/storage');
const port = Number(process.env.PORT || 8080);
const publicDir = fs.existsSync(path.join(__dirname, 'dist')) ? path.join(__dirname, 'dist') : path.join(__dirname, 'public');
@ -18,6 +19,7 @@ const sessionCookieName = 'calorie_ai_session';
const sessionSeconds = 60 * 60 * 24 * 7;
let auth;
let modelConfig;
let store;
const aiConfig = {
baseUrl: (process.env.CALORIE_AI_API_BASE_URL || process.env.LITELLM_API_BASE || 'https://llm.danvics.com/v1').replace(/\/+$/, ''),
apiKey: process.env.CALORIE_AI_API_KEY || process.env.LITELLM_API_KEY || process.env.OPENAI_API_KEY || '',
@ -50,23 +52,12 @@ function readBody(req) {
});
}
function readJsonFile(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
}
function writePrivateJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value, null, 2));
fs.chmodSync(file, 0o600);
}
async function hashPassword(password) {
return argon2.hash(password, { type: argon2.argon2id });
}
async function loadAuthConfig() {
let stored = {};
stored = readJsonFile(authFile, {});
const stored = await store.getAuthConfig();
const user = process.env.CALORIE_AI_WEB_USER || stored.user || 'admin';
const email = process.env.CALORIE_AI_ADMIN_EMAIL || stored.email || process.env.MAIL_FROM || process.env.SMTP_FROM || '';
@ -86,7 +77,7 @@ async function loadAuthConfig() {
const sessionSecret = process.env.CALORIE_AI_SESSION_SECRET || stored.sessionSecret || crypto.randomBytes(32).toString('hex');
if (!stored.passwordHash || !stored.sessionSecret || stored.password) {
writePrivateJson(authFile, {
await store.saveAuthConfig({
user,
email,
emailVerified: stored.emailVerified === true,
@ -96,15 +87,15 @@ async function loadAuthConfig() {
updatedAt: new Date().toISOString(),
...(plainPassword && !stored.passwordHash ? { initialPassword: plainPassword } : {}),
});
console.log(`Calorie AI auth is stored at ${authFile}`);
console.log(`Calorie AI auth is stored in ${store.databaseLabel}`);
}
return { ...stored, user, email, emailVerified: stored.emailVerified === true, passwordHash, sessionSecret };
}
function saveAuthConfig(extra = {}) {
async function saveAuthConfig(extra = {}) {
auth = { ...auth, ...extra, updatedAt: new Date().toISOString() };
writePrivateJson(authFile, {
await store.saveAuthConfig({
user: auth.user,
email: auth.email || '',
emailVerified: auth.emailVerified === true,
@ -210,41 +201,29 @@ async function sendMail(to, subject, text, html = '') {
await transporter.sendMail({ from: config.from, to, subject, text, html: html || undefined });
}
function loadModelConfig() {
const stored = readJsonFile(modelsFile, {});
async function loadModelConfig() {
const stored = await store.getModelConfig();
const defaultModel = stored.defaultModel || aiConfig.defaultModel;
const models = Array.isArray(stored.models) && stored.models.length
? stored.models
: [{ id: defaultModel, name: defaultModel, tag: 'DEFAULT' }];
const config = { defaultModel, models: dedupeModels(models) };
if (!fs.existsSync(modelsFile)) saveModelConfig(config);
if (!stored.models?.length) await saveModelConfig(config);
return config;
}
function saveModelConfig(config = modelConfig) {
writePrivateJson(modelsFile, { ...config, updatedAt: new Date().toISOString() });
}
function loadPlanConfig() {
const stored = readJsonFile(plansFile, {});
return {
plans: Array.isArray(stored.plans) ? stored.plans : [],
selectedPlanId: stored.selectedPlanId || '',
};
}
function savePlanConfig(config) {
writePrivateJson(plansFile, { ...config, updatedAt: new Date().toISOString() });
async function saveModelConfig(config = modelConfig) {
await store.saveModelConfig({ ...config, updatedAt: new Date().toISOString() });
}
async function getPlans(req, res) {
send(res, 200, JSON.stringify(loadPlanConfig()));
send(res, 200, JSON.stringify(await store.getPlans()));
}
async function createPlan(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
if (!payload.content) return send(res, 400, JSON.stringify({ error: 'Plan content required' }));
const config = loadPlanConfig();
const config = await store.getPlans();
const plan = {
id: payload.id || crypto.randomUUID(),
title: String(payload.title || `Weight-loss plan ${config.plans.length + 1}`),
@ -253,91 +232,47 @@ async function createPlan(req, res) {
previousPlanId: payload.previousPlanId || undefined,
createdAt: payload.createdAt || new Date().toISOString(),
};
config.plans = [plan, ...config.plans];
config.selectedPlanId = plan.id;
savePlanConfig(config);
send(res, 200, JSON.stringify(config));
send(res, 200, JSON.stringify(await store.savePlan(plan)));
}
async function selectPlanRoute(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
const config = loadPlanConfig();
if (payload.id && !config.plans.some(plan => plan.id === payload.id)) return send(res, 404, JSON.stringify({ error: 'Plan not found' }));
config.selectedPlanId = payload.id || '';
savePlanConfig(config);
send(res, 200, JSON.stringify(config));
if (payload.id && !await store.planExists(payload.id)) return send(res, 404, JSON.stringify({ error: 'Plan not found' }));
send(res, 200, JSON.stringify(await store.selectPlan(payload.id || '')));
}
async function deletePlanRoute(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
const config = loadPlanConfig();
config.plans = config.plans.filter(plan => plan.id !== payload.id);
if (config.selectedPlanId === payload.id) config.selectedPlanId = config.plans[0]?.id || '';
savePlanConfig(config);
send(res, 200, JSON.stringify(config));
}
function loadSettings() {
return readJsonFile(settingsFile, {
visionModel: '',
taskModel: '',
heightCm: '',
weightKg: '',
targetWeightKg: '',
calorieTarget: '',
activityLevel: 'Moderate',
pace: 'Steady',
});
}
function saveSettingsData(settings) {
writePrivateJson(settingsFile, { ...settings, updatedAt: new Date().toISOString() });
send(res, 200, JSON.stringify(await store.deletePlan(payload.id)));
}
async function getSettings(req, res) {
send(res, 200, JSON.stringify(loadSettings()));
send(res, 200, JSON.stringify(await store.getSettings()));
}
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 current = await store.getSettings();
const allowed = Object.keys(defaultSettings);
const updated = { ...current };
for (const key of allowed) if (payload[key] !== undefined) updated[key] = payload[key];
saveSettingsData(updated);
await store.saveSettings(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()));
send(res, 200, JSON.stringify(await store.getEntries()));
}
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));
send(res, 200, JSON.stringify(await store.upsertMealEntry(payload)));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -347,12 +282,7 @@ 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));
send(res, 200, JSON.stringify(await store.moveEntriesToTrash([...ids])));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -361,13 +291,8 @@ async function deleteEntriesRoute(req, res) {
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);
const data = await store.restoreEntry(payload.id);
if (!data) return send(res, 404, JSON.stringify({ error: 'Entry not found in trash' }));
send(res, 200, JSON.stringify(data));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -376,10 +301,21 @@ async function restoreEntryRoute(req, res) {
async function clearTrashRoute(req, res) {
try {
const data = loadEntriesData();
data.trash = [];
saveEntriesData(data);
send(res, 200, JSON.stringify(data));
send(res, 200, JSON.stringify(await store.clearTrash()));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
}
async function getActivitiesRoute(req, res) {
send(res, 200, JSON.stringify(await store.getActivities()));
}
async function syncActivitiesRoute(req, res) {
try {
const payload = JSON.parse(await readBody(req) || '{}');
const activities = Array.isArray(payload.activities) ? payload.activities : [];
send(res, 200, JSON.stringify(await store.upsertActivities(activities, payload.source || '')));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
}
@ -400,7 +336,7 @@ function send(res, status, body, contentType = 'application/json; charset=utf-8'
'x-content-type-options': 'nosniff',
'referrer-policy': 'same-origin',
'x-frame-options': 'DENY',
'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'",
'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'",
...headers,
});
res.end(headOnly ? '' : body);
@ -561,7 +497,7 @@ async function addModel(req, res) {
if (!payload.id) return send(res, 400, JSON.stringify({ error: 'Model ID required' }));
modelConfig.models = dedupeModels([...modelConfig.models, { id: payload.id, name: payload.name || payload.id, tag: 'ADDED' }]);
if (!modelConfig.defaultModel) modelConfig.defaultModel = payload.id;
saveModelConfig();
await saveModelConfig();
send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -574,7 +510,7 @@ async function removeModel(req, res) {
modelConfig.models = modelConfig.models.filter(model => model.id !== payload.id);
if (!modelConfig.models.length) modelConfig.models = [{ id: aiConfig.defaultModel, name: aiConfig.defaultModel, tag: 'DEFAULT' }];
if (!modelConfig.models.some(model => model.id === modelConfig.defaultModel)) modelConfig.defaultModel = modelConfig.models[0].id;
saveModelConfig();
await saveModelConfig();
send(res, 200, JSON.stringify({ ok: true, models: modelConfig.models, defaultModel: modelConfig.defaultModel }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -604,7 +540,7 @@ async function changePassword(req, res) {
if (!await argon2.verify(auth.passwordHash, payload.currentPassword || '')) return send(res, 401, JSON.stringify({ error: 'Current password is incorrect' }));
if (!payload.newPassword || payload.newPassword.length < 8) return send(res, 400, JSON.stringify({ error: 'Use at least 8 characters for the new password' }));
auth.passwordHash = await hashPassword(payload.newPassword);
saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
await saveAuthConfig({ passwordHash: auth.passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -615,7 +551,7 @@ async function resetPassword(req, res) {
try {
if (!auth.email) return send(res, 400, JSON.stringify({ error: 'Set an account email first' }));
const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
await saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`;
const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`;
const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password');
@ -635,7 +571,7 @@ async function requestPasswordReset(req, res) {
}
if (!auth.email) return send(res, 200, JSON.stringify({ ok: true, message: 'If the account exists, a reset email was sent.' }));
const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
await saveAuthConfig({ resetTokenHash: tokenHash(token), resetExpiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/reset?token=${encodeURIComponent(token)}`;
const text = `Use this link to reset your Calorie AI password. It expires in 1 hour.\n\n${link}`;
const html = actionEmailHtml('Password reset request', 'Someone requested a password reset for your Calorie AI account. If that was you, click the button below to choose a new password. This link expires in 1 hour. If you did not request this, no action is needed.', link, 'Reset My Password');
@ -653,7 +589,7 @@ async function confirmPasswordReset(req, res) {
const valid = auth.resetTokenHash && auth.resetExpiresAt && new Date(auth.resetExpiresAt).getTime() > Date.now() && safeEqual(auth.resetTokenHash, tokenHash(payload.token || ''));
if (!valid) return send(res, 400, JSON.stringify({ error: 'Reset link is invalid or expired' }));
const passwordHash = await hashPassword(payload.newPassword);
saveAuthConfig({ passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
await saveAuthConfig({ passwordHash, resetTokenHash: undefined, resetExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -665,7 +601,7 @@ async function updateAccount(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
const email = String(payload.email || '').trim();
if (!/^\S+@\S+\.\S+$/.test(email)) return send(res, 400, JSON.stringify({ error: 'Enter a valid email address' }));
saveAuthConfig({ email, emailVerified: email === auth.email ? auth.emailVerified : false });
await saveAuthConfig({ email, emailVerified: email === auth.email ? auth.emailVerified : false });
send(res, 200, JSON.stringify({ ok: true, email: auth.email, emailVerified: auth.emailVerified }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -676,7 +612,7 @@ async function sendVerification(req, res) {
try {
if (!auth.email) return send(res, 400, JSON.stringify({ error: 'Set an account email first' }));
const token = crypto.randomBytes(32).toString('base64url');
saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
await saveAuthConfig({ verificationTokenHash: tokenHash(token), verificationExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
const link = `${appOrigin(req)}/verify?token=${encodeURIComponent(token)}`;
const text = `Use this link to verify your Calorie AI account email. It expires in 24 hours.\n\n${link}`;
const html = actionEmailHtml('Verify your email', 'Please verify your Calorie AI account email by clicking the button below. This link expires in 24 hours.', link, 'Verify My Email');
@ -692,7 +628,7 @@ async function verifyAccount(req, res) {
const payload = JSON.parse(await readBody(req) || '{}');
const valid = auth.verificationTokenHash && auth.verificationExpiresAt && new Date(auth.verificationExpiresAt).getTime() > Date.now() && safeEqual(auth.verificationTokenHash, tokenHash(payload.token || ''));
if (!valid) return send(res, 400, JSON.stringify({ error: 'Verification link is invalid or expired' }));
saveAuthConfig({ emailVerified: true, verificationTokenHash: undefined, verificationExpiresAt: undefined });
await saveAuthConfig({ emailVerified: true, verificationTokenHash: undefined, verificationExpiresAt: undefined });
send(res, 200, JSON.stringify({ ok: true }));
} catch (error) {
send(res, 400, JSON.stringify({ error: error.message }));
@ -706,8 +642,12 @@ function logout(req, res) {
}
async function start() {
store = await createStore({
dataDir,
legacyFiles: { authFile, modelsFile, plansFile, settingsFile, entriesFile },
});
auth = await loadAuthConfig();
modelConfig = loadModelConfig();
modelConfig = await loadModelConfig();
const app = express();
app.use(express.text({ type: '*/*', limit: '12mb' }));
app.use((req, res, next) => {
@ -746,6 +686,8 @@ async function start() {
app.post('/api/entries/delete', wrap(deleteEntriesRoute));
app.post('/api/entries/restore', wrap(restoreEntryRoute));
app.post('/api/entries/clear-trash', wrap(clearTrashRoute));
app.get('/api/activities', wrap(getActivitiesRoute));
app.post('/api/activities/sync', wrap(syncActivitiesRoute));
app.post('/api/chat', wrap(proxyChat));
app.use((req, res) => {
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);

437
web/server/storage.js Normal file
View file

@ -0,0 +1,437 @@
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const defaultSettings = {
visionModel: '',
taskModel: '',
sex: '',
age: '',
heightCm: '',
weightKg: '',
targetWeightKg: '',
goal: 'Lose weight',
calorieTarget: '',
activityLevel: 'Moderate',
pace: 'Steady',
motivation: '',
challenge: '',
trackingStyle: '',
calorieCountingExperience: '',
fastingInterest: '',
};
function readJsonFile(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
}
function nowIso() {
return new Date().toISOString();
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function maskDatabaseUrl(databaseUrl) {
try {
const url = new URL(databaseUrl);
if (url.password) url.password = '***';
return url.toString();
} catch {
return 'PostgreSQL';
}
}
function normalizePlan(plan) {
return {
id: String(plan.id || ''),
title: String(plan.title || 'Weight-loss plan'),
version: Number(plan.version || 1),
content: String(plan.content || ''),
previousPlanId: plan.previousPlanId || '',
createdAt: plan.createdAt || nowIso(),
};
}
function normalizeEntry(entry, trashedAt = '') {
const payload = { ...entry };
if (trashedAt) payload.trashedAt = trashedAt;
else delete payload.trashedAt;
return payload;
}
function readSqliteLegacy(sqliteFile) {
if (!fs.existsSync(sqliteFile)) return {};
try {
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(sqliteFile, { readOnly: true });
const readKey = key => {
try {
const row = db.prepare('SELECT value FROM app_kv WHERE key = ?').get(key);
return row ? JSON.parse(row.value) : undefined;
} catch { return undefined; }
};
const plans = (() => {
try {
return db.prepare('SELECT id, title, version, content, previous_plan_id AS previousPlanId, created_at AS createdAt FROM plans ORDER BY version DESC, created_at DESC').all();
} catch { return []; }
})();
const entries = [];
const trash = [];
try {
for (const row of db.prepare('SELECT payload, trashed_at AS trashedAt FROM meal_entries ORDER BY updated_at DESC').all()) {
const entry = JSON.parse(row.payload);
if (row.trashedAt) trash.push(normalizeEntry(entry, row.trashedAt));
else entries.push(normalizeEntry(entry, ''));
}
} catch {}
db.close();
return {
authConfig: readKey('auth_config'),
modelConfig: readKey('model_config'),
settings: readKey('settings'),
selectedPlanId: readKey('selected_plan_id'),
plans,
entries,
trash,
};
} catch {
return {};
}
}
async function connectWithRetry(pool, attempts = 40) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await pool.query('SELECT 1');
return;
} catch (error) {
lastError = error;
await delay(Math.min(3000, attempt * 250));
}
}
throw lastError;
}
async function createStore({
dataDir,
databaseUrl = process.env.CALORIE_AI_DATABASE_URL || process.env.DATABASE_URL || 'postgresql://calorie_ai@postgres:5432/calorie_ai',
legacyFiles = {},
sqliteFile = path.join(dataDir, 'calorie-ai.sqlite'),
}) {
fs.mkdirSync(dataDir, { recursive: true });
const pool = new Pool({ connectionString: databaseUrl });
await connectWithRetry(pool);
await pool.query(`
CREATE TABLE IF NOT EXISTS app_kv (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
version INTEGER NOT NULL,
content TEXT NOT NULL,
previous_plan_id TEXT,
created_at TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS meal_entries (
id TEXT PRIMARY KEY,
payload JSONB NOT NULL,
trashed_at TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS activities (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
source_name TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
start_at TEXT NOT NULL,
end_at TEXT NOT NULL DEFAULT '',
calories DOUBLE PRECISION NOT NULL DEFAULT 0,
steps BIGINT NOT NULL DEFAULT 0,
distance_meters DOUBLE PRECISION NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
synced_at TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
async function getKey(key, fallback) {
const result = await pool.query('SELECT value FROM app_kv WHERE key = $1', [key]);
return result.rows[0]?.value ?? fallback;
}
async function setKey(key, value) {
await pool.query(`
INSERT INTO app_kv (key, value, updated_at) VALUES ($1, $2::jsonb, now())
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, [key, JSON.stringify(value)]);
}
async function hasKey(key) {
const result = await pool.query('SELECT 1 FROM app_kv WHERE key = $1', [key]);
return result.rowCount > 0;
}
async function savePlanRow(plan) {
const normalized = normalizePlan(plan);
await pool.query(`
INSERT INTO plans (id, title, version, content, previous_plan_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
version = excluded.version,
content = excluded.content,
previous_plan_id = excluded.previous_plan_id,
created_at = excluded.created_at,
updated_at = excluded.updated_at
`, [
normalized.id,
normalized.title,
normalized.version,
normalized.content,
normalized.previousPlanId || null,
normalized.createdAt,
]);
return normalized.id;
}
async function saveEntryRow(entry, trashedAt = '') {
const payload = normalizeEntry(entry, trashedAt);
await pool.query(`
INSERT INTO meal_entries (id, payload, trashed_at, updated_at) VALUES ($1, $2::jsonb, $3, now())
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, trashed_at = excluded.trashed_at, updated_at = excluded.updated_at
`, [String(payload.id), JSON.stringify(payload), trashedAt || null]);
}
async function migrateLegacy() {
if (await getKey('legacy_migration_done', false)) return;
const authMissing = !await hasKey('auth_config');
const modelsMissing = !await hasKey('model_config');
const settingsMissing = !await hasKey('settings');
const planCount = await pool.query('SELECT COUNT(*)::int AS count FROM plans');
const entryCount = await pool.query('SELECT COUNT(*)::int AS count FROM meal_entries');
if (!authMissing && !modelsMissing && !settingsMissing && planCount.rows[0].count > 0 && entryCount.rows[0].count > 0) {
await setKey('legacy_migration_done', true);
return;
}
const sqlite = readSqliteLegacy(sqliteFile);
if (authMissing) await setKey('auth_config', sqlite.authConfig || readJsonFile(legacyFiles.authFile, {}));
if (modelsMissing) await setKey('model_config', sqlite.modelConfig || readJsonFile(legacyFiles.modelsFile, {}));
if (settingsMissing) await setKey('settings', sqlite.settings || readJsonFile(legacyFiles.settingsFile, defaultSettings));
if (planCount.rows[0].count === 0) {
const jsonPlans = readJsonFile(legacyFiles.plansFile, {});
const plans = sqlite.plans?.length ? sqlite.plans : Array.isArray(jsonPlans.plans) ? jsonPlans.plans : [];
for (const plan of plans) if (plan && plan.id && plan.content) await savePlanRow(plan);
const selectedPlanId = sqlite.selectedPlanId || jsonPlans.selectedPlanId || '';
if (selectedPlanId) await setKey('selected_plan_id', selectedPlanId);
}
if (entryCount.rows[0].count === 0) {
const jsonEntries = readJsonFile(legacyFiles.entriesFile, {});
const entries = sqlite.entries?.length ? sqlite.entries : Array.isArray(jsonEntries.entries) ? jsonEntries.entries : [];
const trash = sqlite.trash?.length ? sqlite.trash : Array.isArray(jsonEntries.trash) ? jsonEntries.trash : [];
for (const entry of entries) if (entry && entry.id) await saveEntryRow(entry, '');
for (const entry of trash) if (entry && entry.id) await saveEntryRow(entry, entry.trashedAt || nowIso());
}
await setKey('legacy_migration_done', true);
}
async function getPlans() {
const result = await pool.query(`
SELECT id, title, version, content, previous_plan_id, created_at
FROM plans
ORDER BY version DESC, created_at DESC
`);
const plans = result.rows.map(row => {
const plan = {
id: row.id,
title: row.title,
version: row.version,
content: row.content,
createdAt: row.created_at,
};
if (row.previous_plan_id) plan.previousPlanId = row.previous_plan_id;
return plan;
});
const selectedPlanId = await getKey('selected_plan_id', '') || plans[0]?.id || '';
return { plans, selectedPlanId };
}
async function savePlan(plan) {
const id = await savePlanRow(plan);
await setKey('selected_plan_id', id);
return getPlans();
}
async function selectPlan(id) {
await setKey('selected_plan_id', id || '');
return getPlans();
}
async function planExists(id) {
const result = await pool.query('SELECT 1 FROM plans WHERE id = $1', [id]);
return result.rowCount > 0;
}
async function deletePlan(id) {
await pool.query('DELETE FROM plans WHERE id = $1', [id]);
const state = await getPlans();
if (state.selectedPlanId === id) await setKey('selected_plan_id', state.plans[0]?.id || '');
return getPlans();
}
async function getEntries() {
const result = await pool.query('SELECT payload, trashed_at FROM meal_entries ORDER BY updated_at DESC');
const entries = [];
const trash = [];
for (const row of result.rows) {
if (row.trashed_at) trash.push(normalizeEntry(row.payload, row.trashed_at));
else entries.push(normalizeEntry(row.payload, ''));
}
return { entries, trash };
}
async function upsertMealEntry(entry) {
await saveEntryRow(entry, '');
return getEntries();
}
async function moveEntriesToTrash(ids) {
const result = await pool.query('SELECT payload FROM meal_entries WHERE trashed_at IS NULL AND id = ANY($1::text[])', [ids]);
const trashedAt = nowIso();
for (const row of result.rows) await saveEntryRow(row.payload, trashedAt);
return getEntries();
}
async function restoreEntry(id) {
const result = await pool.query('SELECT payload FROM meal_entries WHERE id = $1 AND trashed_at IS NOT NULL', [id]);
if (!result.rows[0]) return null;
await saveEntryRow(result.rows[0].payload, '');
return getEntries();
}
async function clearTrash() {
await pool.query('DELETE FROM meal_entries WHERE trashed_at IS NOT NULL');
return getEntries();
}
function normalizeActivity(activity, fallbackSource = '') {
const startAt = activity.startAt || activity.date || nowIso();
const source = String(activity.source || fallbackSource || 'external');
return {
id: String(activity.id || `${source}-${startAt}`),
source,
sourceName: String(activity.sourceName || activity.source_name || source),
type: String(activity.type || 'Activity'),
title: String(activity.title || activity.type || 'Activity'),
startAt: String(startAt),
endAt: String(activity.endAt || activity.end_at || startAt),
calories: Number(activity.calories || 0),
steps: Number(activity.steps || 0),
distanceMeters: Number(activity.distanceMeters || activity.distance_meters || 0),
metadata: activity.metadata && typeof activity.metadata === 'object' ? activity.metadata : {},
syncedAt: String(activity.syncedAt || nowIso()),
};
}
async function getActivities(limit = 500) {
const result = await pool.query(`
SELECT id, source, source_name, type, title, start_at, end_at, calories, steps, distance_meters, metadata, synced_at
FROM activities
ORDER BY start_at DESC
LIMIT $1
`, [limit]);
return {
activities: result.rows.map(row => ({
id: row.id,
source: row.source,
sourceName: row.source_name,
type: row.type,
title: row.title,
startAt: row.start_at,
endAt: row.end_at,
calories: Number(row.calories || 0),
steps: Number(row.steps || 0),
distanceMeters: Number(row.distance_meters || 0),
metadata: row.metadata || {},
syncedAt: row.synced_at,
})),
};
}
async function upsertActivities(activities, source = '') {
for (const item of activities) {
const activity = normalizeActivity(item, source);
await pool.query(`
INSERT INTO activities (id, source, source_name, type, title, start_at, end_at, calories, steps, distance_meters, metadata, synced_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, now())
ON CONFLICT(id) DO UPDATE SET
source = excluded.source,
source_name = excluded.source_name,
type = excluded.type,
title = excluded.title,
start_at = excluded.start_at,
end_at = excluded.end_at,
calories = excluded.calories,
steps = excluded.steps,
distance_meters = excluded.distance_meters,
metadata = excluded.metadata,
synced_at = excluded.synced_at,
updated_at = excluded.updated_at
`, [
activity.id,
activity.source,
activity.sourceName,
activity.type,
activity.title,
activity.startAt,
activity.endAt,
activity.calories,
activity.steps,
activity.distanceMeters,
JSON.stringify(activity.metadata),
activity.syncedAt,
]);
}
return getActivities();
}
await migrateLegacy();
return {
databaseLabel: maskDatabaseUrl(databaseUrl),
getAuthConfig: () => getKey('auth_config', {}),
saveAuthConfig: config => setKey('auth_config', config),
getModelConfig: () => getKey('model_config', {}),
saveModelConfig: config => setKey('model_config', config),
getSettings: async () => ({ ...defaultSettings, ...await getKey('settings', defaultSettings) }),
saveSettings: settings => setKey('settings', settings),
getPlans,
savePlan,
selectPlan,
planExists,
deletePlan,
getEntries,
upsertMealEntry,
moveEntriesToTrash,
restoreEntry,
clearTrash,
getActivities,
upsertActivities,
};
}
module.exports = { createStore, defaultSettings };

View file

@ -0,0 +1,97 @@
<script>
import { onDestroy, onMount, tick } from 'svelte';
export let week = [];
let calorieCanvas;
let produceCanvas;
let chartTimer;
onMount(scheduleCharts);
onDestroy(() => clearTimeout(chartTimer));
$: scheduleCharts(week);
function scheduleCharts() {
clearTimeout(chartTimer);
chartTimer = setTimeout(drawCharts, 0);
}
async function drawCharts() {
await tick();
drawBars(calorieCanvas, week, 'calories');
drawBars(produceCanvas, week, 'produce');
}
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.scale(dpr, dpr);
const values = days.map(day => mode === 'calories' ? day.calories : day.fruit + day.vegetables);
const max = Math.max(1, ...values);
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 * 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, 6);
if (mode === 'produce' && day.vegetables > 0) {
const vegH = day.vegetables / max * barHeight;
ctx.fillStyle = '#10b981';
roundRect(ctx, x + bar * 0.52, bottom - vegH, bar * 0.48, vegH, 5);
}
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);
});
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.roundRect(x, y, w, Math.max(1, h), r);
ctx.fill();
}
</script>
<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>

View file

@ -1,39 +1,17 @@
<script>
import { onMount, tick } from 'svelte';
import Icon from './Icon.svelte';
import Field from './Field.svelte';
import Stat from './Stat.svelte';
import { onMount } from 'svelte';
import { registerPlugin } from '@capacitor/core';
import AppShell from './AppShell.svelte';
import DashboardPage from './DashboardPage.svelte';
import AnalyticsPage from './AnalyticsPage.svelte';
import MealForm from './MealForm.svelte';
import DiaryPage from './DiaryPage.svelte';
import SettingsPage from './SettingsPage.svelte';
import PlansPage from './PlansPage.svelte';
import { defaultSettings, fallbackModels, mealTypes, pages } from './appConfig.js';
import { calculateCaloriePlan, planSummary } from './calorie.js';
const pages = [
{ 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 = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
{ 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',
};
const HealthConnect = registerPlugin('HealthConnect');
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
let username = 'admin';
@ -54,12 +32,14 @@
let sidebarCollapsed = false;
let entries = [];
let activities = [];
let models = fallbackModels;
let discoveredModels = [];
let modelSearch = '';
let modelStatus = '';
let modelError = false;
let modelSearching = false;
let modelActionId = '';
let settings = { ...defaultSettings };
let currentPassword = '';
let newPassword = '';
@ -68,6 +48,9 @@
let accountVerified = false;
let accountStatus = '';
let accountError = false;
let healthConnectStatus = 'Health Connect not synced.';
let healthConnectError = false;
let healthConnectSyncing = false;
let passwordStatus = '';
let passwordError = false;
let resetPasswordValue = '';
@ -97,17 +80,20 @@
let selectedEntryIds = [];
let trash = [];
let calorieCanvas;
let produceCanvas;
$: trackerPages = pages.filter(item => item.group === 'Tracker');
$: adminPages = pages.filter(item => item.group === 'Admin');
$: today = totalsFor(todayKey());
$: 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));
$: todayActivities = activities.filter(activity => String(activity.startAt || '').slice(0, 10) === todayKey());
$: activityToday = todayActivities.reduce((sum, activity) => ({
calories: sum.calories + Number(activity.calories || 0),
steps: sum.steps + Number(activity.steps || 0),
}), { calories: 0, steps: 0 });
$: macroTotal = Math.max(1, today.protein + today.carbs + today.fat);
$: calculatedCaloriePlan = calculateCaloriePlan(settings);
$: activeCalorieTarget = Number(settings.calorieTarget) || calculatedCaloriePlan?.goalCalories || 0;
$: targetPercent = activeCalorieTarget ? Math.min(100, Math.round(today.calories / activeCalorieTarget * 100)) : 0;
$: diaryRows = entries
.filter(entry => !diaryType || entry.mealType === diaryType)
.filter(entry => !diaryDate || entry.date === diaryDate)
@ -120,22 +106,17 @@
$: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a));
onMount(async () => {
await loadSession();
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
if (loginMode) return;
await loadSession();
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
setDefaultMealDateTime();
await Promise.all([loadSettingsFromServer(), loadEntriesFromServer(), loadModels(), loadPlans()]);
await drawCharts();
await loadSettingsFromServer();
await loadModels();
await Promise.all([loadEntriesFromServer(), loadActivitiesFromServer(), loadPlans()]);
});
$: if (!loginMode) scheduleCharts(entries, page);
let chartTimer;
function scheduleCharts() {
clearTimeout(chartTimer);
chartTimer = setTimeout(drawCharts, 0);
}
function dateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
@ -264,10 +245,17 @@
async function loadSettingsFromServer() {
try {
const response = await fetch('/api/settings');
if (response.ok) settings = { ...defaultSettings, ...await response.json() };
if (response.ok) settings = normalizeSettings(await response.json());
} catch {}
}
function normalizeSettings(data = {}) {
const next = { ...defaultSettings, ...data };
if (!next.visionModel) next.visionModel = defaultSettings.visionModel;
if (!next.taskModel) next.taskModel = defaultSettings.taskModel;
return next;
}
async function saveSettings() {
try {
await fetch('/api/settings', {
@ -290,6 +278,37 @@
trash = data.trash || [];
}
async function loadActivitiesFromServer() {
try {
const response = await fetch('/api/activities');
if (response.ok) activities = (await response.json()).activities || [];
} catch {}
}
async function syncHealthConnect() {
healthConnectStatus = 'Syncing Health Connect...';
healthConnectError = false;
healthConnectSyncing = true;
try {
const nativeResult = await HealthConnect.sync({ days: 14 });
const synced = Array.isArray(nativeResult.activities) ? nativeResult.activities : [];
const response = await fetch('/api/activities/sync', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ source: 'health_connect', activities: synced }),
});
const data = await response.json().catch(() => ({ error: 'Health Connect server sync failed.' }));
if (!response.ok) throw new Error(data.error || 'Health Connect server sync failed.');
activities = data.activities || [];
healthConnectStatus = `Synced ${synced.length} Health Connect record${synced.length === 1 ? '' : 's'}.`;
} catch (error) {
healthConnectStatus = error?.message || 'Health Connect sync failed.';
healthConnectError = true;
} finally {
healthConnectSyncing = false;
}
}
function applyPlanConfig(config) {
plans = config.plans || [];
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
@ -337,6 +356,20 @@
return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' };
}
function normalizeEstimateDraft() {
return {
mealName: estimateDraft.mealName || description.trim() || 'Manual meal',
calories: Math.max(0, Math.round(Number(estimateDraft.calories) || 0)),
proteinGrams: Math.max(0, Math.round(Number(estimateDraft.proteinGrams) || 0)),
carbsGrams: Math.max(0, Math.round(Number(estimateDraft.carbsGrams) || 0)),
fatGrams: Math.max(0, Math.round(Number(estimateDraft.fatGrams) || 0)),
fruitServings: Math.max(0, Number(estimateDraft.fruitServings) || 0),
vegetableServings: Math.max(0, Number(estimateDraft.vegetableServings) || 0),
foodGroups: String(estimateDraft.foodGroups || ''),
notes: String(estimateDraft.notes || ''),
};
}
async function saveSelectedModels() {
await saveSettings();
modelStatus = 'Models saved.';
@ -373,43 +406,63 @@
}
async function addModel(model) {
const response = await fetch('/api/models/add', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(model),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
modelError = true;
return;
}
const data = await response.json();
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;
await saveSettings();
modelStatus = `Added ${model.name || model.id}.`;
modelActionId = model.id;
modelStatus = `Adding ${model.name || model.id}...`;
modelError = false;
try {
const response = await fetch('/api/models/add', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(model),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to add model.';
modelError = true;
return;
}
const data = await response.json();
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;
await saveSettings();
modelStatus = `Added ${model.name || model.id}.`;
modelError = false;
} catch (error) {
modelStatus = error?.message || 'Failed to add model.';
modelError = true;
} finally {
modelActionId = '';
}
}
async function removeModel(modelId) {
const response = await fetch('/api/models/remove', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: modelId }),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
modelError = true;
return;
}
const data = await response.json();
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;
await saveSettings();
modelStatus = 'Model removed.';
modelActionId = modelId;
modelStatus = 'Removing model...';
modelError = false;
try {
const response = await fetch('/api/models/remove', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: modelId }),
});
if (!response.ok) {
modelStatus = (await response.json().catch(() => ({}))).error || 'Failed to remove model.';
modelError = true;
return;
}
const data = await response.json();
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;
await saveSettings();
modelStatus = 'Model removed.';
modelError = false;
} catch (error) {
modelStatus = error?.message || 'Failed to remove model.';
modelError = true;
} finally {
modelActionId = '';
}
}
async function changePassword() {
@ -497,15 +550,32 @@
}
async function chatCompletion(body) {
const payload = { ...body, model: selectedModel(body.model) };
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body }),
body: JSON.stringify({ body: payload }),
});
const text = await response.text();
if (!response.ok) throw new Error(text);
if (!response.ok) throw new Error(readApiError(text, 'AI request failed'));
const json = JSON.parse(text);
return json.choices[0].message.content.trim();
const content = json.choices?.[0]?.message?.content;
if (!content) throw new Error('AI returned an empty response.');
return content.trim();
}
function selectedModel(preferred) {
if (preferred && models.some(model => model.id === preferred)) return preferred;
return models[0]?.id || fallbackModels[0].id;
}
function readApiError(text, fallback) {
try {
const data = JSON.parse(text);
return data.error?.message || data.error || fallback;
} catch {
return text || fallback;
}
}
async function requestImageEstimate() {
@ -523,37 +593,79 @@
});
}
function buildTodayHistory() {
const todayMeals = entries.filter(e => e.date === todayKey());
if (!todayMeals.length) return '';
let total = 0;
const lines = todayMeals.map(e => {
total += e.calories || 0;
return `- ${e.mealName || e.description}: ${e.calories || 0} kcal, P${e.proteinGrams || 0}g C${e.carbsGrams || 0}g F${e.fatGrams || 0}g`;
});
lines.push(`Total so far: ${total} kcal`);
return lines.join('\n');
}
function recentActivitySummary(limit = 10) {
const rows = activities.slice(0, limit).map(activity => {
const label = activity.title || activity.type || 'Activity';
const date = String(activity.startAt || '').slice(0, 10);
const calories = Math.round(Number(activity.calories || 0));
const steps = Number(activity.steps || 0);
return `${date} ${label}: ${calories} active kcal${steps ? `, ${steps} steps` : ''} (${activity.sourceName || activity.source || 'external'})`;
});
return rows.join('; ') || 'none synced yet';
}
async function requestMealEstimate(imageEstimate) {
const todayHistory = buildTodayHistory();
const userContent = [
`Estimate nutrition for this meal.`,
`Description: ${description}`,
`Portion: ${measure}`,
`Image analysis: ${imageEstimate}`,
todayHistory ? `\nUser's meals logged today:\n${todayHistory}` : '',
].filter(Boolean).join('\n');
return chatCompletion({
model: settings.taskModel,
temperature: 0.1,
messages: [
{ role: 'system', content: 'Return strict JSON only. Do not wrap the response in markdown.' },
{ role: 'user', content: `Estimate nutrition for one meal. Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. Use integer grams and calories. fruitServings and vegetableServings can be decimal numbers. foodGroups should be a short comma-separated string.
Description: ${description}
Portion: ${measure}
Image estimate: ${imageEstimate}` },
{ role: 'system', content: 'You are a nutrition estimator. Output ONLY a raw JSON object — no markdown fences, no explanation, no text before or after.\nYou MUST use these exact key names: mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\ncalories, proteinGrams, carbsGrams, fatGrams must be integers. fruitServings and vegetableServings must be numbers. foodGroups must be a short comma-separated string.\nUse the notes field for personalized dietary observations based on the user\'s meals logged today (e.g. macro balance, variety, progress toward goals).\nOutput format: {"mealName":"...","calories":0,"proteinGrams":0,"carbsGrams":0,"fatGrams":0,"fruitServings":0,"vegetableServings":0,"foodGroups":"...","notes":"..."}' },
{ role: 'user', content: userContent },
],
});
}
function parseEstimate(content) {
let cleaned = content.trim().replace(/^```json/i, '').replace(/^```/, '').replace(/```$/, '').trim();
let cleaned = content.trim();
// Strip markdown code fences
const fenceEnd = cleaned.indexOf('\n');
if (cleaned.startsWith('```') && fenceEnd >= 0) cleaned = cleaned.slice(fenceEnd + 1);
if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3);
cleaned = cleaned.trim();
const start = cleaned.indexOf('{');
const end = cleaned.lastIndexOf('}');
if (start >= 0 && end > start) cleaned = cleaned.slice(start, end + 1);
const parsed = JSON.parse(cleaned);
const p = JSON.parse(cleaned);
function safeInt(...keys) {
for (const k of keys) { const v = parseInt(p[k], 10); if (v > 0) return v; }
return 0;
}
function safeFloat(...keys) {
for (const k of keys) { const v = parseFloat(p[k]); if (v > 0) return v; }
return 0;
}
return {
mealName: parsed.mealName || 'Meal',
calories: Math.max(0, Number.parseInt(parsed.calories || 0, 10)),
proteinGrams: Math.max(0, Number.parseInt(parsed.proteinGrams || 0, 10)),
carbsGrams: Math.max(0, Number.parseInt(parsed.carbsGrams || 0, 10)),
fatGrams: Math.max(0, Number.parseInt(parsed.fatGrams || 0, 10)),
fruitServings: Math.max(0, Number(parsed.fruitServings ?? parsed.fruitsServings ?? 0)),
vegetableServings: Math.max(0, Number(parsed.vegetableServings ?? parsed.vegetablesServings ?? 0)),
foodGroups: Array.isArray(parsed.foodGroups) ? parsed.foodGroups.join(', ') : String(parsed.foodGroups || ''),
notes: parsed.notes || '',
mealName: p.mealName || 'Meal',
calories: safeInt('calories', 'kcal', 'energy'),
proteinGrams: safeInt('proteinGrams', 'protein', 'protein_g'),
carbsGrams: safeInt('carbsGrams', 'carbs', 'carbohydrates', 'carbohydrateGrams'),
fatGrams: safeInt('fatGrams', 'fat', 'totalFat'),
fruitServings: safeFloat('fruitServings', 'fruit', 'fruitsServings'),
vegetableServings: safeFloat('vegetableServings', 'vegetables', 'vegetablesServings'),
foodGroups: Array.isArray(p.foodGroups) ? p.foodGroups.join(', ') : String(p.foodGroups || ''),
notes: p.notes || '',
};
}
@ -589,6 +701,7 @@ Image estimate: ${imageEstimate}` },
});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Failed to save meal');
applyEntriesData(await response.json());
estimateDraft = emptyEstimate();
description = '';
measure = '';
selectedImageDataUrl = '';
@ -626,7 +739,7 @@ Image estimate: ${imageEstimate}` },
if (!existing) return;
const updated = {
...existing,
...estimateDraft,
...normalizeEstimateDraft(),
date: mealDate,
time: mealTime,
mealType,
@ -653,6 +766,47 @@ Image estimate: ${imageEstimate}` },
}
}
async function saveManualMeal() {
if (!mealDate || !mealTime || !mealType) return setMealStatus('Choose a date, time, and meal type.', true);
if (!description.trim() && !String(estimateDraft.mealName || '').trim()) return setMealStatus('Describe the meal or enter a meal name.', true);
const estimate = normalizeEstimateDraft();
if (!estimate.calories) return setMealStatus('Enter estimated calories before saving manually.', true);
savingMeal = true;
try {
const meal = {
...estimate,
id: editingEntryId || entryId(),
date: mealDate,
time: mealTime,
mealType,
description,
measure,
imageIncluded: Boolean(selectedImageDataUrl),
imageName: selectedImageName,
updatedAt: new Date().toISOString(),
};
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());
estimateDraft = emptyEstimate();
description = '';
measure = '';
selectedImageDataUrl = '';
selectedImageName = '';
editingEntryId = '';
setDefaultMealDateTime();
setMealStatus(`Saved manually. ${estimate.mealName} · ${estimate.calories} kcal`);
} catch (error) {
setMealStatus(error.message, true);
} finally {
savingMeal = false;
}
}
function cancelEdit() {
editingEntryId = '';
estimateDraft = emptyEstimate();
@ -704,17 +858,39 @@ Image estimate: ${imageEstimate}` },
planStatus = 'Generating plan...';
planError = false;
try {
if (!settings.heightCm || !settings.weightKg) throw new Error('Enter height and current weight first.');
if (!settings.sex || !settings.age || !settings.heightCm || !settings.weightKg) throw new Error('Enter sex, age, height, and current weight first.');
const calculatedPlan = calculateCaloriePlan(settings);
if (calculatedPlan && !settings.calorieTarget) settings.calorieTarget = String(calculatedPlan.goalCalories);
await saveSettings();
const content = await chatCompletion({
model: settings.taskModel,
temperature: 0.2,
messages: [
{ role: 'system', content: 'Create safe, concise nutrition and weight-loss guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals. Use plain text section titles and simple lines. Do not use markdown symbols.' },
{ role: 'user', content: `Create a personalized weight-loss plan using these details: height ${settings.heightCm} cm, current weight ${settings.weightKg} kg, target weight ${settings.targetWeightKg || 'not set'} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, weekly review steps, and safety notes. Recent meals: ${entries.slice(-10).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}.` },
{ role: 'system', content: 'Create safe, concise nutrition and weight-management guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals. Use plain text section titles and simple lines. Do not use markdown symbols. Make it feel like a personalized plan built from onboarding answers, not a generic article.' },
{ role: 'user', content: `Create a personalized plan using these details:
Goal: ${settings.goal}
Sex: ${settings.sex}
Age: ${settings.age}
Height: ${settings.heightCm} cm
Current weight: ${settings.weightKg} kg
Target weight: ${settings.targetWeightKg || 'not set'} kg
Activity: ${settings.activityLevel}
Pace: ${settings.pace}
Calculated targets: ${planSummary(settings)}
Motivation: ${settings.motivation || 'not set'}
Biggest challenge: ${settings.challenge || 'not set'}
Tracking style: ${settings.trackingStyle || 'not set'}
Calorie-counting experience: ${settings.calorieCountingExperience || 'not set'}
Fasting interest: ${settings.fastingInterest || 'not set'}
Recent meals: ${entries.slice(-10).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}.
Recent activity: ${recentActivitySummary()}.
Include: daily calorie target, maintenance calories, protein target, meal logging strategy, activity adjustment rules, challenge-specific tactics, weekly review steps, and safety notes.` },
],
});
const plan = { id: entryId(), title: `Weight-loss plan ${plans.length + 1}`, version: plans.length + 1, content, createdAt: new Date().toISOString() };
const version = Math.max(0, ...plans.map(p => Number(p.version || 0))) + 1;
const titlePrefix = settings.goal === 'Lose weight' ? 'Weight-loss' : settings.goal || 'Weight-management';
const plan = { id: entryId(), title: `${titlePrefix} plan ${version}`, version, content, createdAt: new Date().toISOString() };
await savePlan(plan);
planStatus = 'Plan generated.';
} catch (error) {
@ -742,7 +918,7 @@ Image estimate: ${imageEstimate}` },
temperature: 0.2,
messages: [
{ role: 'system', content: 'Update the weight-loss plan using the provided notes and logged meals. Use plain text section titles and simple lines. Do not use markdown symbols. Do not mention chat history.' },
{ role: 'user', content: `Current plan:\n${currentPlan.content}\n\nUpdate note:\n${tuneNote}\n\nRecent meals:\n${entries.slice(-15).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}` },
{ role: 'user', content: `Current plan:\n${currentPlan.content}\n\nUpdate note:\n${tuneNote}\n\nRecent meals:\n${entries.slice(-15).map(entry => `${entry.date} ${entry.mealType}: ${entry.mealName || entry.description}, ${entry.calories || 0} kcal`).join('; ') || 'none logged yet'}\n\nRecent activity:\n${recentActivitySummary(15)}` },
],
});
const version = Math.max(0, ...plans.map(plan => Number(plan.version || 0))) + 1;
@ -777,52 +953,6 @@ Image estimate: ${imageEstimate}` },
if (response.ok) applyPlanConfig(await response.json());
}
async function drawCharts() {
await tick();
drawBars(calorieCanvas, week, 'calories');
drawBars(produceCanvas, week, 'produce');
}
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.scale(dpr, dpr);
const values = days.map(day => mode === 'calories' ? day.calories : day.fruit + day.vegetables);
const max = Math.max(1, ...values);
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 * 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, 6);
if (mode === 'produce' && day.vegetables > 0) {
const vegH = day.vegetables / max * barHeight;
ctx.fillStyle = '#10b981';
roundRect(ctx, x + bar * 0.52, bottom - vegH, bar * 0.48, vegH, 5);
}
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);
});
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.roundRect(x, y, w, Math.max(1, h), r);
ctx.fill();
}
</script>
{#if loginMode}
@ -879,113 +1009,20 @@ Image estimate: ${imageEstimate}` },
</div>
</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-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>
</div>
<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-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-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 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">
<AppShell {pages} {page} bind:menuOpen bind:sidebarCollapsed {selectPage} {logout}>
{#if page === 'dashboard'}
<section class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<article class="sm:col-span-2 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-5 text-white shadow-sm">
<div class="text-xs font-semibold uppercase tracking-widest text-blue-200">Today</div>
<div id="todayCalories" class="mt-1 text-4xl font-black tracking-tight">{today.calories} kcal</div>
<div id="todayMacroText" class="mt-2 text-sm font-medium text-blue-100">P {today.protein}g &nbsp;·&nbsp; C {today.carbs}g &nbsp;·&nbsp; F {today.fat}g</div>
<div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} &nbsp;·&nbsp; Veg {today.vegetables.toFixed(1)}</div>
</article>
<Stat label="Meals today" value={today.meals} note={today.meals === 1 ? 'meal logged' : 'meals logged'} />
<Stat label="7-day average" value={`${weekAverage} kcal`} note="Calories per day" />
</div>
<div class="grid gap-4 lg:grid-cols-2">
<section class="card">
<h3 class="card-title">Macros today</h3>
<div class="space-y-3 p-4">
{#each [['Protein', today.protein, '#2563eb', 'bg-blue-500'], ['Carbs', today.carbs, '#f59e0b', 'bg-amber-400'], ['Fat', today.fat, '#7c3aed', 'bg-violet-500']] as [label, val, , cls]}
<div>
<div class="mb-1.5 flex justify-between text-sm">
<span class="font-semibold text-slate-700">{label}</span>
<span class="text-slate-500">{val}g &nbsp;·&nbsp; {Math.round(val / macroTotal * 100)}%</span>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full transition-all {cls}" style="width:{Math.round(val / macroTotal * 100)}%"></div>
</div>
</div>
{/each}
<div class="border-t border-slate-100 pt-2 text-xs text-slate-400">{today.calories} kcal total &nbsp;·&nbsp; produce {produceToday.toFixed(1)} / 5</div>
</div>
</section>
<section class="card">
<h3 class="card-title">Today's meals</h3>
<div class="divide-y divide-slate-100">
{#each todayEntries as entry}
<div class="flex items-center justify-between px-4 py-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-slate-800">{entry.mealName || entry.description}</div>
<div class="text-xs text-slate-400">{entry.mealType} · {entry.time}</div>
</div>
<div class="ml-3 shrink-0 text-sm font-bold text-slate-600">{entry.calories} kcal</div>
</div>
{:else}
<div class="px-4 py-8 text-center text-sm text-slate-400">No meals logged today</div>
{/each}
</div>
{#if todayEntries.length}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>+ Log another meal</button>
</div>
{:else}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>Log your first meal today</button>
</div>
{/if}
</section>
</div>
</section>
<DashboardPage
{today}
{activeCalorieTarget}
{targetPercent}
{macroTotal}
{produceToday}
{todayEntries}
{todayActivities}
{activityToday}
{weekAverage}
{selectPage}
/>
{:else if page === 'log'}
<MealForm
@ -1003,41 +1040,13 @@ Image estimate: ${imageEstimate}` },
bind:estimateDraft
{chooseImage}
{saveMeal}
{saveManualMeal}
{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" 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>
<AnalyticsPage {week} />
{:else if page === 'diary'}
<DiaryPage
@ -1096,10 +1105,14 @@ Image estimate: ${imageEstimate}` },
{modelStatus}
{modelError}
{modelSearching}
{modelActionId}
bind:accountEmail
{accountVerified}
{accountStatus}
{accountError}
{healthConnectStatus}
{healthConnectError}
{healthConnectSyncing}
{passwordStatus}
{passwordError}
{resetPasswordValue}
@ -1113,23 +1126,10 @@ Image estimate: ${imageEstimate}` },
{removeModel}
{saveAccountEmail}
{sendVerificationEmail}
{syncHealthConnect}
{changePassword}
{resetPassword}
/>
{/if}
</main>
</div>
</div>
</AppShell>
{/if}
<style>
.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: #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) { display: none; }
:global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0; padding-right: 0; }
:global(.collapsed-sidebar .collapse-button) { display: grid; }
</style>

79
web/src/AppShell.svelte Normal file
View file

@ -0,0 +1,79 @@
<script>
import Icon from './Icon.svelte';
export let pages = [];
export let page = 'dashboard';
export let menuOpen = false;
export let sidebarCollapsed = false;
export let selectPage;
export let logout;
$: trackerPages = pages.filter(item => item.group === 'Tracker');
$: adminPages = pages.filter(item => item.group === 'Admin');
$: pageLabel = pages.find(item => item.id === page)?.label || 'Dashboard';
</script>
<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-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>
</div>
<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-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-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>
<h1 class="truncate text-base font-bold text-slate-900">{pageLabel}</h1>
</div>
<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">
<slot />
</main>
</div>
</div>
<style>
.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: #eff6ff; color: #2563eb; }
.tab-icon { display: flex; width: 1.25rem; height: 1.25rem; flex: 0 0 1.25rem; align-items: center; justify-content: center; }
.collapsed-sidebar { width: 4.25rem !important; }
.collapsed-sidebar .sidebar-header { justify-content: center; padding-left: 0; padding-right: 0; }
.collapsed-sidebar .tab-label, .collapsed-sidebar .sidebar-section-label, .collapsed-sidebar .sidebar-brand { display: none; }
.collapsed-sidebar .tab-button { justify-content: center; padding-left: 0; padding-right: 0; }
.collapsed-sidebar .collapse-button { display: grid; }
</style>

View file

@ -0,0 +1,109 @@
<script>
import Stat from './Stat.svelte';
export let today;
export let activeCalorieTarget;
export let targetPercent;
export let macroTotal;
export let produceToday;
export let todayEntries = [];
export let todayActivities = [];
export let activityToday;
export let weekAverage;
export let selectPage;
</script>
<section class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<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>
{#if activeCalorieTarget}
<div class="mt-2 h-2 overflow-hidden rounded-full bg-white/20"><div class="h-full rounded-full bg-white" style="width:{targetPercent}%"></div></div>
<div class="mt-1 text-xs font-semibold text-blue-100">{Math.max(0, activeCalorieTarget - today.calories)} kcal left of {activeCalorieTarget}</div>
{/if}
<div id="todayMacroText" class="mt-2 text-sm font-medium text-blue-100">P {today.protein}g &nbsp;·&nbsp; C {today.carbs}g &nbsp;·&nbsp; F {today.fat}g</div>
<div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} &nbsp;·&nbsp; Veg {today.vegetables.toFixed(1)}</div>
</article>
<Stat label="Meals today" value={today.meals} note={today.meals === 1 ? 'meal logged' : 'meals logged'} />
<Stat label="7-day average" value={`${weekAverage} kcal`} note="Calories per day" />
<Stat label="Activity today" value={`${Math.round(activityToday.calories)} kcal`} note={`${activityToday.steps} steps`} />
</div>
<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, 'bg-blue-500'], ['Carbs', today.carbs, 'bg-amber-400'], ['Fat', today.fat, 'bg-violet-500']] as [label, val, cls]}
<div>
<div class="mb-1.5 flex justify-between text-sm">
<span class="font-semibold text-slate-700">{label}</span>
<span class="text-slate-500">{val}g &nbsp;·&nbsp; {Math.round(val / macroTotal * 100)}%</span>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-slate-100">
<div class="h-full rounded-full transition-all {cls}" style="width:{Math.round(val / macroTotal * 100)}%"></div>
</div>
</div>
{/each}
<div class="border-t border-slate-100 pt-2 text-xs text-slate-400">{today.calories} kcal total &nbsp;·&nbsp; produce {produceToday.toFixed(1)} / 5</div>
</div>
</section>
<section class="card">
<h3 class="card-title">Today's meals</h3>
<div class="divide-y divide-slate-100">
{#each todayEntries as entry}
<div class="flex items-center justify-between px-4 py-3">
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-slate-800">{entry.mealName || entry.description}</div>
<div class="text-xs text-slate-400">{entry.mealType} · {entry.time}</div>
</div>
<div class="ml-3 shrink-0 text-sm font-bold text-slate-600">{entry.calories} kcal</div>
</div>
{:else}
<div class="px-4 py-8 text-center text-sm text-slate-400">No meals logged today</div>
{/each}
</div>
{#if todayEntries.length}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>+ Log another meal</button>
</div>
{:else}
<div class="border-t border-slate-100 px-4 py-2">
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>Log your first meal today</button>
</div>
{/if}
</section>
<section class="card lg:col-span-2">
<h3 class="card-title">Synced activity</h3>
<div class="divide-y divide-slate-100">
{#each todayActivities.slice(0, 5) as activity}
<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">{activity.title || activity.type || 'Activity'}</div>
<div class="text-xs text-slate-400">{activity.sourceName || activity.source || 'External'} · {String(activity.startAt || '').slice(11, 16) || 'All day'}</div>
</div>
<div class="ml-3 shrink-0 text-right text-sm font-bold text-slate-600">
{Math.round(Number(activity.calories || 0))} kcal
{#if activity.steps}<div class="text-xs font-medium text-slate-400">{activity.steps} steps</div>{/if}
</div>
</div>
{:else}
<div class="grid gap-4 p-4 md:grid-cols-[1fr_1.1fr] md:items-center">
<div class="rounded-2xl bg-blue-50 p-4 text-left">
<div class="text-xs font-black uppercase tracking-widest text-blue-700">Connect Garmin</div>
<h4 class="mt-1 text-lg font-black text-slate-950">Use the Android app to sync your watch.</h4>
<p class="mt-2 text-sm leading-6 text-slate-600">The web dashboard shows synced activity, but Garmin pairing runs through Garmin Connect and Android Health Connect.</p>
</div>
<ol class="grid gap-2 text-sm leading-6 text-slate-600">
<li><strong class="text-slate-900">1.</strong> Sync your watch in Garmin Connect on Android.</li>
<li><strong class="text-slate-900">2.</strong> In Garmin Connect, enable sharing to Health Connect.</li>
<li><strong class="text-slate-900">3.</strong> Open Calorie AI on Android and tap Settings -> Sync Health Connect.</li>
</ol>
</div>
{/each}
</div>
</section>
</div>
</section>

View file

@ -82,10 +82,6 @@
<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>

View file

@ -15,6 +15,7 @@
export let estimateDraft;
export let chooseImage;
export let saveMeal;
export let saveManualMeal;
export let saveMealEdits;
export let cancelEdit;
</script>
@ -31,11 +32,23 @@
<Field label="Meal type"><select id="mealType" class="input" bind:value={mealType} required>{#each mealTypes as type}<option>{type}</option>{/each}</select></Field>
<Field className="md:col-span-3" label="Description"><textarea id="description" class="input min-h-32" bind:value={description} placeholder="Example: grilled salmon, rice, broccoli, berries"></textarea></Field>
<Field label="Portion or measure"><input id="measure" class="input" bind:value={measure} placeholder="Example: one plate, 450g, 2 cups"></Field>
<Field label="Meal image"><input id="image" class="input" type="file" accept="image/*" on:change={chooseImage}></Field>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-slate-700">Photo</span>
<div class="flex gap-2">
<label class="btn-secondary flex cursor-pointer items-center gap-2">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M2 6a2 2 0 012-2h.5l1.207-1.207A1 1 0 016.414 2.5h7.172a1 1 0 01.707.293L15.5 4H16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zm8 1a3 3 0 100 6 3 3 0 000-6z"/></svg>
Choose photo
<input id="image" type="file" accept="image/*" class="sr-only" on:change={chooseImage}>
</label>
</div>
</div>
</div>
{#if selectedImageDataUrl}<img id="preview" class="mx-4 mb-4 max-h-80 w-[calc(100%-2rem)] rounded-2xl object-cover" src={selectedImageDataUrl} alt="Selected meal preview">{/if}
{#if editingEntryId}
<div class="grid gap-3 border-t border-slate-200 p-4 md:grid-cols-4">
<div class="grid gap-3 border-t border-slate-200 p-4 md:grid-cols-4">
<div class="md:col-span-4">
<h3 class="text-sm font-black uppercase tracking-wide text-slate-500">Nutrition estimate</h3>
<p class="mt-1 text-sm text-slate-500">AI fills these automatically, or you can enter calories and save manually.</p>
</div>
<Field className="md:col-span-2" label="Meal name"><input class="input" bind:value={estimateDraft.mealName}></Field>
<Field label="Calories"><input class="input" type="number" min="0" bind:value={estimateDraft.calories}></Field>
<Field label="Protein (g)"><input class="input" type="number" min="0" bind:value={estimateDraft.proteinGrams}></Field>
@ -45,10 +58,10 @@
<Field label="Vegetable servings"><input class="input" type="number" min="0" step="0.1" bind:value={estimateDraft.vegetableServings}></Field>
<Field className="md:col-span-2" label="Food groups"><input class="input" bind:value={estimateDraft.foodGroups}></Field>
<Field className="md:col-span-2" label="Notes"><input class="input" bind:value={estimateDraft.notes}></Field>
</div>
{/if}
</div>
<div class="flex flex-col gap-3 border-t border-slate-200 p-4 md:flex-row md:items-center">
<button class="btn-primary" type="submit" disabled={savingMeal}>{savingMeal ? 'Saving...' : editingEntryId ? 'Analyze again' : 'Analyze and save'}</button>
<button class="btn-secondary" type="button" on:click={saveManualMeal} disabled={savingMeal}>Save manually</button>
{#if editingEntryId}<button class="btn-secondary" type="button" on:click={saveMealEdits}>Save edits</button><button class="btn-secondary" type="button" on:click={cancelEdit}>Cancel</button>{/if}
{#if status}<p id="status" class:text-red-600={statusError} class="text-sm font-semibold text-green-700">{status}</p>{/if}
</div>

View file

@ -1,5 +1,6 @@
<script>
import Field from './Field.svelte';
import { calculateCaloriePlan, planSummary } from './calorie.js';
export let settings;
export let plans = [];
@ -13,40 +14,67 @@
export let selectPlan;
export let deletePlan;
$: selectedPlan = plans.find(plan => plan.id === selectedPlanId) || plans[0];
let faqOpen = null;
const sexOptions = ['Female', 'Male'];
const goalOptions = ['Lose weight', 'Maintain weight', 'Gain weight', 'Build muscle'];
const motivationOptions = ['Improve my overall health', 'Feel more confident', 'Increase my fitness level', 'Prepare for an event', 'Improve my relationship with food'];
const challengeOptions = ['Resisting cravings', 'Staying motivated', 'Reducing portion sizes', 'Knowing what to eat', 'Being too busy'];
const trackingOptions = ['Photo log meals', 'Log before eating', 'Meal prep and plan ahead', 'Track calories closely', 'Build a streak'];
const calorieExperienceOptions = ['New to calorie counting', 'Tried before', 'Experienced'];
const fastingOptions = ['Interested', 'Tried it before', 'Not interested'];
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: 'What changes when I generate a new plan?',
a: 'A new saved version is created. Your older versions stay in Plan history, so you can switch back later.',
},
{
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: 'What should I write in the update box?',
a: 'Use short real-world notes: hunger, weight trend, steps, workouts, missed meals, cravings, sleep, schedule changes, or anything that made the plan hard to follow.',
},
{
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.',
a: 'No. Notes are sent to the AI to create the next plan version, 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.',
q: 'Is this medical advice?',
a: 'No. It is AI-generated guidance based on your inputs and logs. If you have a medical condition, are pregnant, or have a history of disordered eating, use a clinician or dietitian.',
},
];
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 target',
'daily calorie range', 'maintenance calories', 'protein target', 'meal logging strategy',
'activity adjustment rules', 'challenge-specific tactics', 'weekly review steps',
'safety notes', 'healthy habits', 'getting started', 'targets', 'habits',
]);
$: selectedPlan = plans.find(plan => plan.id === selectedPlanId) || plans[0];
$: caloriePlan = calculateCaloriePlan(settings);
$: targetCalories = Number(settings.calorieTarget) || caloriePlan?.goalCalories || 0;
$: profileMissing = [
['sex', settings.sex],
['age', settings.age],
['height', settings.heightCm],
['current weight', settings.weightKg],
].filter(([, value]) => !value).map(([label]) => label);
$: profileReady = profileMissing.length === 0;
$: planSections = sections(selectedPlan?.content);
$: summaryCards = [
{ label: 'Daily target', value: targetCalories ? `${targetCalories} kcal` : 'Add profile', note: settings.goal || 'Goal not set' },
{ label: 'Maintenance', value: caloriePlan ? `${caloriePlan.maintenanceCalories} kcal` : 'Unknown', note: caloriePlan ? `BMR ${caloriePlan.bmr} kcal` : 'Needs basics' },
{ label: 'Pace', value: settings.pace || 'Not set', note: settings.activityLevel ? `${settings.activityLevel} activity` : 'Activity not set' },
];
function cleanLine(line) {
return line.replace(/^#{1,6}\s*/, '').replace(/^[-*]\s+/, '').replace(/\*\*/g, '').replace(/^---+$/, '').trim();
return line
.replace(/^#{1,6}\s*/, '')
.replace(/^[-*]\s+/, '')
.replace(/^\d+\.\s+/, '')
.replace(/\*\*/g, '')
.replace(/^---+$/, '')
.trim();
}
function isHeading(line) {
@ -64,106 +92,213 @@
for (const line of lines) {
if (isHeading(line)) {
if (current.items.length) output.push(current);
current = { title: line.replace(/^\d+\.\s+/, '').replace(/:$/, ''), items: [] };
current = { title: line.replace(/:$/, ''), items: [] };
} else {
current.items.push(line);
}
}
output.push(current);
return output.filter(section => section.items.length || section.title !== 'Plan');
if (current.items.length) output.push(current);
return output;
}
function formatDate(value) {
if (!value) return 'Unknown date';
return new Date(value).toLocaleString([], { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
}
</script>
<section class="space-y-4">
<div>
<h2 class="text-xl font-bold">Plans</h2>
<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]">
<section class="card">
<h3 class="card-title">Profile</h3>
<div class="grid gap-3 p-4 md:grid-cols-2 xl:grid-cols-1">
<Field label="Height (cm)"><input class="input" type="number" min="80" bind:value={settings.heightCm}></Field>
<Field label="Current weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.weightKg}></Field>
<Field label="Target weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.targetWeightKg}></Field>
<Field label="Activity"><select class="input" bind:value={settings.activityLevel}><option>Low</option><option>Moderate</option><option>High</option></select></Field>
<Field label="Pace"><select class="input" bind:value={settings.pace}><option>Gentle</option><option>Steady</option><option>Aggressive</option></select></Field>
{#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}
<section class="space-y-5">
<section class="overflow-hidden rounded-3xl border border-slate-200 bg-slate-950 text-white shadow-sm">
<div class="grid gap-6 p-5 lg:grid-cols-[1fr_420px] lg:p-7">
<div>
<div class="text-xs font-black uppercase tracking-[0.28em] text-blue-200">Personal plan</div>
<h2 class="mt-3 max-w-3xl text-3xl font-black tracking-tight sm:text-4xl">Build a plan you can actually follow this week.</h2>
<p class="mt-3 max-w-2xl text-sm leading-6 text-slate-300">Set your basics, add the behavior details that usually make dieting hard, then keep each generated version as a clean document.</p>
<div class="mt-5 flex flex-wrap gap-2 text-xs font-bold">
<span class="rounded-full bg-white/10 px-3 py-1 text-white">{plans.length || 0} saved version{plans.length === 1 ? '' : 's'}</span>
<span class="rounded-full bg-white/10 px-3 py-1 text-white">{profileReady ? 'Ready to generate' : `Missing ${profileMissing.join(', ')}`}</span>
{#if selectedPlan}<span class="rounded-full bg-blue-500 px-3 py-1 text-white">Active v{selectedPlan.version || 1}</span>{/if}
</div>
</div>
</section>
<section class="card">
<h3 class="card-title">Current plan</h3>
<div class="grid gap-4 p-4">
{#if selectedPlan}
<div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
<div>
<strong>{selectedPlan.title}</strong>
<div class="text-xs text-slate-500">Version {selectedPlan.version || 1} · {new Date(selectedPlan.createdAt).toLocaleString()}</div>
</div>
<button class="rounded-md bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => deletePlan(selectedPlan.id)}>Delete</button>
</div>
<div class="grid gap-3">
{#each sections(selectedPlan.content) as section}
<article class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<h4 class="border-b border-slate-200 pb-2 text-base font-black text-slate-950">{section.title}</h4>
<ul class="mt-3 grid gap-2 text-sm leading-6 text-slate-700">
{#each section.items as item}<li>{item}</li>{/each}
</ul>
</article>
{/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: 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. Fill in your profile and click Generate.</div>
{/if}
<div class="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
{#each summaryCards as item}
<article class="rounded-2xl border border-white/10 bg-white/10 p-4 backdrop-blur">
<div class="text-[0.68rem] font-black uppercase tracking-widest text-blue-100">{item.label}</div>
<div class="mt-1 text-2xl font-black text-white">{item.value}</div>
<div class="mt-1 text-xs text-slate-300">{item.note}</div>
</article>
{/each}
</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">
{#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>
{#if faqOpen === i}
<p class="px-4 pb-4 text-sm leading-6 text-slate-600">{faq.a}</p>
<div class="grid gap-5 xl:grid-cols-[370px_minmax(0,1fr)]">
<aside class="space-y-5">
<section class="card">
<div class="border-b border-slate-200 bg-white p-4">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-black text-slate-950">1. Body target</h3>
<span class="rounded-full px-2.5 py-1 text-xs font-black {profileReady ? 'bg-green-50 text-green-700' : 'bg-amber-50 text-amber-700'}">{profileReady ? 'Complete' : 'Needs basics'}</span>
</div>
<p class="mt-1 text-xs leading-5 text-slate-500">These numbers calculate the baseline. Save or generate to persist changes.</p>
</div>
<div class="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-1">
<Field label="Sex"><select class="input" bind:value={settings.sex}><option value="">Select</option>{#each sexOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Age"><input class="input" type="number" min="16" max="100" bind:value={settings.age}></Field>
<Field label="Height (cm)"><input class="input" type="number" min="80" bind:value={settings.heightCm}></Field>
<Field label="Current weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.weightKg}></Field>
<Field label="Target weight (kg)"><input class="input" type="number" min="20" step="0.1" bind:value={settings.targetWeightKg}></Field>
<Field label="Main goal"><select class="input" bind:value={settings.goal}>{#each goalOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Activity"><select class="input" bind:value={settings.activityLevel}><option>Low</option><option>Moderate</option><option>High</option></select></Field>
<Field label="Pace"><select class="input" bind:value={settings.pace}><option>Gentle</option><option>Steady</option><option>Aggressive</option></select></Field>
<Field label="Daily kcal goal"><input class="input" type="number" min="0" bind:value={settings.calorieTarget} placeholder={caloriePlan ? `${caloriePlan.goalCalories}` : 'Calculated after profile'}></Field>
{#if caloriePlan}
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-3 text-sm text-blue-950 sm:col-span-2 xl:col-span-1">
<div class="font-black">Calculated target</div>
<div>{planSummary(settings)}</div>
<div class="mt-1 text-xs text-blue-700">Maintenance {caloriePlan.maintenanceCalories} kcal/day · BMR {caloriePlan.bmr} kcal/day</div>
</div>
{:else}
<p class="text-xs font-medium text-amber-600 sm:col-span-2 xl:col-span-1">Fill in sex, age, height, and current weight before generating.</p>
{/if}
</div>
{/each}
</section>
<section class="card">
<div class="border-b border-slate-200 bg-white p-4">
<h3 class="text-sm font-black text-slate-950">2. Personal strategy</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">This keeps the plan from reading like generic diet advice.</p>
</div>
<div class="grid gap-3 p-4">
<Field label="Why this matters"><select class="input" bind:value={settings.motivation}><option value="">Select</option>{#each motivationOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Biggest challenge"><select class="input" bind:value={settings.challenge}><option value="">Select</option>{#each challengeOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Tracking style"><select class="input" bind:value={settings.trackingStyle}><option value="">Select</option>{#each trackingOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Calorie-counting experience"><select class="input" bind:value={settings.calorieCountingExperience}><option value="">Select</option>{#each calorieExperienceOptions as option}<option>{option}</option>{/each}</select></Field>
<Field label="Fasting interest"><select class="input" bind:value={settings.fastingInterest}><option value="">Select</option>{#each fastingOptions as option}<option>{option}</option>{/each}</select></Field>
</div>
</section>
<section class="card">
<div class="border-b border-slate-200 bg-white p-4">
<h3 class="text-sm font-black text-slate-950">3. Generate</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">Creates a new version and keeps the older one.</p>
</div>
<div class="space-y-3 p-4">
<button class="btn-primary w-full" type="button" on:click={generatePlan} disabled={planLoading || !caloriePlan}>{planLoading ? 'Generating...' : 'Generate new plan'}</button>
{#if planStatus}<p class:text-red-600={planError} class="rounded-xl bg-slate-50 p-3 text-sm font-semibold text-green-700">{planStatus}</p>{/if}
</div>
</section>
<section class="card">
<div class="border-b border-slate-200 bg-white p-4">
<h3 class="text-sm font-black text-slate-950">Plan history</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">Switch versions without deleting the current one.</p>
</div>
<div class="grid gap-2 p-4">
{#each plans as plan}
<button class="rounded-2xl border px-3 py-3 text-left text-sm hover:border-blue-300 hover:bg-blue-50" class:border-blue-500={plan.id === selectedPlanId} class:bg-blue-50={plan.id === selectedPlanId} type="button" on:click={() => selectPlan(plan.id)}>
<div class="flex items-center justify-between gap-3">
<strong class="text-slate-900">{plan.title}</strong>
{#if plan.id === selectedPlanId}<span class="rounded-full bg-blue-600 px-2 py-0.5 text-[0.65rem] font-black uppercase tracking-wider text-white">Active</span>{/if}
</div>
<div class="mt-1 text-xs text-slate-500">Version {plan.version || 1} · {formatDate(plan.createdAt)}</div>
</button>
{:else}
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-500">No versions yet.</div>
{/each}
</div>
</section>
</aside>
<div class="space-y-5">
{#if selectedPlan}
<section class="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm">
<div class="border-b border-slate-200 bg-gradient-to-r from-slate-50 to-blue-50 p-5">
<div class="flex flex-col justify-between gap-4 sm:flex-row sm:items-start">
<div>
<div class="text-xs font-black uppercase tracking-[0.22em] text-blue-700">Current plan</div>
<h3 class="mt-1 text-2xl font-black tracking-tight text-slate-950">{selectedPlan.title}</h3>
<p class="mt-1 text-sm text-slate-500">Version {selectedPlan.version || 1} · saved {formatDate(selectedPlan.createdAt)}</p>
</div>
<button class="rounded-xl bg-red-50 px-4 py-2 text-sm font-black text-red-600 hover:bg-red-100" type="button" on:click={() => deletePlan(selectedPlan.id)}>Delete plan</button>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-3">
{#each summaryCards as item}
<div class="rounded-2xl border border-white bg-white/80 p-3 shadow-sm">
<div class="text-[0.68rem] font-black uppercase tracking-widest text-slate-400">{item.label}</div>
<div class="mt-1 text-lg font-black text-slate-950">{item.value}</div>
<div class="text-xs text-slate-500">{item.note}</div>
</div>
{/each}
</div>
</div>
<div class="grid gap-4 p-4 lg:p-5">
{#each planSections as section, index}
<article class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm lg:p-5">
<div class="flex items-center gap-3 border-b border-slate-100 pb-3">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-blue-600 text-xs font-black text-white">{index + 1}</span>
<h4 class="text-base font-black text-slate-950">{section.title}</h4>
</div>
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-700">
{#each section.items as item}
<li class="flex gap-3">
<span class="mt-2 h-2 w-2 shrink-0 rounded-full bg-blue-500"></span>
<span>{item}</span>
</li>
{/each}
</ul>
</article>
{:else}
<pre class="whitespace-pre-wrap rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm leading-6 text-slate-700">{selectedPlan.content}</pre>
{/each}
</div>
</section>
<section class="card">
<div class="border-b border-slate-200 bg-white p-4">
<h3 class="text-sm font-black text-slate-950">Adjust this plan</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">Tell the AI what happened since the last version. The note is not saved, only the new plan is.</p>
</div>
<div class="p-4">
<Field label="Fine-tune with meals, activities, or progress notes">
<textarea class="input min-h-32" bind:value={tuneNote} placeholder="Example: walked 7,000 steps daily, felt hungry at night, lost 0.3 kg, struggled with breakfast"></textarea>
</Field>
<div class="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center">
<button class="btn-primary" type="button" on:click={() => tunePlan(selectedPlan.id)} disabled={planLoading || !tuneNote?.trim()}>{planLoading ? 'Updating...' : 'Update plan with AI'}</button>
<p class="text-xs leading-5 text-slate-400">This keeps the current version in history and creates a new active version.</p>
</div>
</div>
</section>
{:else}
<section class="rounded-3xl border border-dashed border-slate-300 bg-white p-8 text-center shadow-sm">
<div class="mx-auto grid h-14 w-14 place-items-center rounded-2xl bg-blue-50 text-xl font-black text-blue-600">1</div>
<h3 class="mt-4 text-xl font-black text-slate-950">No plan yet</h3>
<p class="mx-auto mt-2 max-w-lg text-sm leading-6 text-slate-500">Fill in the body target and strategy sections, then generate your first saved plan.</p>
</section>
{/if}
<section class="card">
<h3 class="card-title">Plan 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>
{#if faqOpen === i}
<p class="px-4 pb-4 text-sm leading-6 text-slate-600">{faq.a}</p>
{/if}
</div>
{/each}
</div>
</section>
</div>
</section>
</div>
</section>

View file

@ -8,10 +8,14 @@
export let modelStatus;
export let modelError;
export let modelSearching;
export let modelActionId;
export let accountEmail;
export let accountVerified;
export let accountStatus;
export let accountError;
export let healthConnectStatus;
export let healthConnectError;
export let healthConnectSyncing;
export let passwordStatus;
export let passwordError;
export let resetPasswordValue;
@ -25,6 +29,7 @@
export let removeModel;
export let saveAccountEmail;
export let sendVerificationEmail;
export let syncHealthConnect;
export let changePassword;
export let resetPassword;
</script>
@ -65,7 +70,7 @@
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="mb-2 text-xs font-bold uppercase tracking-wide text-slate-500">Saved models</div>
<div class="max-h-40 overflow-y-auto pr-1">
{#each models as model}<div class="mb-2 flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-md bg-red-50 px-2 py-1 text-xs font-bold text-red-600 hover:bg-red-100" type="button" on:click={() => removeModel(model.id)}>Remove</button></div>{/each}
{#each models as model}<div class="mb-2 grid gap-2 rounded-lg bg-white px-3 py-2 text-sm shadow-sm sm:flex sm:items-center"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-lg bg-red-50 px-3 py-2 text-xs font-bold text-red-600 hover:bg-red-100 disabled:opacity-60" type="button" on:click={() => removeModel(model.id)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Removing...' : 'Remove'}</button></div>{/each}
</div>
</div>
</div>
@ -73,10 +78,28 @@
<label class="mb-2 block text-sm font-bold text-slate-700" for="modelSearch">Search provider models</label>
<div class="flex flex-col gap-2 sm:flex-row"><input id="modelSearch" class="input" bind:value={modelSearch} placeholder="Search current models, e.g. claude, gemini, gpt" on:keydown={(event) => event.key === 'Enter' && searchModels()}><button class="btn-primary sm:w-36" type="button" on:click={searchModels} disabled={modelSearching}>{modelSearching ? 'Searching...' : 'Search'}</button></div>
<div class="mt-3 max-h-72 overflow-y-auto rounded-xl border border-slate-200 bg-white p-2">
{#each discoveredModels as model}<div class="mb-2 flex items-center gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm"><button class="rounded-md bg-blue-600 px-2.5 py-1 text-xs font-black text-white hover:bg-blue-700" type="button" on:click={() => addModel(model)}>+</button><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span></div>{/each}
{#each discoveredModels as model}<div class="mb-2 grid gap-2 rounded-lg border border-slate-100 px-3 py-2 text-sm sm:flex sm:items-center"><span class="min-w-0 flex-1 truncate"><strong>{model.name}</strong> <span class="text-slate-400">{model.id}</span></span><button class="rounded-lg bg-blue-600 px-3 py-2 text-xs font-black text-white hover:bg-blue-700 disabled:opacity-60 sm:order-first" type="button" on:click={() => addModel(model)} disabled={modelActionId === model.id}>{modelActionId === model.id ? 'Adding...' : 'Add'}</button></div>{/each}
</div>
</div>
</div>
</section>
<section class="card xl:col-span-2">
<div class="border-b border-slate-200 bg-gradient-to-r from-blue-50 to-slate-50 p-4">
<div class="text-xs font-black uppercase tracking-[0.24em] text-blue-700">Garmin and Health Connect</div>
<h3 class="mt-1 text-xl font-black text-slate-950">Connect Garmin from the Android app</h3>
<p class="mt-1 text-sm leading-6 text-slate-600">The web app cannot pair directly with a Garmin watch. Garmin writes to Android Health Connect, then Calorie AI imports that activity.</p>
</div>
<div class="grid gap-3 p-4 text-sm leading-6 text-slate-600 lg:grid-cols-3">
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">1. Garmin Connect</strong>Pair the watch and confirm it syncs in the Garmin Connect Android app.</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">2. Health Connect</strong>In Garmin Connect, enable sharing to Android Health Connect. Garmin sends steps, calories, and workouts after each device sync.</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"><strong class="block text-slate-900">3. Calorie AI Android</strong>Open Calorie AI on Android, go to Settings, and tap Sync Health Connect. The web dashboard updates after the server receives the sync.</div>
</div>
<div class="flex flex-col gap-3 border-t border-slate-100 px-4 py-4 sm:flex-row sm:items-center">
<button class="btn-primary sm:w-52" type="button" on:click={syncHealthConnect} disabled={healthConnectSyncing}>{healthConnectSyncing ? 'Syncing...' : 'Sync Health Connect'}</button>
<p id="healthConnectStatus" class:text-red-600={healthConnectError} class="text-sm font-semibold text-green-700">{healthConnectStatus}</p>
</div>
<div class="border-t border-slate-100 bg-slate-50 px-4 py-3 text-xs leading-5 text-slate-500">Direct Garmin web login is not available because Garmin Health API requires partner approval and API credentials.</div>
</section>
</div>
</section>

37
web/src/appConfig.js Normal file
View file

@ -0,0 +1,37 @@
export const pages = [
{ 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' },
];
export const mealTypes = ['Breakfast', 'Lunch', 'Dinner', 'Snack', 'Other'];
export const fallbackModels = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: 'claude-sonnet-4.6', name: 'Claude Sonnet 4.6' },
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
{ id: 'gpt-4o-mini', name: 'GPT-4o mini' },
];
export const defaultSettings = {
visionModel: fallbackModels[0].id,
taskModel: fallbackModels[0].id,
sex: '',
age: '',
heightCm: '',
weightKg: '',
targetWeightKg: '',
goal: 'Lose weight',
calorieTarget: '',
activityLevel: 'Moderate',
pace: 'Steady',
motivation: '',
challenge: '',
trackingStyle: '',
calorieCountingExperience: '',
fastingInterest: '',
};

66
web/src/calorie.js Normal file
View file

@ -0,0 +1,66 @@
function asNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
function roundTo(value, step = 10) {
return Math.round(value / step) * step;
}
function activityFactor(level) {
return {
Low: 1.2,
Moderate: 1.55,
High: 1.725,
}[level] || 1.375;
}
function paceDelta(pace, goal) {
if (goal === 'Gain weight' || goal === 'Build muscle') return pace === 'Aggressive' ? 500 : pace === 'Steady' ? 350 : 250;
if (goal === 'Maintain weight') return 0;
return pace === 'Aggressive' ? -750 : pace === 'Steady' ? -500 : -250;
}
function weeklyChangeKg(pace, goal) {
if (goal === 'Maintain weight') return 0;
const amount = pace === 'Aggressive' ? 0.75 : pace === 'Steady' ? 0.5 : 0.25;
return goal === 'Gain weight' || goal === 'Build muscle' ? amount : -amount;
}
export function calculateCaloriePlan(settings) {
const weight = asNumber(settings.weightKg);
const height = asNumber(settings.heightCm);
const age = asNumber(settings.age);
if (!weight || !height || !age || !settings.sex) return null;
const sexOffset = settings.sex === 'Male' ? 5 : -161;
const bmr = 10 * weight + 6.25 * height - 5 * age + sexOffset;
const maintenanceCalories = roundTo(bmr * activityFactor(settings.activityLevel));
const goal = settings.goal || 'Lose weight';
const goalCalories = Math.max(settings.sex === 'Male' ? 1500 : 1200, roundTo(maintenanceCalories + paceDelta(settings.pace, goal)));
const proteinTarget = roundTo(weight * (goal === 'Build muscle' ? 1.8 : goal === 'Maintain weight' ? 1.3 : 1.6), 5);
const weeklyKg = weeklyChangeKg(settings.pace, goal);
const targetWeight = asNumber(settings.targetWeightKg);
const remainingKg = targetWeight ? targetWeight - weight : 0;
const weeksToTarget = remainingKg && weeklyKg && Math.sign(remainingKg) === Math.sign(weeklyKg)
? Math.ceil(Math.abs(remainingKg / weeklyKg))
: 0;
return {
bmr: roundTo(bmr),
maintenanceCalories,
goalCalories,
proteinTarget,
weeklyKg,
weeksToTarget,
};
}
export function planSummary(settings) {
const plan = calculateCaloriePlan(settings);
if (!plan) return 'Add sex, age, height, weight, and activity level to calculate a daily target.';
const direction = plan.weeklyKg > 0 ? 'gain' : plan.weeklyKg < 0 ? 'lose' : 'maintain';
const pace = plan.weeklyKg ? `${direction} about ${Math.abs(plan.weeklyKg).toFixed(2)} kg/week` : 'maintain weight';
const weeks = plan.weeksToTarget ? ` Estimated time to target: ${plan.weeksToTarget} weeks.` : '';
return `${plan.goalCalories} kcal/day, ${plan.proteinTarget} g protein/day, ${pace}.${weeks}`;
}

View file

@ -6,15 +6,17 @@ const png = Buffer.from(
);
async function mockModels(page) {
let models = [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
];
await page.route('**/api/models', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
],
models,
}),
});
});
@ -35,19 +37,28 @@ async function mockModels(page) {
await page.route('**/api/models/add', async (route) => {
const body = route.request().postDataJSON();
models = [...models.filter(model => model.id !== body.id), { id: body.id, name: body.name || body.id }];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
ok: true,
defaultModel: 'claude-haiku-4.5',
models: [
{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' },
{ id: body.id, name: body.name || body.id },
],
models,
}),
});
});
await page.route('**/api/models/remove', async (route) => {
const body = route.request().postDataJSON();
models = models.filter(model => model.id !== body.id);
if (!models.length) models = [{ id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5' }];
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true, defaultModel: models[0].id, models }),
});
});
}
async function mockPlans(page) {
@ -107,6 +118,18 @@ async function mockEntries(page) {
});
}
async function mockActivities(page) {
let activities = [];
await page.route('**/api/activities', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ activities }) });
});
await page.route('**/api/activities/sync', async (route) => {
const body = route.request().postDataJSON();
activities = [...(body.activities || []), ...activities];
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ activities }) });
});
}
async function mockSettings(page) {
let settings = {};
await page.route('**/api/settings', async (route) => {
@ -122,6 +145,7 @@ async function login(page) {
await mockModels(page);
await mockPlans(page);
await mockEntries(page);
await mockActivities(page);
await mockSettings(page);
await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
@ -174,9 +198,15 @@ test.describe('authenticated app', () => {
await page.locator('nav').getByRole('button').filter({ hasText: 'Plans' }).click();
await expect(page.getByRole('heading', { name: 'Plans' })).toBeVisible();
await page.getByLabel('Sex').selectOption('Male');
await page.getByLabel('Age').fill('36');
await page.getByLabel('Height (cm)').fill('178');
await page.getByLabel('Current weight (kg)').fill('91');
await page.getByLabel('Target weight (kg)').fill('82');
await page.getByLabel('Why this matters').selectOption('Improve my overall health');
await page.getByLabel('Biggest challenge').selectOption('Being too busy');
await page.getByLabel('Tracking style').selectOption('Photo log meals');
await expect(page.getByText('Calculated target')).toBeVisible();
await page.getByRole('button', { name: 'Generate new plan' }).click();
await expect(page.getByText('Plan generated.')).toBeVisible();
await expect(page.getByText('Targets')).toBeVisible();
@ -235,11 +265,14 @@ test.describe('authenticated app', () => {
await page.locator('#modelSearch').fill('gemini');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText('Gemini 2.5 Flash')).toBeVisible();
await page.getByRole('button', { name: '+' }).last().click();
await page.getByRole('button', { name: 'Add' }).last().click();
await expect(page.locator('#modelStatus')).toContainText('Added Gemini 2.5 Flash.');
await page.locator('#visionModel').selectOption('gemini-2.5-flash');
await page.locator('#taskModel').selectOption('gemini-2.5-flash');
await page.getByRole('button', { name: 'Save selected models' }).click();
await expect(page.locator('#modelStatus')).toContainText('Models saved.');
await page.getByRole('button', { name: 'Remove' }).last().click();
await expect(page.locator('#modelStatus')).toContainText('Model removed.');
await page.getByRole('button', { name: 'Log meal' }).click();
await page.locator('#mealDate').fill('2026-05-18');