Compare commits
18 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd70da8c71 | ||
|
|
38d9f50c31 | ||
|
|
ef6b3a494c | ||
|
|
176a9fc3bb | ||
|
|
33d50258ce | ||
|
|
f73b58a158 | ||
|
|
fcef0d48db | ||
|
|
f14b433085 | ||
|
|
8da55652e0 | ||
|
|
40e65ca70c | ||
|
|
03d4178113 | ||
|
|
a4e44201a2 | ||
|
|
b93e479be4 | ||
|
|
af35b70399 | ||
|
|
709d6c1f6f | ||
|
|
99bd1bff42 | ||
|
|
19d8899057 | ||
|
|
9f660d50bc |
|
|
@ -36,4 +36,4 @@ jobs:
|
|||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: calorie-ai-debug-apk
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
|
|
|
|||
|
|
@ -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/app-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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -8,5 +8,6 @@ local.properties
|
|||
web/node_modules/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
web/.test-data/
|
||||
web/data/
|
||||
web/.env
|
||||
|
|
|
|||
22
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -12,14 +12,20 @@ android {
|
|||
applicationId 'com.danvics.calorieai'
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 1
|
||||
versionName '1.0'
|
||||
versionCode 11
|
||||
versionName '1.11'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
applicationVariants.configureEach { variant ->
|
||||
variant.outputs.configureEach {
|
||||
outputFileName = "calorie-ai-${variant.versionName}.apk"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
|
|
@ -38,8 +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-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'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,33 @@
|
|||
<?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"
|
||||
android:label="Calorie AI"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,31 @@
|
|||
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 com.danvics.calorieai.ai.AiClient
|
||||
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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
|
|
@ -23,91 +34,440 @@ import java.util.UUID
|
|||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val repo = AppRepository(getSharedPreferences("calorie_ai", MODE_PRIVATE))
|
||||
val ai = AiClient()
|
||||
setContent { AppRoot(repo, ai) }
|
||||
val repo = ApiRepository(getSharedPreferences("calorie_ai", MODE_PRIVATE))
|
||||
setContent { AppRoot(repo) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRoot(repo: AppRepository, ai: AiClient) {
|
||||
private fun AppRoot(repo: ApiRepository) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var entries by remember { mutableStateOf(repo.entries()) }
|
||||
var trash by remember { mutableStateOf(repo.trash()) }
|
||||
var settings by remember { mutableStateOf(repo.settings()) }
|
||||
val parser = remember { NutritionParser() }
|
||||
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("") }
|
||||
var connecting by remember { mutableStateOf(false) }
|
||||
|
||||
var appState by remember { mutableStateOf(AppState()) }
|
||||
var editing by remember { mutableStateOf<MealEntry?>(null) }
|
||||
var selectedImage by remember { mutableStateOf<ImagePayload?>(null) }
|
||||
var selectedImageBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var status by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<MealEntry?>(null) }
|
||||
var planStatus by remember { mutableStateOf("") }
|
||||
var planBusy by remember { mutableStateOf(false) }
|
||||
var syncing by remember { mutableStateOf(false) }
|
||||
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 { input ->
|
||||
val bytes = input.readBytes()
|
||||
ImagePayload.fromBytes(uri.lastPathSegment ?: "meal image", context.contentResolver.getType(uri) ?: "image/jpeg", bytes)
|
||||
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 meal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun persistEntries(next: List<MealEntry>) { entries = next; repo.saveEntries(next) }
|
||||
fun persistTrash(next: List<MealEntry>) { trash = next; repo.saveTrash(next) }
|
||||
fun persistSettings(next: AppSettings) { settings = next; repo.saveSettings(next) }
|
||||
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) {
|
||||
runCatching { repo.getAppState() }
|
||||
.onSuccess { state -> withContext(Dispatchers.Main) { appState = state; syncing = false } }
|
||||
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth(); syncing = false } }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(connected) {
|
||||
if (connected) syncState()
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
CalorieTheme {
|
||||
ConnectScreen(error = connectError, connecting = connecting) { url, user, pass ->
|
||||
connecting = true
|
||||
connectError = ""
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { repo.login(url, user, pass) }
|
||||
.onSuccess { cookie ->
|
||||
repo.saveConfig(url, cookie)
|
||||
withContext(Dispatchers.Main) { connecting = false; connected = true }
|
||||
}
|
||||
.onFailure { e ->
|
||||
withContext(Dispatchers.Main) { connecting = false; connectError = e.message ?: "Connection failed" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
CalorieAiApp(
|
||||
entries = entries,
|
||||
trash = trash,
|
||||
settings = settings,
|
||||
appState = appState,
|
||||
serverUrl = repo.localConfig().serverUrl,
|
||||
status = status,
|
||||
busy = busy,
|
||||
editing = editing,
|
||||
selectedImageName = selectedImage?.name.orEmpty(),
|
||||
selectedImageBitmap = selectedImageBitmap,
|
||||
planStatus = planStatus,
|
||||
planBusy = planBusy,
|
||||
syncing = syncing,
|
||||
mealSaveCount = mealSaveCount,
|
||||
onSync = { syncState() },
|
||||
onPickImage = { imagePicker.launch("image/*") },
|
||||
onTakePhoto = { camera.launch(null) },
|
||||
onCancelEdit = { editing = null; status = "" },
|
||||
onSaveManualEdit = { updated ->
|
||||
persistEntries(entries.map { if (it.id == updated.id) updated else it })
|
||||
onTakePhoto = { camera.launch(cameraImageUri) },
|
||||
onCancelEdit = {
|
||||
editing = null
|
||||
status = "Meal updated."
|
||||
selectedImage = null
|
||||
selectedImageBitmap = null
|
||||
status = ""
|
||||
},
|
||||
onSaveManualEdit = { updated ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { repo.upsertEntry(updated) }
|
||||
.onSuccess { (entries, trash) ->
|
||||
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}" } }
|
||||
}
|
||||
},
|
||||
onAnalyze = { draft ->
|
||||
if (!ai.isConfigured(settings)) { status = "Set API base URL and nutrition model in Settings first."; return@CalorieAiApp }
|
||||
if (appState.settings.taskModel.isBlank()) { status = "No AI model configured on server."; return@CalorieAiApp }
|
||||
busy = true
|
||||
status = "Analyzing meal..."
|
||||
scope.launch {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { ai.estimateMeal(settings, draft.description, draft.measure, selectedImage) }
|
||||
}.onSuccess { estimate ->
|
||||
val visionEstimate = if (selectedImage != null && appState.settings.visionModel.isNotBlank()) {
|
||||
repo.chat(buildVisionBody(appState.settings.visionModel, draft.description, draft.measure, selectedImage!!))
|
||||
} else ""
|
||||
val 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(),
|
||||
date = draft.date.ifBlank { LocalDate.now().toString() },
|
||||
time = draft.time.ifBlank { LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) },
|
||||
imageIncluded = selectedImage != null,
|
||||
imageName = selectedImage?.name.orEmpty(),
|
||||
visionEstimate = visionEstimate,
|
||||
estimate = estimate
|
||||
)
|
||||
persistEntries(if (editing == null) entries + meal else entries.map { if (it.id == meal.id) meal else it })
|
||||
editing = meal
|
||||
status = "Saved. You can edit values or analyze again."
|
||||
}.onFailure { status = "Analysis failed: ${it.message}" }
|
||||
busy = false
|
||||
repo.upsertEntry(meal) to meal
|
||||
}.onSuccess { (pair, meal) ->
|
||||
val (entries, trash) = pair
|
||||
withContext(Dispatchers.Main) {
|
||||
appState = appState.copy(entries = entries, trash = trash)
|
||||
editing = null
|
||||
selectedImage = null
|
||||
selectedImageBitmap = null
|
||||
status = "Saved. ${meal.estimate.mealName} · ${meal.estimate.calories} kcal"
|
||||
mealSaveCount++
|
||||
busy = false
|
||||
}
|
||||
}.onFailure { e ->
|
||||
withContext(Dispatchers.Main) {
|
||||
if (e is UnauthorizedException) handleUnauth()
|
||||
else status = "Analysis failed: ${e.message}"
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onEdit = { editing = it },
|
||||
onMoveToTrash = { ids ->
|
||||
val moving = entries.filter { it.id in ids }.map { it.copy(trashedAt = LocalDate.now().toString()) }
|
||||
persistEntries(entries.filterNot { it.id in ids })
|
||||
persistTrash(moving + trash)
|
||||
},
|
||||
onRestore = { id ->
|
||||
trash.find { it.id == id }?.let { found ->
|
||||
persistTrash(trash.filterNot { it.id == id })
|
||||
persistEntries(listOf(found.copy(trashedAt = "")) + entries)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { repo.deleteEntries(ids.toList()) }
|
||||
.onSuccess { (entries, trash) -> withContext(Dispatchers.Main) { appState = appState.copy(entries = entries, trash = trash) } }
|
||||
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
|
||||
}
|
||||
},
|
||||
onSettingsChange = ::persistSettings,
|
||||
onSearchModels = { query -> withContext(Dispatchers.IO) { ai.searchModels(settings, query) } },
|
||||
onGeneratePlan = { withContext(Dispatchers.IO) { ai.weightPlan(settings) } }
|
||||
onClearTrash = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { repo.clearTrash() }
|
||||
.onSuccess { (entries, trash) -> withContext(Dispatchers.Main) { appState = appState.copy(entries = entries, trash = trash) } }
|
||||
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
|
||||
}
|
||||
},
|
||||
onRestore = { id ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { repo.restoreEntry(id) }
|
||||
.onSuccess { (entries, trash) -> withContext(Dispatchers.Main) { appState = appState.copy(entries = entries, trash = trash) } }
|
||||
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
|
||||
}
|
||||
},
|
||||
onSettingsChange = { updated ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { repo.saveSettings(updated) }
|
||||
.onSuccess { saved -> withContext(Dispatchers.Main) { appState = appState.copy(settings = saved) } }
|
||||
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
|
||||
}
|
||||
},
|
||||
onGeneratePlan = {
|
||||
val settings = appState.settings
|
||||
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 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}",
|
||||
version = appState.plans.plans.size + 1,
|
||||
content = content,
|
||||
createdAt = java.time.Instant.now().toString()
|
||||
)
|
||||
repo.savePlan(plan)
|
||||
}.onSuccess { plans ->
|
||||
withContext(Dispatchers.Main) { appState = appState.copy(plans = plans, settings = planSettings); planStatus = "Plan generated."; planBusy = false }
|
||||
}.onFailure { e ->
|
||||
withContext(Dispatchers.Main) {
|
||||
if (e is UnauthorizedException) handleUnauth()
|
||||
else planStatus = "Error: ${e.message}"
|
||||
planBusy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onTunePlan = { planId, note ->
|
||||
val currentPlan = appState.plans.plans.find { it.id == planId } ?: return@CalorieAiApp
|
||||
planBusy = true
|
||||
planStatus = "Updating plan..."
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val recentMeals = appState.entries.takeLast(15)
|
||||
.joinToString("; ") { "${it.date} ${it.mealType}: ${it.estimate.mealName}, ${it.estimate.calories} kcal" }
|
||||
.ifBlank { "none logged yet" }
|
||||
val 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(),
|
||||
title = "Updated plan $version",
|
||||
version = version,
|
||||
content = content,
|
||||
createdAt = java.time.Instant.now().toString(),
|
||||
previousPlanId = planId
|
||||
)
|
||||
repo.savePlan(updated)
|
||||
}.onSuccess { plans ->
|
||||
withContext(Dispatchers.Main) { appState = appState.copy(plans = plans); planStatus = "Plan updated."; planBusy = false }
|
||||
}.onFailure { e ->
|
||||
withContext(Dispatchers.Main) {
|
||||
if (e is UnauthorizedException) handleUnauth()
|
||||
else planStatus = "Error: ${e.message}"
|
||||
planBusy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectPlan = { id ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { repo.selectPlan(id) }
|
||||
.onSuccess { plans -> withContext(Dispatchers.Main) { appState = appState.copy(plans = plans) } }
|
||||
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
|
||||
}
|
||||
},
|
||||
onDeletePlan = { id ->
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching { repo.deletePlan(id) }
|
||||
.onSuccess { plans -> withContext(Dispatchers.Main) { appState = appState.copy(plans = plans) } }
|
||||
.onFailure { e -> withContext(Dispatchers.Main) { if (e is UnauthorizedException) handleUnauth() } }
|
||||
}
|
||||
},
|
||||
healthConnectStatus = healthConnectStatus,
|
||||
onHealthConnectSync = { syncHealthConnect() },
|
||||
onDisconnect = {
|
||||
repo.disconnect()
|
||||
connected = false
|
||||
appState = AppState()
|
||||
editing = null
|
||||
status = ""
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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",
|
||||
JSONArray()
|
||||
.put(JSONObject().put("type", "text").put("text", "Analyze this meal photo. Estimate foods, portions, calories, protein, carbs, fat, fruit servings, vegetable servings. Description: $description. Portion: $measure"))
|
||||
.put(JSONObject().put("type", "image_url").put("image_url", JSONObject().put("url", image.dataUri)))
|
||||
)))
|
||||
|
||||
private fun 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",
|
||||
"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 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, 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-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 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, 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\n\nRecent activity:\n$recentActivity"
|
||||
)))
|
||||
|
|
|
|||
|
|
@ -1,90 +1,8 @@
|
|||
package com.danvics.calorieai.ai
|
||||
|
||||
import android.util.Base64
|
||||
import android.graphics.Bitmap
|
||||
import com.danvics.calorieai.data.AppSettings
|
||||
import com.danvics.calorieai.data.ModelOption
|
||||
import com.danvics.calorieai.data.NutritionEstimate
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.InputStream
|
||||
import android.util.Base64
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class AiClient(private val parser: NutritionParser = NutritionParser()) {
|
||||
fun isConfigured(settings: AppSettings) = settings.apiBaseUrl.isNotBlank() && settings.nutritionModel.isNotBlank()
|
||||
|
||||
fun estimateMeal(settings: AppSettings, description: String, measure: String, image: ImagePayload?): NutritionEstimate {
|
||||
val vision = if (image != null && settings.visionModel.isNotBlank()) visionEstimate(settings, description, measure, image) else ""
|
||||
val prompt = "Estimate nutrition for one meal. Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\n\nDescription: $description\nPortion: $measure\nImage estimate: $vision"
|
||||
val body = JSONObject()
|
||||
.put("model", settings.nutritionModel)
|
||||
.put("temperature", 0.1)
|
||||
.put("messages", JSONArray()
|
||||
.put(JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap the response in markdown."))
|
||||
.put(JSONObject().put("role", "user").put("content", prompt)))
|
||||
return parser.parse(chat(settings, body))
|
||||
}
|
||||
|
||||
fun weightPlan(settings: AppSettings): String {
|
||||
val prompt = "Create a safe personalized weight-loss plan. Height ${settings.heightCm} cm, current weight ${settings.weightKg} kg, target ${settings.targetWeightKg.ifBlank { "not set" }} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, and safety notes."
|
||||
val body = JSONObject()
|
||||
.put("model", settings.nutritionModel)
|
||||
.put("temperature", 0.2)
|
||||
.put("messages", JSONArray()
|
||||
.put(JSONObject().put("role", "system").put("content", "Give concise nutrition guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals."))
|
||||
.put(JSONObject().put("role", "user").put("content", prompt)))
|
||||
return chat(settings, body)
|
||||
}
|
||||
|
||||
fun searchModels(settings: AppSettings, query: String): List<ModelOption> {
|
||||
val base = settings.apiBaseUrl.trimEnd('/').removeSuffix("/v1")
|
||||
val response = runCatching { request(settings, "$base/model/info", null, "GET") }.getOrElse {
|
||||
request(settings, settings.apiBaseUrl.trimEnd('/') + "/models", null, "GET")
|
||||
}
|
||||
val root = JSONObject(response)
|
||||
val rows = root.optJSONArray("data") ?: JSONArray()
|
||||
val q = query.trim().lowercase()
|
||||
return (0 until rows.length()).mapNotNull { rows.optJSONObject(it) }.mapNotNull { item ->
|
||||
val id = item.optString("model_name", item.optString("id"))
|
||||
if (id.isBlank()) null else ModelOption(id, id)
|
||||
}.distinctBy { it.id }.filter { q.isBlank() || it.id.lowercase().contains(q) }.take(100)
|
||||
}
|
||||
|
||||
private fun visionEstimate(settings: AppSettings, description: String, measure: String, image: ImagePayload): String {
|
||||
val content = JSONArray()
|
||||
.put(JSONObject().put("type", "text").put("text", "Analyze this meal photo for nutrition tracking. Estimate foods, portions, calories, protein grams, carbs grams, fat grams, fruit servings, and vegetable servings. Description: $description; portion: $measure"))
|
||||
.put(JSONObject().put("type", "image_url").put("image_url", JSONObject().put("url", image.dataUri)))
|
||||
val body = JSONObject().put("model", settings.visionModel).put("temperature", 0.15)
|
||||
.put("messages", JSONArray().put(JSONObject().put("role", "user").put("content", content)))
|
||||
return chat(settings, body)
|
||||
}
|
||||
|
||||
private fun chat(settings: AppSettings, body: JSONObject): String {
|
||||
val response = request(settings, settings.apiBaseUrl.trimEnd('/') + "/chat/completions", body.toString(), "POST")
|
||||
return JSONObject(response).getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content").trim()
|
||||
}
|
||||
|
||||
private fun request(settings: AppSettings, url: String, body: String?, method: String): String {
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 90_000
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
if (settings.apiKey.isNotBlank()) setRequestProperty("Authorization", "Bearer ${settings.apiKey}")
|
||||
}
|
||||
if (body != null) {
|
||||
connection.doOutput = true
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
}
|
||||
val code = connection.responseCode
|
||||
val text = (if (code in 200..299) connection.inputStream else connection.errorStream).readTextSafely()
|
||||
if (code !in 200..299) error("HTTP $code: $text")
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
data class ImagePayload(val name: String, val dataUri: String) {
|
||||
companion object {
|
||||
|
|
@ -100,5 +18,3 @@ data class ImagePayload(val name: String, val dataUri: String) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun InputStream?.readTextSafely(): String = this?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } ?: ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,64 +3,224 @@ package com.danvics.calorieai.data
|
|||
import android.content.SharedPreferences
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class AppRepository(private val prefs: SharedPreferences) {
|
||||
fun entries(): List<MealEntry> = readEntries("entries")
|
||||
fun trash(): List<MealEntry> = readEntries("trash")
|
||||
class UnauthorizedException : Exception("Session expired. Please reconnect.")
|
||||
|
||||
fun saveEntries(entries: List<MealEntry>) = writeEntries("entries", entries)
|
||||
fun saveTrash(entries: List<MealEntry>) = writeEntries("trash", entries)
|
||||
class ApiRepository(private val prefs: SharedPreferences) {
|
||||
private var config = loadConfig()
|
||||
|
||||
fun settings(): AppSettings = AppSettings(
|
||||
apiBaseUrl = prefs.getString("api_base_url", "") ?: "",
|
||||
apiKey = prefs.getString("api_key", "") ?: "",
|
||||
visionModel = prefs.getString("vision_model", "gpt-4o-mini") ?: "gpt-4o-mini",
|
||||
nutritionModel = prefs.getString("nutrition_model", prefs.getString("task_model", "gpt-4o-mini")) ?: "gpt-4o-mini",
|
||||
heightCm = prefs.getString("height_cm", "") ?: "",
|
||||
weightKg = prefs.getString("weight_kg", "") ?: "",
|
||||
targetWeightKg = prefs.getString("target_weight_kg", "") ?: "",
|
||||
activityLevel = prefs.getString("activity_level", "Moderate") ?: "Moderate",
|
||||
pace = prefs.getString("pace", "Steady") ?: "Steady",
|
||||
models = readModels()
|
||||
fun localConfig() = config
|
||||
fun isConnected() = config.isConnected()
|
||||
|
||||
fun login(serverUrl: String, username: String, password: String): String {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
val body = JSONObject().put("username", username).put("password", password).toString()
|
||||
val conn = openConn("$base/api/login", "POST", null).apply {
|
||||
doOutput = true
|
||||
outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
}
|
||||
val code = conn.responseCode
|
||||
val text = conn.readStream(code)
|
||||
if (code !in 200..299) error(runCatching { JSONObject(text).optString("error", "Login failed") }.getOrElse { "Login failed ($code)" })
|
||||
return conn.headerFields["Set-Cookie"]
|
||||
?.firstOrNull { it.startsWith("calorie_ai_session=") }
|
||||
?.substringBefore(';')?.trim()
|
||||
?: error("No session cookie received from server")
|
||||
}
|
||||
|
||||
fun saveConfig(serverUrl: String, cookie: String) {
|
||||
config = LocalConfig(serverUrl, cookie)
|
||||
prefs.edit().putString("server_url", serverUrl).putString("session_cookie", cookie).apply()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
config = LocalConfig()
|
||||
prefs.edit().remove("server_url").remove("session_cookie").apply()
|
||||
}
|
||||
|
||||
fun getAppState(): AppState {
|
||||
val entries = JSONObject(get("/api/entries"))
|
||||
val 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
|
||||
)
|
||||
}
|
||||
|
||||
fun upsertEntry(entry: MealEntry): Pair<List<MealEntry>, List<MealEntry>> {
|
||||
val root = JSONObject(post("/api/entries", entry.toJson().toString()))
|
||||
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
|
||||
}
|
||||
|
||||
fun deleteEntries(ids: List<String>): Pair<List<MealEntry>, List<MealEntry>> {
|
||||
val body = JSONObject().put("ids", JSONArray(ids)).toString()
|
||||
val root = JSONObject(post("/api/entries/delete", body))
|
||||
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
|
||||
}
|
||||
|
||||
fun restoreEntry(id: String): Pair<List<MealEntry>, List<MealEntry>> {
|
||||
val root = JSONObject(post("/api/entries/restore", JSONObject().put("id", id).toString()))
|
||||
return parseEntries(root.optJSONArray("entries")) to parseEntries(root.optJSONArray("trash"))
|
||||
}
|
||||
|
||||
fun getSettings(): ServerSettings = parseSettings(JSONObject(get("/api/settings")))
|
||||
|
||||
fun saveSettings(settings: ServerSettings): ServerSettings =
|
||||
parseSettings(JSONObject(post("/api/settings", JSONObject()
|
||||
.put("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")))
|
||||
|
||||
fun savePlan(plan: Plan): PlansState =
|
||||
parsePlans(JSONObject(post("/api/plans", JSONObject()
|
||||
.put("id", plan.id)
|
||||
.put("title", plan.title)
|
||||
.put("version", plan.version)
|
||||
.put("content", plan.content)
|
||||
.put("createdAt", plan.createdAt)
|
||||
.put("previousPlanId", plan.previousPlanId)
|
||||
.toString())))
|
||||
|
||||
fun selectPlan(id: String): PlansState =
|
||||
parsePlans(JSONObject(post("/api/plans/select", JSONObject().put("id", id).toString())))
|
||||
|
||||
fun deletePlan(id: String): PlansState =
|
||||
parsePlans(JSONObject(post("/api/plans/delete", JSONObject().put("id", id).toString())))
|
||||
|
||||
fun 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()
|
||||
}
|
||||
|
||||
private fun get(path: String) = request(path, null, "GET")
|
||||
private fun post(path: String, body: String) = request(path, body, "POST")
|
||||
|
||||
private fun request(path: String, body: String?, method: String): String {
|
||||
val conn = openConn(config.serverUrl.trimEnd('/') + path, method, config.sessionCookie)
|
||||
if (body != null) {
|
||||
conn.doOutput = true
|
||||
conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
}
|
||||
val code = conn.responseCode
|
||||
if (code == 401) throw UnauthorizedException()
|
||||
val text = conn.readStream(code)
|
||||
if (code !in 200..299) error("HTTP $code: $text")
|
||||
return text
|
||||
}
|
||||
|
||||
private fun openConn(url: String, method: String, cookie: String?) =
|
||||
(URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 90_000
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
if (!cookie.isNullOrBlank()) setRequestProperty("Cookie", cookie)
|
||||
}
|
||||
|
||||
private fun loadConfig() = LocalConfig(
|
||||
serverUrl = prefs.getString("server_url", "") ?: "",
|
||||
sessionCookie = prefs.getString("session_cookie", "") ?: ""
|
||||
)
|
||||
|
||||
fun saveSettings(settings: AppSettings) {
|
||||
prefs.edit()
|
||||
.putString("api_base_url", settings.apiBaseUrl)
|
||||
.putString("api_key", settings.apiKey)
|
||||
.putString("vision_model", settings.visionModel)
|
||||
.putString("nutrition_model", settings.nutritionModel)
|
||||
.putString("height_cm", settings.heightCm)
|
||||
.putString("weight_kg", settings.weightKg)
|
||||
.putString("target_weight_kg", settings.targetWeightKg)
|
||||
.putString("activity_level", settings.activityLevel)
|
||||
.putString("pace", settings.pace)
|
||||
.putString("models", JSONArray(settings.models.map { JSONObject().put("id", it.id).put("name", it.name) }).toString())
|
||||
.apply()
|
||||
private fun parseEntries(arr: JSONArray?): List<MealEntry> =
|
||||
(0 until (arr?.length() ?: 0)).mapNotNull { arr?.optJSONObject(it)?.toMealEntry() }
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
private fun readEntries(key: String): List<MealEntry> = runCatching {
|
||||
val array = JSONArray(prefs.getString(key, "[]"))
|
||||
(0 until array.length()).mapNotNull { index -> array.optJSONObject(index)?.toMealEntry() }
|
||||
}.getOrDefault(emptyList())
|
||||
private fun parseSettings(json: JSONObject) = ServerSettings(
|
||||
visionModel = json.optString("visionModel"),
|
||||
taskModel = json.optString("taskModel"),
|
||||
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" },
|
||||
motivation = json.optString("motivation"),
|
||||
challenge = json.optString("challenge"),
|
||||
trackingStyle = json.optString("trackingStyle"),
|
||||
calorieCountingExperience = json.optString("calorieCountingExperience"),
|
||||
fastingInterest = json.optString("fastingInterest")
|
||||
)
|
||||
|
||||
private fun writeEntries(key: String, entries: List<MealEntry>) {
|
||||
prefs.edit().putString(key, JSONArray(entries.map { it.toJson() }).toString()).apply()
|
||||
private fun parsePlans(json: JSONObject): PlansState {
|
||||
val arr = json.optJSONArray("plans") ?: JSONArray()
|
||||
val plans = (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }.map { obj ->
|
||||
Plan(
|
||||
id = obj.optString("id"),
|
||||
title = obj.optString("title", "Plan"),
|
||||
version = obj.optInt("version", 1),
|
||||
content = obj.optString("content"),
|
||||
createdAt = obj.optString("createdAt"),
|
||||
previousPlanId = obj.optString("previousPlanId")
|
||||
)
|
||||
}
|
||||
return PlansState(
|
||||
plans = plans,
|
||||
selectedPlanId = json.optString("selectedPlanId").ifBlank { plans.firstOrNull()?.id ?: "" }
|
||||
)
|
||||
}
|
||||
|
||||
private fun readModels(): List<ModelOption> = runCatching {
|
||||
val array = JSONArray(prefs.getString("models", "[]"))
|
||||
(0 until array.length()).mapNotNull { array.optJSONObject(it) }.map { ModelOption(it.optString("id"), it.optString("name", it.optString("id"))) }
|
||||
}.getOrDefault(emptyList()).ifEmpty { listOf(ModelOption("gpt-4o-mini")) }
|
||||
private fun parseModels(json: JSONObject): List<ModelOption> {
|
||||
val arr = json.optJSONArray("models") ?: return emptyList()
|
||||
return (0 until arr.length()).mapNotNull { arr.optJSONObject(it) }
|
||||
.map { obj -> ModelOption(obj.optString("id"), obj.optString("name", obj.optString("id"))) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun HttpURLConnection.readStream(code: Int): String =
|
||||
(if (code in 200..299) inputStream else errorStream ?: inputStream)
|
||||
.bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||
|
||||
fun MealEntry.toJson(): JSONObject = JSONObject()
|
||||
.put("id", id).put("date", date).put("time", time).put("mealType", mealType)
|
||||
.put("description", description).put("measure", measure).put("imageIncluded", imageIncluded)
|
||||
.put("imageName", imageName).put("visionEstimate", visionEstimate).put("trashedAt", trashedAt)
|
||||
.put("mealName", estimate.mealName).put("calories", estimate.calories)
|
||||
.put("proteinGrams", estimate.proteinGrams).put("carbsGrams", estimate.carbsGrams).put("fatGrams", estimate.fatGrams)
|
||||
.put("fruitServings", estimate.fruitServings).put("vegetableServings", estimate.vegetableServings)
|
||||
.put("proteinGrams", estimate.proteinGrams).put("carbsGrams", estimate.carbsGrams)
|
||||
.put("fatGrams", estimate.fatGrams).put("fruitServings", estimate.fruitServings)
|
||||
.put("vegetableServings", estimate.vegetableServings)
|
||||
.put("foodGroups", estimate.foodGroups).put("notes", estimate.notes).put("rawAi", estimate.raw)
|
||||
|
||||
fun JSONObject.toMealEntry(): MealEntry = MealEntry(
|
||||
|
|
@ -75,8 +235,37 @@ fun JSONObject.toMealEntry(): MealEntry = MealEntry(
|
|||
visionEstimate = optString("visionEstimate"),
|
||||
trashedAt = optString("trashedAt"),
|
||||
estimate = NutritionEstimate(
|
||||
mealName = optString("mealName", "Meal"), calories = optInt("calories"), proteinGrams = optInt("proteinGrams"),
|
||||
carbsGrams = optInt("carbsGrams"), fatGrams = optInt("fatGrams"), fruitServings = optDouble("fruitServings"),
|
||||
vegetableServings = optDouble("vegetableServings"), foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi")
|
||||
mealName = optString("mealName", "Meal").ifBlank { "Meal" },
|
||||
calories = optInt("calories"), proteinGrams = optInt("proteinGrams"),
|
||||
carbsGrams = optInt("carbsGrams"), fatGrams = optInt("fatGrams"),
|
||||
fruitServings = optDouble("fruitServings"), vegetableServings = optDouble("vegetableServings"),
|
||||
foodGroups = optString("foodGroups"), notes = optString("notes"), raw = optString("rawAi")
|
||||
)
|
||||
)
|
||||
|
||||
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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -41,15 +41,62 @@ data class DayTotals(
|
|||
|
||||
data class ModelOption(val id: String, val name: String = id)
|
||||
|
||||
data class AppSettings(
|
||||
val apiBaseUrl: String = "",
|
||||
val apiKey: String = "",
|
||||
val visionModel: String = "gpt-4o-mini",
|
||||
val nutritionModel: String = "gpt-4o-mini",
|
||||
data class 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()
|
||||
}
|
||||
|
||||
data class Plan(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val version: Int = 1,
|
||||
val content: String,
|
||||
val createdAt: String,
|
||||
val previousPlanId: String = ""
|
||||
)
|
||||
|
||||
data class PlansState(
|
||||
val plans: List<Plan> = emptyList(),
|
||||
val selectedPlanId: String = ""
|
||||
)
|
||||
|
||||
data class ServerSettings(
|
||||
val visionModel: String = "",
|
||||
val taskModel: String = "",
|
||||
val 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 models: List<ModelOption> = listOf(ModelOption("gpt-4o-mini"))
|
||||
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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -1,25 +1,41 @@
|
|||
package com.danvics.calorieai.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.danvics.calorieai.data.*
|
||||
|
||||
private enum class Screen(val label: String) { Dashboard("Dashboard"), Log("Log"), Diary("Diary"), Trash("Trash"), Settings("Settings") }
|
||||
private enum class Screen(val label: String, val icon: ImageVector) {
|
||||
Dashboard("Dashboard", Icons.Default.Home),
|
||||
Log("Log meal", Icons.Default.Add),
|
||||
Diary("Diary", Icons.Default.Edit),
|
||||
Plans("Plans", Icons.Default.Star),
|
||||
Settings("Settings", Icons.Default.Settings),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalorieAiApp(
|
||||
entries: List<MealEntry>,
|
||||
trash: List<MealEntry>,
|
||||
settings: AppSettings,
|
||||
appState: AppState,
|
||||
serverUrl: String,
|
||||
status: String,
|
||||
busy: Boolean,
|
||||
editing: MealEntry?,
|
||||
selectedImageName: String,
|
||||
selectedImageBitmap: Bitmap?,
|
||||
planStatus: String,
|
||||
planBusy: Boolean,
|
||||
syncing: Boolean,
|
||||
mealSaveCount: Int,
|
||||
onSync: () -> Unit,
|
||||
onPickImage: () -> Unit,
|
||||
onTakePhoto: () -> Unit,
|
||||
onCancelEdit: () -> Unit,
|
||||
|
|
@ -27,25 +43,83 @@ fun CalorieAiApp(
|
|||
onAnalyze: (MealEntry) -> Unit,
|
||||
onEdit: (MealEntry) -> Unit,
|
||||
onMoveToTrash: (Set<String>) -> Unit,
|
||||
onClearTrash: () -> Unit,
|
||||
onRestore: (String) -> Unit,
|
||||
onSettingsChange: (AppSettings) -> Unit,
|
||||
onSearchModels: suspend (String) -> List<ModelOption>,
|
||||
onGeneratePlan: suspend () -> String
|
||||
onSettingsChange: (ServerSettings) -> Unit,
|
||||
onGeneratePlan: () -> Unit,
|
||||
onTunePlan: (String, String) -> Unit,
|
||||
onSelectPlan: (String) -> Unit,
|
||||
onDeletePlan: (String) -> Unit,
|
||||
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 = {
|
||||
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) {
|
||||
Box(Modifier.padding(end = 16.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onSync) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Sync")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
NavigationBar { Screen.entries.forEach { item -> NavigationBarItem(selected = screen == item, onClick = { screen = item }, label = { Text(item.label) }, icon = {}) } }
|
||||
NavigationBar {
|
||||
Screen.entries.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = screen == item,
|
||||
onClick = { screen = item },
|
||||
label = { Text(item.label, style = MaterialTheme.typography.labelSmall) },
|
||||
icon = { Icon(item.icon, contentDescription = item.label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(Modifier.padding(padding).fillMaxSize()) {
|
||||
when (screen) {
|
||||
Screen.Dashboard -> DashboardScreen(entries, onLog = { screen = Screen.Log }, onSettings = { screen = Screen.Settings })
|
||||
Screen.Log -> LogMealScreen(editing, selectedImageName, status, busy, onPickImage, onTakePhoto, onAnalyze, onSaveManualEdit, onCancelEdit)
|
||||
Screen.Diary -> DiaryScreen(entries, onEdit = { onEdit(it); screen = Screen.Log }, onMoveToTrash = onMoveToTrash)
|
||||
Screen.Trash -> TrashScreen(trash, onRestore)
|
||||
Screen.Settings -> SettingsScreen(settings, onSettingsChange, onSearchModels, onGeneratePlan)
|
||||
Screen.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,
|
||||
onEdit = { onEdit(it); screen = Screen.Log },
|
||||
onMoveToTrash = onMoveToTrash,
|
||||
onRestore = onRestore,
|
||||
onClearTrash = onClearTrash
|
||||
)
|
||||
Screen.Plans -> PlansScreen(appState.settings, appState.plans, planStatus, planBusy, onGeneratePlan, onTunePlan, onSelectPlan, onDeletePlan)
|
||||
Screen.Settings -> SettingsScreen(appState.settings, serverUrl, healthConnectStatus, onSettingsChange, onHealthConnectSync, onDisconnect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -56,5 +130,3 @@ fun CalorieAiApp(
|
|||
fun Page(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), content = content)
|
||||
}
|
||||
|
||||
private fun blankMeal() = MealEntry("", "", "", "Breakfast", "", "", false, "", "", NutritionEstimate())
|
||||
|
|
|
|||
|
|
@ -1,17 +1,24 @@
|
|||
package com.danvics.calorieai.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.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) {
|
||||
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
|
@ -20,20 +27,118 @@ fun SectionCard(title: String, modifier: Modifier = Modifier, content: @Composab
|
|||
@Composable
|
||||
fun StatTile(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(value, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black)
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Field(label: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, singleLine: Boolean = true) {
|
||||
fun Field(label: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, singleLine: Boolean = true, placeholder: String = "") {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
placeholder = if (placeholder.isNotEmpty()) ({ Text(placeholder, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) }) else null,
|
||||
singleLine = singleLine,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DropdownField(label: String, options: List<String>, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable, true)
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(text = { Text(option) }, onClick = { onValueChange(option); expanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MacroBar(label: String, grams: Int, totalGrams: Int, color: Color) {
|
||||
val pct = if (totalGrams > 0) grams.toFloat() / totalGrams else 0f
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
||||
Text("${grams}g · ${(pct * 100).toInt()}%", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { pct },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp),
|
||||
color = color,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun 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)
|
||||
val barColor = when {
|
||||
fraction >= 1f -> MaterialTheme.colorScheme.error
|
||||
fraction >= 0.85f -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("$consumed kcal consumed", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
|
||||
Text("goal: $target kcal", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { fraction },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp),
|
||||
color = barColor,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
Text(
|
||||
if (fraction >= 1f) "Daily goal reached" else "${target - consumed} kcal remaining",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (fraction >= 1f) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
96
app/src/main/java/com/danvics/calorieai/ui/ConnectScreen.kt
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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
|
||||
fun ConnectScreen(error: String, connecting: Boolean, onConnect: (String, String, String) -> Unit) {
|
||||
var serverUrl by remember { mutableStateOf("") }
|
||||
var username by remember { mutableStateOf("admin") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Calorie AI", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black)
|
||||
Text(
|
||||
"Connect to your self-hosted server",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("http://192.168.1.10:3000") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Username") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) })
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Password") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
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(
|
||||
enabled = !connecting && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
||||
onClick = { onConnect(serverUrl.trim(), username.trim(), password) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(if (connecting) "Connecting..." else "Connect") }
|
||||
if (error.isNotBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,29 +6,97 @@ 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>, onLog: () -> Unit, onSettings: () -> Unit) = Page {
|
||||
fun DashboardScreen(entries: List<MealEntry>, activities: List<ActivityRecord>, settings: ServerSettings, onLog: () -> Unit) = Page {
|
||||
val today = MealStats.totalsForDate(entries, LocalDate.now().toString())
|
||||
Text("Calorie AI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Black)
|
||||
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() ?: calculateCalorieTarget(settings)?.goalCalories ?: 0
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Today", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.75f), fontWeight = FontWeight.Bold)
|
||||
Text("${today.calories} kcal", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onPrimary)
|
||||
Text("P ${today.protein}g · C ${today.carbs}g · F ${today.fat}g", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.85f))
|
||||
Text("Fruit ${"%.1f".format(today.fruit)} · Veg ${"%.1f".format(today.vegetables)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f))
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
StatTile("Today", "${today.calories} kcal", Modifier.weight(1f))
|
||||
StatTile("Meals", today.meals.toString(), Modifier.weight(1f))
|
||||
StatTile("Meals today", today.meals.toString(), Modifier.weight(1f))
|
||||
StatTile("7-day avg", "$avgCalories kcal", Modifier.weight(1f))
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
StatTile("Protein", "${today.protein}g", Modifier.weight(1f))
|
||||
StatTile("Produce", "%.1f".format(today.fruit + today.vegetables), Modifier.weight(1f))
|
||||
StatTile("Activity", "$activityCalories kcal", Modifier.weight(1f))
|
||||
StatTile("Steps", activitySteps.toString(), Modifier.weight(1f))
|
||||
}
|
||||
SectionCard("Quick actions") {
|
||||
Button(onClick = onLog, modifier = Modifier.fillMaxWidth()) { Text("Log a meal") }
|
||||
OutlinedButton(onClick = onSettings, modifier = Modifier.fillMaxWidth()) { Text("Settings") }
|
||||
|
||||
if (calorieTarget > 0) {
|
||||
SectionCard("Daily calorie goal") {
|
||||
CalorieGoalBar(today.calories, calorieTarget)
|
||||
}
|
||||
}
|
||||
SectionCard("Last 7 days") {
|
||||
MealStats.lastSevenDays(entries).forEach { day ->
|
||||
Text("${day.label}: ${day.calories} kcal, produce ${"%.1f".format(day.fruit + day.vegetables)}")
|
||||
|
||||
if (macroTotal > 0) {
|
||||
SectionCard("Macros") {
|
||||
MacroBar("Protein", today.protein, macroTotal, MaterialTheme.colorScheme.primary)
|
||||
MacroBar("Carbs", today.carbs, macroTotal, MaterialTheme.colorScheme.tertiary)
|
||||
MacroBar("Fat", today.fat, macroTotal, MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
val todayEntries = entries.filter { it.date == LocalDate.now().toString() }.sortedBy { it.time }
|
||||
if (todayEntries.isNotEmpty()) {
|
||||
SectionCard("Today's meals") {
|
||||
todayEntries.forEach { meal ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(meal.estimate.mealName, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
|
||||
Text("${meal.estimate.calories} kcal", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button(onClick = onLog, modifier = Modifier.fillMaxWidth()) { Text("Log your first meal today") }
|
||||
}
|
||||
|
||||
if (week.any { it.calories > 0 }) {
|
||||
SectionCard("Last 7 days") {
|
||||
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)
|
||||
Text(if (day.calories > 0) "${day.calories} kcal" else "—", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,51 +3,154 @@ package com.danvics.calorieai.ui
|
|||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.danvics.calorieai.data.MealEntry
|
||||
|
||||
@Composable
|
||||
fun DiaryScreen(entries: List<MealEntry>, onEdit: (MealEntry) -> Unit, onMoveToTrash: (Set<String>) -> Unit) = Page {
|
||||
fun DiaryScreen(
|
||||
entries: List<MealEntry>,
|
||||
trash: List<MealEntry>,
|
||||
onEdit: (MealEntry) -> Unit,
|
||||
onMoveToTrash: (Set<String>) -> Unit,
|
||||
onRestore: (String) -> Unit,
|
||||
onClearTrash: () -> Unit
|
||||
) {
|
||||
var activeTab by remember { mutableStateOf(0) }
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TabRow(selectedTabIndex = activeTab) {
|
||||
Tab(selected = activeTab == 0, onClick = { activeTab = 0 }, text = { Text("Meals") })
|
||||
Tab(
|
||||
selected = activeTab == 1,
|
||||
onClick = { activeTab = 1 },
|
||||
text = { Text(if (trash.isEmpty()) "Trash" else "Trash (${trash.size})") }
|
||||
)
|
||||
}
|
||||
when (activeTab) {
|
||||
0 -> EntriesContent(entries, onEdit, onMoveToTrash)
|
||||
1 -> TrashContent(trash, onRestore, onClearTrash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntriesContent(
|
||||
entries: List<MealEntry>,
|
||||
onEdit: (MealEntry) -> Unit,
|
||||
onMoveToTrash: (Set<String>) -> Unit
|
||||
) = Page {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var selected by remember { mutableStateOf(setOf<String>()) }
|
||||
val rows = entries.filter { query.isBlank() || listOf(it.description, it.estimate.mealName, it.estimate.foodGroups).any { value -> value.contains(query, true) } }
|
||||
Text("Diary", style = MaterialTheme.typography.headlineMedium)
|
||||
Field("Search meals", query, { query = it })
|
||||
Button(enabled = selected.isNotEmpty(), onClick = { onMoveToTrash(selected); selected = emptySet() }, modifier = Modifier.fillMaxWidth()) { Text("Move selected to trash") }
|
||||
val rows = entries.filter {
|
||||
query.isBlank() || listOf(it.description, it.estimate.mealName, it.estimate.foodGroups, it.mealType)
|
||||
.any { value -> value.contains(query, ignoreCase = true) }
|
||||
}
|
||||
|
||||
Field("Search meals", query, { query = it }, placeholder = "Search by name, type, or food groups...")
|
||||
|
||||
if (selected.isNotEmpty()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${selected.size} selected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(onClick = { selected = emptySet() }) { Text("Deselect") }
|
||||
Button(
|
||||
onClick = { onMoveToTrash(selected); selected = emptySet() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
) { Text("Trash") }
|
||||
}
|
||||
}
|
||||
|
||||
rows.groupBy { it.date }.toSortedMap(reverseOrder()).forEach { (date, dayRows) ->
|
||||
SectionCard(date) {
|
||||
OutlinedButton(onClick = { onMoveToTrash(dayRows.map { it.id }.toSet()) }, modifier = Modifier.fillMaxWidth()) { Text("Move day to trash") }
|
||||
dayRows.forEach { meal ->
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Checkbox(checked = meal.id in selected, onCheckedChange = { checked -> selected = if (checked) selected + meal.id else selected - meal.id })
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(meal.estimate.mealName, fontWeight = FontWeight.Bold)
|
||||
Text("${meal.time} · ${meal.mealType} · ${meal.estimate.calories} kcal")
|
||||
}
|
||||
}
|
||||
Text("P ${meal.estimate.proteinGrams}g · C ${meal.estimate.carbsGrams}g · F ${meal.estimate.fatGrams}g")
|
||||
if (meal.description.isNotBlank()) Text(meal.description)
|
||||
OutlinedButton(onClick = { onEdit(meal) }) { Text("Edit") }
|
||||
dayRows.sortedByDescending { it.time }.forEach { meal ->
|
||||
HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = meal.id in selected,
|
||||
onCheckedChange = { checked -> selected = if (checked) selected + meal.id else selected - meal.id }
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(meal.estimate.mealName, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
"${meal.time} · ${meal.mealType} · ${meal.estimate.calories} kcal",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"P ${meal.estimate.proteinGrams}g · C ${meal.estimate.carbsGrams}g · F ${meal.estimate.fatGrams}g",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { onEdit(meal) }) { Text("Edit") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rows.isEmpty()) Text("No meals match the current filters.")
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
Text(
|
||||
if (query.isBlank()) "No meals logged yet." else "No meals match your search.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrashScreen(trash: List<MealEntry>, onRestore: (String) -> Unit) = Page {
|
||||
Text("Trash", style = MaterialTheme.typography.headlineMedium)
|
||||
if (trash.isEmpty()) Text("Trash is empty.")
|
||||
private fun TrashContent(
|
||||
trash: List<MealEntry>,
|
||||
onRestore: (String) -> Unit,
|
||||
onClearTrash: () -> Unit
|
||||
) = Page {
|
||||
var showConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
if (showConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showConfirm = false },
|
||||
title = { Text("Clear trash?") },
|
||||
text = { Text("This will permanently delete all ${trash.size} items. This cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onClearTrash(); showConfirm = false },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) { Text("Clear all") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showConfirm = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (trash.isEmpty()) {
|
||||
Text("Trash is empty.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
return@Page
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { showConfirm = true },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text("Clear all trash (${trash.size})") }
|
||||
|
||||
trash.forEach { meal ->
|
||||
SectionCard(meal.estimate.mealName) {
|
||||
Text("${meal.date} ${meal.time} · ${meal.estimate.calories} kcal")
|
||||
OutlinedButton(onClick = { onRestore(meal.id) }) { Text("Restore") }
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.padding(16.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(meal.estimate.mealName, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
"${meal.date} · ${meal.mealType} · ${meal.estimate.calories} kcal",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = { onRestore(meal.id) }) { Text("Restore") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +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,
|
||||
|
|
@ -23,50 +43,180 @@ 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) }
|
||||
|
||||
Text(if (editing == null) "Log meal" else "Edit meal", style = MaterialTheme.typography.headlineMedium)
|
||||
SectionCard("Meal details") {
|
||||
Field("Date", date, { date = it })
|
||||
Field("Time", time, { time = it })
|
||||
Field("Meal type", mealType, { mealType = it })
|
||||
Field("Description", description, { description = it }, singleLine = false)
|
||||
Field("Portion or measure", measure, { measure = it })
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(onClick = onTakePhoto, modifier = Modifier.weight(1f)) { Text("Take photo") }
|
||||
OutlinedButton(onClick = onPickImage, modifier = Modifier.weight(1f)) { Text("Choose image") }
|
||||
if (showDatePicker) {
|
||||
val cal = Calendar.getInstance()
|
||||
runCatching {
|
||||
val d = LocalDate.parse(date)
|
||||
cal.set(d.year, d.monthValue - 1, d.dayOfMonth)
|
||||
}
|
||||
if (selectedImageName.isNotBlank()) Text("Selected: $selectedImageName")
|
||||
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)) {
|
||||
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")
|
||||
|
||||
if (selectedImageBitmap != null) {
|
||||
Image(
|
||||
bitmap = selectedImageBitmap.asImageBitmap(),
|
||||
contentDescription = "Meal photo preview",
|
||||
modifier = Modifier.fillMaxWidth().height(180.dp).clip(MaterialTheme.shapes.medium),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
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(draft(editing, date, time, mealType, description, measure, estimate)) },
|
||||
onClick = { onAnalyze(buildDraft(editing, date, time, mealType, description, measure, estimate)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(if (busy) "Analyzing..." else if (editing == null) "Analyze and save" else "Analyze again") }
|
||||
if (status.isNotBlank()) Text(status, color = MaterialTheme.colorScheme.primary)
|
||||
|
||||
if (status.isNotBlank()) {
|
||||
Text(
|
||||
status,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (status.startsWith("Analysis failed") || status.startsWith("No AI") || status.startsWith("Save failed"))
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (editing != null) {
|
||||
SectionCard("Editable estimate") {
|
||||
SectionCard("Edit estimate") {
|
||||
Field("Meal name", estimate.mealName, { estimate = estimate.copy(mealName = it) })
|
||||
Field("Calories", estimate.calories.toString(), { estimate = estimate.copy(calories = it.toIntOrNull() ?: 0) })
|
||||
Field("Protein grams", estimate.proteinGrams.toString(), { estimate = estimate.copy(proteinGrams = it.toIntOrNull() ?: 0) })
|
||||
Field("Carbs grams", estimate.carbsGrams.toString(), { estimate = estimate.copy(carbsGrams = it.toIntOrNull() ?: 0) })
|
||||
Field("Fat grams", estimate.fatGrams.toString(), { estimate = estimate.copy(fatGrams = it.toIntOrNull() ?: 0) })
|
||||
Field("Fruit servings", estimate.fruitServings.toString(), { estimate = estimate.copy(fruitServings = it.toDoubleOrNull() ?: 0.0) })
|
||||
Field("Vegetable servings", estimate.vegetableServings.toString(), { estimate = estimate.copy(vegetableServings = it.toDoubleOrNull() ?: 0.0) })
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Field("Calories", estimate.calories.toString(), { estimate = estimate.copy(calories = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f))
|
||||
Field("Protein g", estimate.proteinGrams.toString(), { estimate = estimate.copy(proteinGrams = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Field("Carbs g", estimate.carbsGrams.toString(), { estimate = estimate.copy(carbsGrams = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f))
|
||||
Field("Fat g", estimate.fatGrams.toString(), { estimate = estimate.copy(fatGrams = it.toIntOrNull() ?: 0) }, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Field("Fruit srv", estimate.fruitServings.toString(), { estimate = estimate.copy(fruitServings = it.toDoubleOrNull() ?: 0.0) }, modifier = Modifier.weight(1f))
|
||||
Field("Veg srv", estimate.vegetableServings.toString(), { estimate = estimate.copy(vegetableServings = it.toDoubleOrNull() ?: 0.0) }, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Field("Food groups", estimate.foodGroups, { estimate = estimate.copy(foodGroups = it) })
|
||||
Field("Notes", estimate.notes, { estimate = estimate.copy(notes = it) }, singleLine = false)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { onSaveManualEdit(draft(editing, date, time, mealType, description, measure, estimate)) }) { Text("Save edits") }
|
||||
OutlinedButton(onClick = onCancelEdit) { Text("Cancel") }
|
||||
Button(
|
||||
onClick = { onSaveManualEdit(buildDraft(editing, date, time, mealType, description, measure, estimate)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("Save edits") }
|
||||
OutlinedButton(onClick = onCancelEdit, modifier = Modifier.weight(1f)) { Text("Cancel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun draft(editing: MealEntry?, date: String, time: String, mealType: String, description: String, measure: String, estimate: NutritionEstimate) =
|
||||
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
|
||||
)
|
||||
|
|
|
|||
162
app/src/main/java/com/danvics/calorieai/ui/PlansScreen.kt
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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(
|
||||
settings: ServerSettings,
|
||||
plansState: PlansState,
|
||||
planStatus: String,
|
||||
planBusy: Boolean,
|
||||
onGenerate: () -> Unit,
|
||||
onTune: (planId: String, note: String) -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
onDelete: (String) -> Unit
|
||||
) = Page {
|
||||
val selectedPlan = plansState.plans.find { it.id == plansState.selectedPlanId } ?: plansState.plans.firstOrNull()
|
||||
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 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 && calorieTarget != null,
|
||||
onClick = onGenerate,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(if (planBusy) "Generating..." else "Generate new plan") }
|
||||
if (calorieTarget == null) {
|
||||
Text(
|
||||
"Fill in sex, age, height, and weight in Settings first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
if (planStatus.isNotBlank()) {
|
||||
Text(planStatus, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedPlan != null) {
|
||||
SectionCard(selectedPlan.title) {
|
||||
Text(
|
||||
"Version ${selectedPlan.version} · ${selectedPlan.createdAt.take(10)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
Text(
|
||||
selectedPlan.content,
|
||||
style = MaterialTheme.typography.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"
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
enabled = !planBusy && tuneNote.isNotBlank(),
|
||||
onClick = { onTune(selectedPlan.id, tuneNote); tuneNote = "" },
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text(if (planBusy) "Updating..." else "Update plan") }
|
||||
OutlinedButton(
|
||||
onClick = { showDeleteConfirm = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) { Text("Delete") }
|
||||
}
|
||||
}
|
||||
} else if (!planBusy) {
|
||||
SectionCard("No plan yet") {
|
||||
Text(
|
||||
"Generate your first plan to get started.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (plansState.plans.size > 1) {
|
||||
SectionCard("Plan history") {
|
||||
plansState.plans.forEach { plan ->
|
||||
val isSelected = plan.id == plansState.selectedPlanId
|
||||
Card(
|
||||
onClick = { onSelect(plan.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surface
|
||||
)
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,49 +5,92 @@ import androidx.compose.material3.*
|
|||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.danvics.calorieai.data.AppSettings
|
||||
import com.danvics.calorieai.data.ModelOption
|
||||
import kotlinx.coroutines.launch
|
||||
import com.danvics.calorieai.data.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: AppSettings,
|
||||
onSettingsChange: (AppSettings) -> Unit,
|
||||
onSearchModels: suspend (String) -> List<ModelOption>,
|
||||
onGeneratePlan: suspend () -> String
|
||||
settings: ServerSettings,
|
||||
serverUrl: String,
|
||||
healthConnectStatus: String,
|
||||
onSettingsChange: (ServerSettings) -> Unit,
|
||||
onHealthConnectSync: () -> Unit,
|
||||
onDisconnect: () -> Unit
|
||||
) = Page {
|
||||
val scope = rememberCoroutineScope()
|
||||
var draft by remember(settings) { mutableStateOf(settings) }
|
||||
var modelQuery by remember { mutableStateOf("") }
|
||||
var discovered by remember { mutableStateOf(emptyList<ModelOption>()) }
|
||||
var plan by remember { mutableStateOf("") }
|
||||
var status by remember { mutableStateOf("") }
|
||||
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
||||
SectionCard("AI connection") {
|
||||
Field("API base URL", draft.apiBaseUrl, { draft = draft.copy(apiBaseUrl = it) })
|
||||
Field("API key", draft.apiKey, { draft = draft.copy(apiKey = it) })
|
||||
Field("Image model", draft.visionModel, { draft = draft.copy(visionModel = it) })
|
||||
Field("Nutrition and advice model", draft.nutritionModel, { draft = draft.copy(nutritionModel = it) })
|
||||
Button(onClick = { onSettingsChange(draft); status = "Settings saved." }, modifier = Modifier.fillMaxWidth()) { Text("Save settings") }
|
||||
if (status.isNotBlank()) Text(status)
|
||||
}
|
||||
SectionCard("Model search") {
|
||||
Field("Search current provider models", modelQuery, { modelQuery = it })
|
||||
Button(onClick = { scope.launch { discovered = onSearchModels(modelQuery) } }, modifier = Modifier.fillMaxWidth()) { Text("Search") }
|
||||
Column(Modifier.heightIn(max = 260.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
discovered.forEach { model ->
|
||||
OutlinedButton(onClick = { draft = draft.copy(models = (draft.models + model).distinctBy { it.id }, nutritionModel = model.id) }, modifier = Modifier.fillMaxWidth()) { Text("+ ${model.name}") }
|
||||
}
|
||||
LaunchedEffect(status) {
|
||||
if (status.isNotBlank()) {
|
||||
delay(3000)
|
||||
status = ""
|
||||
}
|
||||
}
|
||||
SectionCard("Weight-loss plan") {
|
||||
Field("Height cm", draft.heightCm, { draft = draft.copy(heightCm = it) })
|
||||
Field("Current weight kg", draft.weightKg, { draft = draft.copy(weightKg = it) })
|
||||
Field("Target weight kg", draft.targetWeightKg, { draft = draft.copy(targetWeightKg = it) })
|
||||
Field("Activity", draft.activityLevel, { draft = draft.copy(activityLevel = it) })
|
||||
Field("Pace", draft.pace, { draft = draft.copy(pace = it) })
|
||||
Button(onClick = { onSettingsChange(draft); scope.launch { plan = onGeneratePlan() } }, modifier = Modifier.fillMaxWidth()) { Text("Generate plan") }
|
||||
if (plan.isNotBlank()) Text(plan)
|
||||
|
||||
SectionCard("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")
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Field("Target weight (kg)", draft.targetWeightKg, { draft = draft.copy(targetWeightKg = it) }, modifier = Modifier.weight(1f), placeholder = "70")
|
||||
Field("Daily kcal goal", draft.calorieTarget, { draft = draft.copy(calorieTarget = it) }, modifier = Modifier.weight(1f), placeholder = "2000")
|
||||
}
|
||||
DropdownField("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)
|
||||
OutlinedButton(
|
||||
onClick = onDisconnect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) { Text("Disconnect") }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,36 @@
|
|||
package com.danvics.calorieai.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val Colors = lightColorScheme(
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = Color(0xFF2563EB),
|
||||
secondary = Color(0xFF10B981),
|
||||
tertiary = Color(0xFFF59E0B),
|
||||
background = Color(0xFFF8FAFC),
|
||||
surface = Color.White
|
||||
surface = Color(0xFFFFFFFF),
|
||||
surfaceVariant = Color(0xFFF1F5F9),
|
||||
onSurfaceVariant = Color(0xFF64748B),
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFF60A5FA),
|
||||
secondary = Color(0xFF34D399),
|
||||
tertiary = Color(0xFFFBBF24),
|
||||
background = Color(0xFF0F172A),
|
||||
surface = Color(0xFF1E293B),
|
||||
surfaceVariant = Color(0xFF334155),
|
||||
onSurfaceVariant = Color(0xFF94A3B8),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CalorieTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = Colors, content = content)
|
||||
MaterialTheme(
|
||||
colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
|
|||
5
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2563EB" />
|
||||
</shape>
|
||||
20
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Outer flame body (white) -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,22 C54,22 36,40 36,57 C36,69 44,78 54,78 C64,78 72,69 72,57 C72,40 54,22 54,22 Z" />
|
||||
<!-- Inner cutout — same color as background creates hollow flame -->
|
||||
<path
|
||||
android:fillColor="#2563EB"
|
||||
android:pathData="M54,44 C54,44 46,54 46,62 C46,68 50,72 54,72 C58,72 62,68 62,62 C62,54 54,44 54,44 Z" />
|
||||
<!-- Small inner highlight dot -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:alpha="0.4"
|
||||
android:pathData="M54,52 C52,56 52,60 54,62 C56,60 56,56 54,52 Z" />
|
||||
</vector>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
4
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#2563EB</color>
|
||||
</resources>
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:statusBarColor">#F8F4EC</item>
|
||||
<item name="android:navigationBarColor">#F8F4EC</item>
|
||||
<item name="android:colorAccent">#2F7D59</item>
|
||||
<item name="android:windowBackground">@android:color/white</item>
|
||||
<item name="android:statusBarColor">@color/ic_launcher_background</item>
|
||||
<item name="android:navigationBarColor">@android:color/white</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:fontFamily">sans-serif</item>
|
||||
<item name="android:colorAccent">#2563EB</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
4
app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="camera_photos" path="." />
|
||||
</paths>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
/build/*
|
||||
!/build/.npmkeep
|
||||
64
web/android/app/build.gradle
Normal 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")
|
||||
}
|
||||
19
web/android/app/capacitor.build.gradle
Normal 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
|
|
@ -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
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
58
web/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
BIN
web/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
web/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
web/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
web/android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
web/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
web/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
web/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
web/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
web/android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
web/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -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>
|
||||
170
web/android/app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
BIN
web/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
web/android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
BIN
web/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
BIN
web/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
web/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
BIN
web/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
web/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
BIN
web/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
web/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
BIN
web/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
web/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
web/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
22
web/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
5
web/android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
3
web/android/capacitor.settings.gradle
Normal 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')
|
||||
22
web/android/gradle.properties
Normal 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
|
||||
BIN
web/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
web/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
||||
5
web/android/settings.gradle
Normal 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'
|
||||
16
web/android/variables.gradle
Normal 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
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
208
web/server.js
|
|
@ -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');
|
||||
|
|
@ -12,10 +13,13 @@ const dataDir = process.env.CALORIE_AI_DATA_DIR || path.join(__dirname, 'data');
|
|||
const authFile = path.join(dataDir, 'auth.json');
|
||||
const modelsFile = path.join(dataDir, 'models.json');
|
||||
const plansFile = path.join(dataDir, 'plans.json');
|
||||
const settingsFile = path.join(dataDir, 'settings.json');
|
||||
const entriesFile = path.join(dataDir, 'entries.json');
|
||||
const sessionCookieName = 'calorie_ai_session';
|
||||
const sessionSeconds = 60 * 60 * 24 * 7;
|
||||
let auth;
|
||||
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 || '',
|
||||
|
|
@ -48,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 || '';
|
||||
|
|
@ -84,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,
|
||||
|
|
@ -94,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,
|
||||
|
|
@ -208,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}`),
|
||||
|
|
@ -251,28 +232,93 @@ 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));
|
||||
send(res, 200, JSON.stringify(await store.deletePlan(payload.id)));
|
||||
}
|
||||
|
||||
async function getSettings(req, res) {
|
||||
send(res, 200, JSON.stringify(await store.getSettings()));
|
||||
}
|
||||
|
||||
async function updateSettings(req, res) {
|
||||
try {
|
||||
const payload = JSON.parse(await readBody(req) || '{}');
|
||||
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];
|
||||
await store.saveSettings(updated);
|
||||
send(res, 200, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
send(res, 400, JSON.stringify({ error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function getEntriesRoute(req, res) {
|
||||
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' }));
|
||||
send(res, 200, JSON.stringify(await store.upsertMealEntry(payload)));
|
||||
} catch (error) {
|
||||
send(res, 400, JSON.stringify({ error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEntriesRoute(req, res) {
|
||||
try {
|
||||
const payload = JSON.parse(await readBody(req) || '{}');
|
||||
const ids = new Set(Array.isArray(payload.ids) ? payload.ids : [payload.id].filter(Boolean));
|
||||
send(res, 200, JSON.stringify(await store.moveEntriesToTrash([...ids])));
|
||||
} catch (error) {
|
||||
send(res, 400, JSON.stringify({ error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreEntryRoute(req, res) {
|
||||
try {
|
||||
const payload = JSON.parse(await readBody(req) || '{}');
|
||||
const data = 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 }));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearTrashRoute(req, res) {
|
||||
try {
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeModels(models) {
|
||||
|
|
@ -290,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);
|
||||
|
|
@ -451,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 }));
|
||||
|
|
@ -464,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 }));
|
||||
|
|
@ -494,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 }));
|
||||
|
|
@ -505,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');
|
||||
|
|
@ -525,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');
|
||||
|
|
@ -543,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 }));
|
||||
|
|
@ -555,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 }));
|
||||
|
|
@ -566,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');
|
||||
|
|
@ -582,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 }));
|
||||
|
|
@ -595,38 +641,13 @@ function logout(req, res) {
|
|||
});
|
||||
}
|
||||
|
||||
async function handleRequest(req, res) {
|
||||
const url = req.url.split('?')[0];
|
||||
const authenticated = isAuthenticated(req);
|
||||
|
||||
if (!authenticated && !publicRequest(req)) {
|
||||
if (url.startsWith('/api/')) return send(res, 401, JSON.stringify({ error: 'Authentication required' }));
|
||||
return redirect(res, '/login');
|
||||
}
|
||||
|
||||
if (authenticated && (req.method === 'GET' || req.method === 'HEAD') && url === '/login') return redirect(res, '/');
|
||||
if (req.method === 'POST' && url === '/api/login') return login(req, res);
|
||||
if (req.method === 'POST' && url === '/api/logout') return logout(req, res);
|
||||
if (req.method === 'POST' && url === '/api/account') return updateAccount(req, res);
|
||||
if (req.method === 'POST' && url === '/api/account/send-verification') return sendVerification(req, res);
|
||||
if (req.method === 'POST' && url === '/api/account/verify') return verifyAccount(req, res);
|
||||
if (req.method === 'POST' && url === '/api/password/change') return changePassword(req, res);
|
||||
if (req.method === 'POST' && url === '/api/password/reset') return resetPassword(req, res);
|
||||
if (req.method === 'POST' && url === '/api/password/request-reset') return requestPasswordReset(req, res);
|
||||
if (req.method === 'POST' && url === '/api/password/confirm-reset') return confirmPasswordReset(req, res);
|
||||
if (req.method === 'GET' && url === '/api/session') return send(res, 200, JSON.stringify({ user: auth.user, email: auth.email || '', emailVerified: auth.emailVerified === true }));
|
||||
if (req.method === 'POST' && url === '/api/models') return proxyModels(req, res);
|
||||
if (req.method === 'POST' && url === '/api/models/search') return searchModels(req, res);
|
||||
if (req.method === 'POST' && url === '/api/models/add') return addModel(req, res);
|
||||
if (req.method === 'POST' && url === '/api/models/remove') return removeModel(req, res);
|
||||
if (req.method === 'POST' && req.url === '/api/chat') return proxyChat(req, res);
|
||||
if (req.method === 'GET' || req.method === 'HEAD') return serveStatic(req, res);
|
||||
send(res, 405, 'Method not allowed', 'text/plain; charset=utf-8');
|
||||
}
|
||||
|
||||
async function start() {
|
||||
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) => {
|
||||
|
|
@ -658,6 +679,15 @@ async function start() {
|
|||
app.post('/api/plans', wrap(createPlan));
|
||||
app.post('/api/plans/select', wrap(selectPlanRoute));
|
||||
app.post('/api/plans/delete', wrap(deletePlanRoute));
|
||||
app.get('/api/settings', wrap(getSettings));
|
||||
app.post('/api/settings', wrap(updateSettings));
|
||||
app.get('/api/entries', wrap(getEntriesRoute));
|
||||
app.post('/api/entries', wrap(upsertEntryRoute));
|
||||
app.post('/api/entries/delete', wrap(deleteEntriesRoute));
|
||||
app.post('/api/entries/restore', wrap(restoreEntryRoute));
|
||||
app.post('/api/entries/clear-trash', wrap(clearTrashRoute));
|
||||
app.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
|
|
@ -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 };
|
||||
97
web/src/AnalyticsPage.svelte
Normal 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>
|
||||
|
|
@ -1,31 +1,17 @@
|
|||
<script>
|
||||
import { onMount, tick } from '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 entriesKey = 'calorie-ai-web-entries';
|
||||
const trashKey = 'calorie-ai-web-trash';
|
||||
const settingsKey = 'calorie-ai-web-settings';
|
||||
const pages = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '⌂', group: 'Tracker' },
|
||||
{ id: 'log', label: 'Log meal', icon: '+', group: 'Tracker' },
|
||||
{ id: 'analytics', label: 'Analytics', icon: '↗', group: 'Tracker' },
|
||||
{ id: 'diary', label: 'Diary', icon: '☰', group: 'Tracker' },
|
||||
{ id: 'trash', label: 'Trash', icon: '×', group: 'Tracker' },
|
||||
{ id: 'plans', label: 'Plans', icon: '◇', group: 'Tracker' },
|
||||
{ id: 'settings', label: 'Settings', icon: '⚙', group: 'Admin' },
|
||||
];
|
||||
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 HealthConnect = registerPlugin('HealthConnect');
|
||||
|
||||
let loginMode = location.pathname === '/login' || location.pathname === '/reset' || location.pathname === '/verify';
|
||||
let username = 'admin';
|
||||
|
|
@ -46,13 +32,15 @@
|
|||
let sidebarCollapsed = false;
|
||||
|
||||
let entries = [];
|
||||
let activities = [];
|
||||
let models = fallbackModels;
|
||||
let discoveredModels = [];
|
||||
let modelSearch = '';
|
||||
let modelStatus = '';
|
||||
let modelError = false;
|
||||
let modelSearching = false;
|
||||
let settings = { visionModel: fallbackModels[0].id, taskModel: fallbackModels[0].id, heightCm: '', weightKg: '', targetWeightKg: '', activityLevel: 'Moderate', pace: 'Steady' };
|
||||
let modelActionId = '';
|
||||
let settings = { ...defaultSettings };
|
||||
let currentPassword = '';
|
||||
let newPassword = '';
|
||||
let confirmPassword = '';
|
||||
|
|
@ -60,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 = '';
|
||||
|
|
@ -89,16 +80,20 @@
|
|||
let selectedEntryIds = [];
|
||||
let trash = [];
|
||||
|
||||
let macroCanvas;
|
||||
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)
|
||||
|
|
@ -111,26 +106,17 @@
|
|||
$: diaryDays = Array.from(new Set(diaryRows.map(entry => entry.date))).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
onMount(async () => {
|
||||
entries = readJson(entriesKey, []);
|
||||
trash = readJson(trashKey, []);
|
||||
settings = { ...settings, ...readJson(settingsKey, settings) };
|
||||
await loadSession();
|
||||
if (location.pathname === '/verify' && resetToken) await verifyAccountToken();
|
||||
if (loginMode) return;
|
||||
|
||||
await loadSession();
|
||||
page = localStorage.getItem('calorie_ai_last_tab') || 'dashboard';
|
||||
setDefaultMealDateTime();
|
||||
await loadSettingsFromServer();
|
||||
await loadModels();
|
||||
await loadPlans();
|
||||
await drawCharts();
|
||||
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')}`;
|
||||
}
|
||||
|
|
@ -143,14 +129,6 @@
|
|||
return dateKey(new Date());
|
||||
}
|
||||
|
||||
function readJson(key, fallback) {
|
||||
try { return JSON.parse(localStorage.getItem(key)) || fallback; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function saveJson(key, value) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function setDefaultMealDateTime() {
|
||||
const now = new Date();
|
||||
mealDate = dateKey(now);
|
||||
|
|
@ -264,8 +242,80 @@
|
|||
verifyError = !response.ok;
|
||||
}
|
||||
|
||||
async function loadSettingsFromServer() {
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadEntriesFromServer() {
|
||||
try {
|
||||
const response = await fetch('/api/entries');
|
||||
if (response.ok) applyEntriesData(await response.json());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function applyEntriesData(data) {
|
||||
entries = data.entries || [];
|
||||
trash = data.trash || [];
|
||||
}
|
||||
|
||||
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 || '';
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
modelStatus = 'Loading models...';
|
||||
modelStatus = '';
|
||||
modelError = false;
|
||||
try {
|
||||
const response = await fetch('/api/models', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
||||
|
|
@ -273,10 +323,11 @@
|
|||
const data = await response.json();
|
||||
models = data.models?.length ? data.models : fallbackModels;
|
||||
const defaultModel = data.defaultModel || models[0].id;
|
||||
if (!models.some(model => model.id === settings.visionModel)) settings.visionModel = defaultModel;
|
||||
if (!models.some(model => model.id === settings.taskModel)) settings.taskModel = defaultModel;
|
||||
saveSettings();
|
||||
modelStatus = `${models.length} saved model${models.length === 1 ? '' : 's'} loaded.`;
|
||||
let changed = false;
|
||||
if (!models.some(model => model.id === settings.visionModel)) { settings.visionModel = defaultModel; changed = true; }
|
||||
if (!models.some(model => model.id === settings.taskModel)) { settings.taskModel = defaultModel; changed = true; }
|
||||
if (changed) await saveSettings();
|
||||
modelStatus = `${models.length} model${models.length === 1 ? '' : 's'} loaded.`;
|
||||
} catch {
|
||||
models = fallbackModels;
|
||||
modelStatus = 'Could not load saved models. Showing defaults.';
|
||||
|
|
@ -284,15 +335,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
saveJson(settingsKey, settings);
|
||||
}
|
||||
|
||||
function applyPlanConfig(config) {
|
||||
plans = config.plans || [];
|
||||
selectedPlanId = config.selectedPlanId || plans[0]?.id || '';
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
try {
|
||||
const response = await fetch('/api/plans');
|
||||
|
|
@ -314,16 +356,24 @@
|
|||
return { mealName: '', calories: 0, proteinGrams: 0, carbsGrams: 0, fatGrams: 0, fruitServings: 0, vegetableServings: 0, foodGroups: '', notes: '' };
|
||||
}
|
||||
|
||||
function saveSelectedModels() {
|
||||
saveSettings();
|
||||
modelStatus = 'Models saved.';
|
||||
modelError = false;
|
||||
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 || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function selectModel(modelId, target = 'task') {
|
||||
if (target === 'vision') settings.visionModel = modelId;
|
||||
else settings.taskModel = modelId;
|
||||
saveSelectedModels();
|
||||
async function saveSelectedModels() {
|
||||
await saveSettings();
|
||||
modelStatus = 'Models saved.';
|
||||
modelError = false;
|
||||
}
|
||||
|
||||
async function searchModels() {
|
||||
|
|
@ -356,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.text();
|
||||
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;
|
||||
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.text();
|
||||
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;
|
||||
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() {
|
||||
|
|
@ -480,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() {
|
||||
|
|
@ -506,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 || '',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -565,16 +694,21 @@ Image estimate: ${imageEstimate}` },
|
|||
imageName: selectedImageName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
entries = editingEntryId ? entries.map(entry => entry.id === editingEntryId ? { ...entry, ...meal } : entry) : [...entries, meal];
|
||||
saveJson(entriesKey, entries);
|
||||
const response = await fetch('/api/entries', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(meal),
|
||||
});
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'Failed to save meal');
|
||||
applyEntriesData(await response.json());
|
||||
estimateDraft = emptyEstimate();
|
||||
description = '';
|
||||
measure = '';
|
||||
selectedImageDataUrl = '';
|
||||
selectedImageName = '';
|
||||
editingEntryId = '';
|
||||
estimateDraft = emptyEstimate();
|
||||
setDefaultMealDateTime();
|
||||
setMealStatus('Saved. You can edit it from Diary or run analysis again.');
|
||||
setMealStatus(`Saved. ${estimate.mealName} · ${estimate.calories} kcal`);
|
||||
} catch (error) {
|
||||
setMealStatus(error.message, true);
|
||||
} finally {
|
||||
|
|
@ -599,25 +733,78 @@ Image estimate: ${imageEstimate}` },
|
|||
selectPage('log');
|
||||
}
|
||||
|
||||
function saveMealEdits() {
|
||||
async function saveMealEdits() {
|
||||
if (!editingEntryId) return;
|
||||
entries = entries.map(entry => entry.id === editingEntryId ? {
|
||||
...entry,
|
||||
...estimateDraft,
|
||||
const existing = entries.find(e => e.id === editingEntryId);
|
||||
if (!existing) return;
|
||||
const updated = {
|
||||
...existing,
|
||||
...normalizeEstimateDraft(),
|
||||
date: mealDate,
|
||||
time: mealTime,
|
||||
mealType,
|
||||
description,
|
||||
measure,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} : entry);
|
||||
saveJson(entriesKey, entries);
|
||||
editingEntryId = '';
|
||||
estimateDraft = emptyEstimate();
|
||||
description = '';
|
||||
measure = '';
|
||||
setDefaultMealDateTime();
|
||||
setMealStatus('Edits saved.');
|
||||
};
|
||||
try {
|
||||
const response = await fetch('/api/entries', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save edits');
|
||||
applyEntriesData(await response.json());
|
||||
editingEntryId = '';
|
||||
estimateDraft = emptyEstimate();
|
||||
description = '';
|
||||
measure = '';
|
||||
setDefaultMealDateTime();
|
||||
setMealStatus('Edits saved.');
|
||||
} catch (error) {
|
||||
setMealStatus(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
|
|
@ -635,28 +822,35 @@ Image estimate: ${imageEstimate}` },
|
|||
selectedEntryIds = selectedEntryIds.includes(id) ? selectedEntryIds.filter(item => item !== id) : [...selectedEntryIds, id];
|
||||
}
|
||||
|
||||
function moveEntriesToTrash(ids) {
|
||||
const idSet = new Set(ids);
|
||||
const moving = entries.filter(entry => idSet.has(entry.id)).map(entry => ({ ...entry, trashedAt: new Date().toISOString() }));
|
||||
if (!moving.length) return;
|
||||
trash = [...moving, ...trash];
|
||||
entries = entries.filter(entry => !idSet.has(entry.id));
|
||||
selectedEntryIds = selectedEntryIds.filter(id => !idSet.has(id));
|
||||
saveJson(entriesKey, entries);
|
||||
saveJson(trashKey, trash);
|
||||
async function moveEntriesToTrash(ids) {
|
||||
const idList = Array.isArray(ids) ? ids : [ids];
|
||||
if (!idList.length) return;
|
||||
try {
|
||||
const response = await fetch('/api/entries/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ids: idList }),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
applyEntriesData(await response.json());
|
||||
selectedEntryIds = selectedEntryIds.filter(id => !idList.includes(id));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function moveDayToTrash(date) {
|
||||
moveEntriesToTrash(entries.filter(entry => entry.date === date).map(entry => entry.id));
|
||||
}
|
||||
|
||||
function restoreEntry(id) {
|
||||
const found = trash.find(entry => entry.id === id);
|
||||
if (!found) return;
|
||||
entries = [{ ...found, trashedAt: undefined }, ...entries];
|
||||
trash = trash.filter(entry => entry.id !== id);
|
||||
saveJson(entriesKey, entries);
|
||||
saveJson(trashKey, trash);
|
||||
async function restoreEntry(id) {
|
||||
try {
|
||||
const response = await fetch('/api/entries/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
applyEntriesData(await response.json());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function generatePlan() {
|
||||
|
|
@ -664,19 +858,41 @@ 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.';
|
||||
saveSettings();
|
||||
} catch (error) {
|
||||
planStatus = error.message;
|
||||
planError = true;
|
||||
|
|
@ -702,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;
|
||||
|
|
@ -737,77 +953,6 @@ Image estimate: ${imageEstimate}` },
|
|||
if (response.ok) applyPlanConfig(await response.json());
|
||||
}
|
||||
|
||||
async function drawCharts() {
|
||||
await tick();
|
||||
drawMacroChart();
|
||||
drawBars(calorieCanvas, week, 'calories');
|
||||
drawBars(produceCanvas, week, 'produce');
|
||||
}
|
||||
|
||||
function drawMacroChart() {
|
||||
if (!macroCanvas) return;
|
||||
const ctx = macroCanvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, macroCanvas.width, macroCanvas.height);
|
||||
const values = [today.protein, today.carbs, today.fat];
|
||||
const colors = ['#2563eb', '#f59e0b', '#7c3aed'];
|
||||
const labels = [`Protein ${today.protein}g`, `Carbs ${today.carbs}g`, `Fat ${today.fat}g`];
|
||||
const sum = values.reduce((a, b) => a + b, 0) || 1;
|
||||
let start = -Math.PI / 2;
|
||||
values.forEach((value, index) => {
|
||||
const arc = (value / sum) * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 30;
|
||||
ctx.strokeStyle = colors[index];
|
||||
ctx.arc(120, 130, 70, start, start + arc);
|
||||
ctx.stroke();
|
||||
start += arc;
|
||||
});
|
||||
ctx.fillStyle = '#1f2937';
|
||||
ctx.font = '700 20px Inter, sans-serif';
|
||||
ctx.fillText('Today', 240, 88);
|
||||
ctx.font = '15px Inter, sans-serif';
|
||||
labels.forEach((label, index) => {
|
||||
ctx.fillStyle = colors[index];
|
||||
ctx.fillRect(240, 112 + index * 34, 14, 14);
|
||||
ctx.fillStyle = '#1f2937';
|
||||
ctx.fillText(label, 264, 125 + index * 34);
|
||||
});
|
||||
}
|
||||
|
||||
function drawBars(canvas, days, mode) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const values = days.map(day => mode === 'calories' ? day.calories : day.fruit + day.vegetables);
|
||||
const max = Math.max(1, ...values);
|
||||
const left = 26;
|
||||
const bottom = canvas.height - 44;
|
||||
const height = canvas.height - 76;
|
||||
const gap = 14;
|
||||
const bar = (canvas.width - left * 2 - gap * 6) / 7;
|
||||
days.forEach((day, index) => {
|
||||
const x = left + index * (bar + gap);
|
||||
const h = values[index] / max * height;
|
||||
ctx.fillStyle = '#e0e7ff';
|
||||
roundRect(ctx, x, bottom - height, bar, height, 10);
|
||||
ctx.fillStyle = mode === 'calories' ? '#2563eb' : '#f59e0b';
|
||||
roundRect(ctx, x, bottom - h, bar, h, 10);
|
||||
if (mode === 'produce') {
|
||||
const vegH = day.vegetables / max * height;
|
||||
ctx.fillStyle = '#10b981';
|
||||
roundRect(ctx, x + bar * .52, bottom - vegH, bar * .48, vegH, 8);
|
||||
}
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.font = '13px Inter, sans-serif';
|
||||
ctx.fillText(day.label, x, bottom + 24);
|
||||
});
|
||||
}
|
||||
|
||||
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}
|
||||
|
|
@ -864,56 +1009,21 @@ 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-72 -translate-x-full overflow-hidden border-r border-slate-200 bg-white shadow-xl transition-all duration-200 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 lg:shadow-none lg:w-60">
|
||||
<div class="sidebar-header flex h-[68px] items-center justify-between border-b border-slate-200 px-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="sidebar-logo grid h-11 w-11 flex-none place-items-center rounded-2xl bg-blue-600 text-sm font-black text-white">CA</div>
|
||||
<div class="sidebar-brand min-w-0">
|
||||
<h1 class="truncate text-lg font-black text-slate-950">Calorie AI</h1>
|
||||
<p class="text-xs font-semibold text-slate-500">Food diary</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="collapse-button hidden h-10 w-10 place-items-center rounded-xl bg-slate-100 text-slate-700 hover:bg-slate-200 lg:grid" type="button" on:click={() => sidebarCollapsed = !sidebarCollapsed} aria-label="Collapse menu">☰</button>
|
||||
<button class="rounded-lg bg-slate-100 px-3 py-1.5 text-sm font-bold lg:hidden" type="button" on:click={() => menuOpen = false}>Close</button>
|
||||
</div>
|
||||
<nav class="p-2" aria-label="Main menu">
|
||||
{#if trackerPages.length}<div class="sidebar-section-label px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Tracker</div>{/if}
|
||||
{#each trackerPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}><span class="tab-icon">{item.icon}</span><span class="tab-label">{item.label}</span></button>{/each}
|
||||
{#if adminPages.length}<div class="sidebar-section-label px-3 py-2 text-xs font-bold uppercase tracking-wide text-slate-400">Admin</div>{/if}
|
||||
{#each adminPages as item}<button class:active-tab={page === item.id} class="tab-button" type="button" title={item.label} on:click={() => selectPage(item.id)}><span class="tab-icon">{item.icon}</span><span class="tab-label">{item.label}</span></button>{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
{#if menuOpen}<button class="fixed inset-0 z-30 bg-slate-950/40 lg:hidden" type="button" aria-label="Close menu" on:click={() => menuOpen = false}></button>{/if}
|
||||
|
||||
<div class="min-w-0">
|
||||
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 shadow-sm backdrop-blur">
|
||||
<div class="flex h-[68px] items-center justify-between gap-3 px-4 lg:px-6">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<button class="grid h-10 w-10 place-items-center rounded-xl bg-blue-600 text-white hover:bg-blue-700 lg:hidden" type="button" on:click={() => menuOpen = true} aria-label="Open menu">☰</button>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-lg font-black text-slate-950">{pages.find(item => item.id === page)?.label || 'Dashboard'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-bold text-slate-700 hover:border-blue-300 hover:text-blue-700" type="button" on:click={logout}>Sign out</button>
|
||||
</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><h2 class="text-xl font-bold">Dashboard</h2><p class="text-sm text-slate-500">Today’s totals and recent trends.</p></div>
|
||||
<div class="grid gap-3 xl:grid-cols-[1.4fr_0.6fr_0.6fr_0.8fr]">
|
||||
<article class="rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-700 p-6 text-white shadow-sm"><div class="text-xs font-bold uppercase tracking-wide text-blue-100">Today</div><div id="todayCalories" class="mt-2 text-5xl font-black">{today.calories} kcal</div><div id="todayMacroText" class="mt-3">P {today.protein}g | C {today.carbs}g | F {today.fat}g</div><div id="todayProduceText" class="text-sm text-blue-100">Fruit {today.fruit.toFixed(1)} | Veg {today.vegetables.toFixed(1)}</div></article>
|
||||
<Stat label="Meals" value={today.meals} note="Today" />
|
||||
<Stat label="7-day avg" value={`${weekAverage} kcal`} note="Calories/day" />
|
||||
<Stat label="Produce" value={`${produceToday.toFixed(1)} / 5`} note="Fruit + vegetables" />
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<section class="card"><h3 class="card-title">Quick actions</h3><div class="grid gap-2 p-4"><button class="btn-primary" type="button" on:click={() => selectPage('log')}>Log a meal</button><button class="btn-secondary" type="button" on:click={() => selectPage('diary')}>Review diary</button><button class="btn-secondary" type="button" on:click={() => selectPage('plans')}>Plans</button><button class="btn-secondary" type="button" on:click={() => selectPage('settings')}>Settings</button></div></section>
|
||||
<section class="card"><h3 class="card-title">Macros today</h3><div class="p-4"><canvas id="macroChart" bind:this={macroCanvas} width="520" height="260"></canvas></div></section>
|
||||
</div>
|
||||
</section>
|
||||
<DashboardPage
|
||||
{today}
|
||||
{activeCalorieTarget}
|
||||
{targetPercent}
|
||||
{macroTotal}
|
||||
{produceToday}
|
||||
{todayEntries}
|
||||
{todayActivities}
|
||||
{activityToday}
|
||||
{weekAverage}
|
||||
{selectPage}
|
||||
/>
|
||||
|
||||
{:else if page === 'log'}
|
||||
<MealForm
|
||||
{mealTypes}
|
||||
|
|
@ -930,11 +1040,14 @@ 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"><canvas id="calorieChart" bind:this={calorieCanvas} width="720" height="260"></canvas></div></section><section class="card"><h3 class="card-title">Fruit and vegetables</h3><div class="p-4"><canvas id="produceChart" bind:this={produceCanvas} width="720" height="240"></canvas></div></section></div></section>
|
||||
<AnalyticsPage {week} />
|
||||
|
||||
{:else if page === 'diary'}
|
||||
<DiaryPage
|
||||
{mealTypes}
|
||||
|
|
@ -950,6 +1063,7 @@ Image estimate: ${imageEstimate}` },
|
|||
{moveEntriesToTrash}
|
||||
{restoreEntry}
|
||||
/>
|
||||
|
||||
{:else if page === 'trash'}
|
||||
<DiaryPage
|
||||
{mealTypes}
|
||||
|
|
@ -966,6 +1080,7 @@ Image estimate: ${imageEstimate}` },
|
|||
{restoreEntry}
|
||||
trashMode={true}
|
||||
/>
|
||||
|
||||
{:else if page === 'plans'}
|
||||
<PlansPage
|
||||
bind:settings
|
||||
|
|
@ -980,6 +1095,7 @@ Image estimate: ${imageEstimate}` },
|
|||
{selectPlan}
|
||||
{deletePlan}
|
||||
/>
|
||||
|
||||
{:else if page === 'settings'}
|
||||
<SettingsPage
|
||||
bind:settings
|
||||
|
|
@ -989,10 +1105,14 @@ Image estimate: ${imageEstimate}` },
|
|||
{modelStatus}
|
||||
{modelError}
|
||||
{modelSearching}
|
||||
{modelActionId}
|
||||
bind:accountEmail
|
||||
{accountVerified}
|
||||
{accountStatus}
|
||||
{accountError}
|
||||
{healthConnectStatus}
|
||||
{healthConnectError}
|
||||
{healthConnectSyncing}
|
||||
{passwordStatus}
|
||||
{passwordError}
|
||||
{resetPasswordValue}
|
||||
|
|
@ -1006,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.65rem; border-left: 3px solid transparent; border-radius: 0.55rem; padding: 0.65rem 1rem; text-align: left; font-size: 0.875rem; font-weight: 600; color: #475569; }
|
||||
.tab-button:hover { background: #f8fafc; color: #0f172a; }
|
||||
.active-tab { border-left-color: #2563eb; background: #dbeafe; color: #2563eb; }
|
||||
.tab-icon { display: grid; width: 1.35rem; height: 1.35rem; flex: 0 0 1.35rem; place-items: center; font-weight: 900; }
|
||||
:global(.collapsed-sidebar) { width: 4.75rem !important; }
|
||||
:global(.collapsed-sidebar .sidebar-header) { justify-content: center; padding-left: 0; padding-right: 0; }
|
||||
:global(.collapsed-sidebar .tab-label), :global(.collapsed-sidebar .sidebar-section-label), :global(.collapsed-sidebar .sidebar-brand), :global(.collapsed-sidebar .sidebar-logo) { display: none; }
|
||||
:global(.collapsed-sidebar .tab-button) { justify-content: center; padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
:global(.collapsed-sidebar .collapse-button) { height: 2.5rem; width: 2.5rem; border-radius: 0.75rem; }
|
||||
</style>
|
||||
|
|
|
|||
79
web/src/AppShell.svelte
Normal 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>
|
||||
109
web/src/DashboardPage.svelte
Normal 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 · C {today.carbs}g · F {today.fat}g</div>
|
||||
<div id="todayProduceText" class="text-sm text-blue-200">Fruit {today.fruit.toFixed(1)} · Veg {today.vegetables.toFixed(1)}</div>
|
||||
</article>
|
||||
<Stat label="Meals today" value={today.meals} note={today.meals === 1 ? 'meal logged' : 'meals logged'} />
|
||||
<Stat label="7-day average" value={`${weekAverage} kcal`} note="Calories per day" />
|
||||
<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 · {Math.round(val / macroTotal * 100)}%</span>
|
||||
</div>
|
||||
<div class="h-2.5 overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full transition-all {cls}" style="width:{Math.round(val / macroTotal * 100)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="border-t border-slate-100 pt-2 text-xs text-slate-400">{today.calories} kcal total · produce {produceToday.toFixed(1)} / 5</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3 class="card-title">Today's meals</h3>
|
||||
<div class="divide-y divide-slate-100">
|
||||
{#each todayEntries as entry}
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-slate-800">{entry.mealName || entry.description}</div>
|
||||
<div class="text-xs text-slate-400">{entry.mealType} · {entry.time}</div>
|
||||
</div>
|
||||
<div class="ml-3 shrink-0 text-sm font-bold text-slate-600">{entry.calories} kcal</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-4 py-8 text-center text-sm text-slate-400">No meals logged today</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if todayEntries.length}
|
||||
<div class="border-t border-slate-100 px-4 py-2">
|
||||
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>+ Log another meal</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border-t border-slate-100 px-4 py-2">
|
||||
<button class="text-xs font-semibold text-blue-600 hover:text-blue-700" type="button" on:click={() => selectPage('log')}>Log your first meal today</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<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>
|
||||
|
|
@ -22,22 +22,29 @@
|
|||
<section class="space-y-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{trashMode ? 'Trash' : 'Diary'}</h2>
|
||||
<p class="text-sm text-slate-500">{trashMode ? 'Restore meals moved out of the diary.' : 'Search, edit, select, or move meals to trash.'}</p>
|
||||
<p class="text-sm text-slate-500">{trashMode ? 'Restore meals moved out of the diary.' : 'Search, filter, edit, or remove meals.'}</p>
|
||||
</div>
|
||||
|
||||
{#if trashMode}
|
||||
<div id="trashEntries" class="grid gap-3">
|
||||
<div id="trashEntries" class="grid gap-2">
|
||||
{#if trash.length}
|
||||
{#each trash as entry}
|
||||
<article class="entry card p-4">
|
||||
<div class="flex flex-col justify-between gap-2 md:flex-row">
|
||||
<div><strong>{entry.mealName}</strong><div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div></div>
|
||||
<button class="btn-secondary" type="button" on:click={() => restoreEntry(entry.id)}>Restore</button>
|
||||
<div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-800">{entry.mealName || entry.description}</div>
|
||||
<div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div>
|
||||
<div class="text-sm text-slate-400">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · {entry.calories} kcal</div>
|
||||
</div>
|
||||
<button class="btn-secondary shrink-0" type="button" on:click={() => restoreEntry(entry.id)}>Restore</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{:else}<div class="empty-state">Trash is empty.</div>{/if}
|
||||
{:else}
|
||||
<div class="empty-state">Trash is empty.</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<section class="card">
|
||||
<div class="grid gap-3 p-4 md:grid-cols-[1fr_180px_170px]">
|
||||
|
|
@ -47,10 +54,10 @@
|
|||
</div>
|
||||
{#if selectedEntryIds.length}
|
||||
<div class="flex flex-col gap-2 border-t border-slate-200 bg-blue-50 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="text-sm font-bold text-blue-900">{selectedEntryIds.length} selected</div>
|
||||
<div class="text-sm font-semibold text-blue-900">{selectedEntryIds.length} selected</div>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
{#if oneSelected}<button class="btn-secondary" type="button" on:click={() => editEntry(selectedRows[0])}>Edit selected</button>{/if}
|
||||
<button class="rounded-lg bg-red-600 px-4 py-2 text-sm font-black text-white hover:bg-red-700" type="button" on:click={() => moveEntriesToTrash(selectedEntryIds)}>Move selected to trash</button>
|
||||
<button class="rounded-lg bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700" type="button" on:click={() => moveEntriesToTrash(selectedEntryIds)}>Move selected to trash</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -60,27 +67,36 @@
|
|||
{#if diaryRows.length}
|
||||
{#each diaryDays as day}
|
||||
<section class="card">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-2">
|
||||
<h3 class="font-bold">{day}</h3>
|
||||
<button class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-bold text-slate-600 hover:border-blue-300 hover:text-blue-700" type="button" on:click={() => diaryDate = day}>Filter day</button>
|
||||
<div class="flex items-center justify-between border-b border-slate-100 bg-slate-50 px-4 py-2.5">
|
||||
<h3 class="text-sm font-bold text-slate-700">{day}</h3>
|
||||
<button class="rounded text-xs font-semibold text-slate-400 hover:text-blue-600" type="button" on:click={() => diaryDate = day}>Filter day</button>
|
||||
</div>
|
||||
<div class="grid gap-2 p-3">
|
||||
<div class="divide-y divide-slate-100">
|
||||
{#each diaryRows.filter(entry => entry.date === day) as entry}
|
||||
<article class="entry rounded-xl border border-slate-200 p-3">
|
||||
<div class="flex flex-col justify-between gap-2 md:flex-row">
|
||||
<label class="flex min-w-0 gap-3"><input type="checkbox" checked={selectedEntryIds.includes(entry.id)} on:change={() => toggleSelected(entry.id)}><span class="min-w-0"><strong>{entry.mealName}</strong><div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div></span></label>
|
||||
<div class="font-bold">{entry.calories} kcal</div>
|
||||
<article class="entry px-4 py-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<input type="checkbox" class="mt-0.5 h-4 w-4 shrink-0 rounded border-slate-300 accent-blue-600" checked={selectedEntryIds.includes(entry.id)} on:change={() => toggleSelected(entry.id)}>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<span class="font-semibold text-slate-800">{entry.mealName || entry.description}</span>
|
||||
<span class="ml-2 text-sm font-bold text-slate-600">{entry.calories} kcal</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-500">{entry.date} {entry.time} · {entry.mealType}</div>
|
||||
<div class="mt-0.5 text-xs text-slate-400">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · Fruit {Number(entry.fruitServings || 0).toFixed(1)} · Veg {Number(entry.vegetableServings || 0).toFixed(1)}</div>
|
||||
{#if entry.description}<div class="mt-1 truncate text-xs text-slate-400">{entry.description}</div>{/if}
|
||||
{#if entry.notes}<div class="text-xs text-slate-400">{entry.notes}</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-sm">P {entry.proteinGrams}g · C {entry.carbsGrams}g · F {entry.fatGrams}g · Fruit {Number(entry.fruitServings || 0).toFixed(1)} · Vegetables {Number(entry.vegetableServings || 0).toFixed(1)}</div>
|
||||
{#if entry.description}<div class="mt-2 text-sm text-slate-500">{entry.description}</div>{/if}
|
||||
{#if entry.foodGroups}<div class="text-sm text-slate-500">Groups: {entry.foodGroups}</div>{/if}
|
||||
{#if entry.notes}<div class="text-sm text-slate-500">{entry.notes}</div>{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
{:else}<div class="empty-state">No meals match the current filters.</div>{/if}
|
||||
{:else}
|
||||
<div class="empty-state">No meals match the current filters.</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
|
|
|||
22
web/src/Icon.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script>
|
||||
export let name;
|
||||
export let size = 18;
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" style="flex-shrink:0">
|
||||
{#if name === 'dashboard'}
|
||||
<path d="M2 3a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm10 0a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1V3zm0 8a1 1 0 011-1h4a1 1 0 011 1v6a1 1 0 01-1 1h-4a1 1 0 01-1-1v-6zM2 12a1 1 0 011-1h6a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5z"/>
|
||||
{:else if name === 'plus'}
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
|
||||
{:else if name === 'analytics'}
|
||||
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/>
|
||||
{:else if name === 'diary'}
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 000 2h14a1 1 0 100-2H3zm0 4a1 1 0 000 2h14a1 1 0 100-2H3zm0 4a1 1 0 000 2h10a1 1 0 100-2H3z" clip-rule="evenodd"/>
|
||||
{:else if name === 'trash'}
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm4 0a1 1 0 112 0v4a1 1 0 11-2 0V8z" clip-rule="evenodd"/>
|
||||
{:else if name === 'plans'}
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h4a1 1 0 100-2H7z" clip-rule="evenodd"/>
|
||||
{:else if name === 'settings'}
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
|
||||
{/if}
|
||||
</svg>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||