Compare commits
25 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd70da8c71 | ||
|
|
38d9f50c31 | ||
|
|
ef6b3a494c | ||
|
|
176a9fc3bb | ||
|
|
33d50258ce | ||
|
|
f73b58a158 | ||
|
|
fcef0d48db | ||
|
|
f14b433085 | ||
|
|
8da55652e0 | ||
|
|
40e65ca70c | ||
|
|
03d4178113 | ||
|
|
a4e44201a2 | ||
|
|
b93e479be4 | ||
|
|
af35b70399 | ||
|
|
709d6c1f6f | ||
|
|
99bd1bff42 | ||
|
|
19d8899057 | ||
|
|
9f660d50bc | ||
|
|
f42e8c8784 | ||
|
|
5fa4b59335 | ||
|
|
2e2ba12cf3 | ||
|
|
87102dbc5b | ||
|
|
5837b1c8dc | ||
|
|
f57c04c989 | ||
|
|
71c8837b48 |
|
|
@ -29,11 +29,11 @@ jobs:
|
|||
unzip -q /tmp/gradle.zip -d /opt
|
||||
ln -s "/opt/gradle-${GRADLE_VERSION}/bin/gradle" /usr/local/bin/gradle
|
||||
|
||||
- name: Build debug APK
|
||||
run: gradle --no-daemon :app:assembleDebug
|
||||
- name: Test and build debug APK
|
||||
run: gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug
|
||||
|
||||
- name: Upload debug APK
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
name: Android Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
|
|
@ -9,8 +12,6 @@ on:
|
|||
jobs:
|
||||
release-apk:
|
||||
runs-on: android-ci
|
||||
env:
|
||||
GRADLE_VERSION: 8.10.2
|
||||
steps:
|
||||
- name: Check out repository
|
||||
run: |
|
||||
|
|
@ -20,16 +21,47 @@ 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: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:
|
||||
FORGEJO_API: http://forgejo:3000/api/v1
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apk="dist/calorie-ai-${GITHUB_REF_NAME}.apk"
|
||||
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: "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 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
|
||||
curl -fsS -X POST -H "$auth_header" -F "attachment=@${apk}" "$FORGEJO_API/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=calorie-ai-${GITHUB_REF_NAME}.apk"
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
|
|
|
|||
42
.forgejo/workflows/web.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
name: Web CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: forgejo-local
|
||||
steps:
|
||||
- name: Check out repository
|
||||
run: |
|
||||
git clone "http://forgejo:3000/${GITHUB_REPOSITORY}.git" .
|
||||
git checkout "$GITHUB_SHA"
|
||||
|
||||
- name: Install Node and browsers
|
||||
run: |
|
||||
apt-get update
|
||||
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
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
cd web
|
||||
CI=true \
|
||||
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
|
||||
6
.gitignore
vendored
|
|
@ -5,3 +5,9 @@ dist/
|
|||
local.properties
|
||||
*.iml
|
||||
.idea/
|
||||
web/node_modules/
|
||||
web/test-results/
|
||||
web/playwright-report/
|
||||
web/.test-data/
|
||||
web/data/
|
||||
web/.env
|
||||
|
|
|
|||
76
README.md
|
|
@ -1,17 +1,19 @@
|
|||
# Calorie AI Android
|
||||
# Calorie AI
|
||||
|
||||
Native Android calorie tracker with optional meal images and admin-configurable AI models.
|
||||
Native Android and Dockerized web calorie tracker with meal images, AI nutrition estimates, server-backed plans, and admin-configurable AI models.
|
||||
|
||||
## Features
|
||||
|
||||
- Add meals by description, portion estimate, and optional image.
|
||||
- Add meals by description, portion estimate, and uploaded image.
|
||||
- Analyze meals through OpenAI-compatible chat completions.
|
||||
- Uses a vision model for image interpretation when an image is attached.
|
||||
- Uses a tasking model to estimate calories, macros, and notes.
|
||||
- Stores daily meal entries locally on device.
|
||||
- Admin settings control API base URL, API key, vision model, and tasking model. The two model fields can contain the same model name.
|
||||
- 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 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.
|
||||
|
||||
## Build
|
||||
## Android Build
|
||||
|
||||
Open this folder in Android Studio and run the `app` configuration, or build with Gradle if available:
|
||||
|
||||
|
|
@ -21,7 +23,61 @@ gradle :app:assembleDebug
|
|||
|
||||
Forgejo Actions builds the debug APK on every push to `main` and uploads it as the `calorie-ai-debug-apk` artifact.
|
||||
|
||||
Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` through the Android Release workflow.
|
||||
Tagged versions also build an installable APK named `calorie-ai-vX.Y.Z.apk` and attach it to a Forgejo release through the Android Release workflow.
|
||||
|
||||
## Install With Obtainium
|
||||
|
||||
Use the Forgejo releases page as the source:
|
||||
|
||||
```text
|
||||
https://git.danvics.com/danvics/calorie-ai-android/releases
|
||||
```
|
||||
|
||||
If Obtainium asks for a direct APK URL, use the latest release asset URL pattern:
|
||||
|
||||
```text
|
||||
https://git.danvics.com/danvics/calorie-ai-android/releases/download/v0.1.1/calorie-ai-v0.1.1.apk
|
||||
```
|
||||
|
||||
Recommended Obtainium setup:
|
||||
|
||||
- App source: `HTML` or `Gitea/Forgejo` if your Obtainium version offers it.
|
||||
- URL: `https://git.danvics.com/danvics/calorie-ai-android/releases`
|
||||
- APK link filter: `calorie-ai-.*\.apk`
|
||||
- Version extraction: release tag, for example `v0.1.1`.
|
||||
|
||||
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.
|
||||
|
||||
Run it with Docker:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8095
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -36,4 +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 and Android clients use the same authenticated server APIs for entries, settings, plans, models, and synced activity.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.compose'
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
@ -10,7 +12,45 @@ 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
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20240303'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,569 +0,0 @@
|
|||
package com.danvics.calorieai;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.text.InputType;
|
||||
import android.util.Base64;
|
||||
import android.view.Gravity;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.database.Cursor;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public class MainActivity extends Activity {
|
||||
private static final int PICK_IMAGE_REQUEST = 11;
|
||||
|
||||
private SharedPreferences prefs;
|
||||
private LinearLayout root;
|
||||
private EditText descriptionInput;
|
||||
private EditText measureInput;
|
||||
private TextView selectedImageText;
|
||||
private TextView statusText;
|
||||
private Button analyzeButton;
|
||||
private Uri selectedImageUri;
|
||||
private String selectedImageDataUri;
|
||||
private String selectedImageName;
|
||||
private boolean adminUnlocked;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
prefs = getSharedPreferences("calorie_ai", MODE_PRIVATE);
|
||||
adminUnlocked = prefs.getBoolean("admin_unlocked", false);
|
||||
buildScreen();
|
||||
}
|
||||
|
||||
private void buildScreen() {
|
||||
ScrollView scrollView = new ScrollView(this);
|
||||
scrollView.setBackgroundColor(Color.rgb(248, 244, 236));
|
||||
|
||||
root = new LinearLayout(this);
|
||||
root.setOrientation(LinearLayout.VERTICAL);
|
||||
root.setPadding(dp(18), dp(24), dp(18), dp(24));
|
||||
scrollView.addView(root);
|
||||
|
||||
TextView title = text("Calorie AI", 30, true);
|
||||
title.setTextColor(Color.rgb(38, 55, 45));
|
||||
root.addView(title);
|
||||
|
||||
TextView subtitle = text("Track what you eat each day. Add a description, portion estimate, and optional image for AI calorie analysis.", 15, false);
|
||||
subtitle.setTextColor(Color.rgb(91, 99, 92));
|
||||
subtitle.setPadding(0, dp(6), 0, dp(18));
|
||||
root.addView(subtitle);
|
||||
|
||||
addTodaySummary();
|
||||
addMealForm();
|
||||
addEntries();
|
||||
addAdminSettings();
|
||||
|
||||
setContentView(scrollView);
|
||||
}
|
||||
|
||||
private void addTodaySummary() {
|
||||
JSONArray todayEntries = entriesForDate(todayKey());
|
||||
int calories = 0;
|
||||
int protein = 0;
|
||||
int carbs = 0;
|
||||
int fat = 0;
|
||||
|
||||
for (int i = 0; i < todayEntries.length(); i++) {
|
||||
JSONObject entry = todayEntries.optJSONObject(i);
|
||||
if (entry == null) continue;
|
||||
calories += entry.optInt("calories");
|
||||
protein += entry.optInt("proteinGrams");
|
||||
carbs += entry.optInt("carbsGrams");
|
||||
fat += entry.optInt("fatGrams");
|
||||
}
|
||||
|
||||
LinearLayout card = card();
|
||||
TextView heading = text("Today", 18, true);
|
||||
card.addView(heading);
|
||||
card.addView(text(todayKey() + " | " + todayEntries.length() + " meals", 13, false));
|
||||
|
||||
TextView totals = text(calories + " kcal", 34, true);
|
||||
totals.setTextColor(Color.rgb(47, 125, 89));
|
||||
totals.setPadding(0, dp(8), 0, dp(2));
|
||||
card.addView(totals);
|
||||
card.addView(text("Protein " + protein + "g | Carbs " + carbs + "g | Fat " + fat + "g", 14, false));
|
||||
root.addView(card);
|
||||
}
|
||||
|
||||
private void addMealForm() {
|
||||
LinearLayout card = card();
|
||||
card.addView(text("Add Meal", 20, true));
|
||||
|
||||
descriptionInput = input("What did you eat? Example: chicken rice bowl with avocado");
|
||||
descriptionInput.setMinLines(3);
|
||||
descriptionInput.setGravity(Gravity.TOP);
|
||||
card.addView(descriptionInput);
|
||||
|
||||
measureInput = input("Portion or measure. Example: 450g, 1 large bowl, 2 slices");
|
||||
card.addView(measureInput);
|
||||
|
||||
Button imageButton = button("Choose optional image");
|
||||
imageButton.setOnClickListener(v -> pickImage());
|
||||
card.addView(imageButton);
|
||||
|
||||
selectedImageText = text("No image selected", 13, false);
|
||||
selectedImageText.setTextColor(Color.rgb(91, 99, 92));
|
||||
selectedImageText.setPadding(0, dp(6), 0, dp(6));
|
||||
card.addView(selectedImageText);
|
||||
|
||||
analyzeButton = button("Analyze and save meal");
|
||||
analyzeButton.setOnClickListener(v -> analyzeMeal());
|
||||
card.addView(analyzeButton);
|
||||
|
||||
statusText = text("", 13, false);
|
||||
statusText.setPadding(0, dp(8), 0, 0);
|
||||
card.addView(statusText);
|
||||
|
||||
root.addView(card);
|
||||
}
|
||||
|
||||
private void addEntries() {
|
||||
LinearLayout card = card();
|
||||
card.addView(text("Today's Meals", 20, true));
|
||||
JSONArray todayEntries = entriesForDate(todayKey());
|
||||
|
||||
if (todayEntries.length() == 0) {
|
||||
TextView empty = text("No meals logged today.", 14, false);
|
||||
empty.setTextColor(Color.rgb(91, 99, 92));
|
||||
card.addView(empty);
|
||||
root.addView(card);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = todayEntries.length() - 1; i >= 0; i--) {
|
||||
JSONObject entry = todayEntries.optJSONObject(i);
|
||||
if (entry == null) continue;
|
||||
TextView meal = text(entry.optString("time") + " " + entry.optString("mealName", "Meal"), 16, true);
|
||||
meal.setPadding(0, dp(12), 0, dp(2));
|
||||
card.addView(meal);
|
||||
card.addView(text(entry.optInt("calories") + " kcal | P " + entry.optInt("proteinGrams")
|
||||
+ "g | C " + entry.optInt("carbsGrams") + "g | F " + entry.optInt("fatGrams") + "g", 14, false));
|
||||
card.addView(text(entry.optString("description"), 13, false));
|
||||
String notes = entry.optString("notes");
|
||||
if (!notes.isEmpty()) {
|
||||
TextView note = text(notes, 13, false);
|
||||
note.setTextColor(Color.rgb(91, 99, 92));
|
||||
card.addView(note);
|
||||
}
|
||||
}
|
||||
root.addView(card);
|
||||
}
|
||||
|
||||
private void addAdminSettings() {
|
||||
LinearLayout card = card();
|
||||
card.addView(text("Admin AI Settings", 20, true));
|
||||
|
||||
if (!adminUnlocked) {
|
||||
EditText pin = input("Admin PIN");
|
||||
pin.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
card.addView(pin);
|
||||
|
||||
Button unlock = button("Unlock settings");
|
||||
unlock.setOnClickListener(v -> {
|
||||
String expected = prefs.getString("admin_pin", "admin");
|
||||
if (expected.equals(pin.getText().toString())) {
|
||||
adminUnlocked = true;
|
||||
prefs.edit().putBoolean("admin_unlocked", true).apply();
|
||||
buildScreen();
|
||||
} else {
|
||||
pin.setError("Incorrect PIN");
|
||||
}
|
||||
});
|
||||
card.addView(unlock);
|
||||
card.addView(text("Default PIN: admin", 12, false));
|
||||
root.addView(card);
|
||||
return;
|
||||
}
|
||||
|
||||
EditText baseUrl = input("API base URL, e.g. https://api.openai.com/v1");
|
||||
baseUrl.setText(prefs.getString("api_base_url", ""));
|
||||
card.addView(baseUrl);
|
||||
|
||||
EditText apiKey = input("API key. Leave blank for local endpoints that do not need auth");
|
||||
apiKey.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
apiKey.setText(prefs.getString("api_key", ""));
|
||||
card.addView(apiKey);
|
||||
|
||||
EditText visionModel = input("Vision model");
|
||||
visionModel.setText(prefs.getString("vision_model", "gpt-4o-mini"));
|
||||
card.addView(visionModel);
|
||||
|
||||
EditText taskModel = input("Tasking model");
|
||||
taskModel.setText(prefs.getString("task_model", "gpt-4o-mini"));
|
||||
card.addView(taskModel);
|
||||
|
||||
EditText newPin = input("New admin PIN, optional");
|
||||
newPin.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
card.addView(newPin);
|
||||
|
||||
Button save = button("Save AI settings");
|
||||
save.setOnClickListener(v -> {
|
||||
SharedPreferences.Editor editor = prefs.edit()
|
||||
.putString("api_base_url", baseUrl.getText().toString().trim())
|
||||
.putString("api_key", apiKey.getText().toString().trim())
|
||||
.putString("vision_model", visionModel.getText().toString().trim())
|
||||
.putString("task_model", taskModel.getText().toString().trim());
|
||||
String pin = newPin.getText().toString().trim();
|
||||
if (!pin.isEmpty()) editor.putString("admin_pin", pin);
|
||||
editor.apply();
|
||||
save.setText("Saved");
|
||||
});
|
||||
card.addView(save);
|
||||
|
||||
Button lock = button("Lock admin settings");
|
||||
lock.setOnClickListener(v -> {
|
||||
adminUnlocked = false;
|
||||
prefs.edit().putBoolean("admin_unlocked", false).apply();
|
||||
buildScreen();
|
||||
});
|
||||
card.addView(lock);
|
||||
|
||||
TextView help = text("The vision model reads attached meal photos. The tasking model estimates calories, macros, and notes from the full meal context. Both fields may use the same model.", 12, false);
|
||||
help.setTextColor(Color.rgb(91, 99, 92));
|
||||
card.addView(help);
|
||||
root.addView(card);
|
||||
}
|
||||
|
||||
private void pickImage() {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("image/*");
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
|
||||
startActivityForResult(intent, PICK_IMAGE_REQUEST);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode != PICK_IMAGE_REQUEST || resultCode != RESULT_OK || data == null) return;
|
||||
selectedImageUri = data.getData();
|
||||
if (selectedImageUri == null) return;
|
||||
try {
|
||||
getContentResolver().takePersistableUriPermission(selectedImageUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
} catch (SecurityException ignored) {
|
||||
// Some document providers grant one-shot read access only; analysis still works in-session.
|
||||
}
|
||||
selectedImageName = displayName(selectedImageUri);
|
||||
selectedImageDataUri = null;
|
||||
selectedImageText.setText("Selected: " + selectedImageName);
|
||||
}
|
||||
|
||||
private void analyzeMeal() {
|
||||
String description = descriptionInput.getText().toString().trim();
|
||||
String measure = measureInput.getText().toString().trim();
|
||||
String baseUrl = prefs.getString("api_base_url", "").trim();
|
||||
String taskModel = prefs.getString("task_model", "").trim();
|
||||
|
||||
if (description.isEmpty() && selectedImageUri == null) {
|
||||
status("Describe the meal or attach an image.", true);
|
||||
return;
|
||||
}
|
||||
if (baseUrl.isEmpty() || taskModel.isEmpty()) {
|
||||
status("Admin must set API base URL and tasking model first.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
analyzeButton.setEnabled(false);
|
||||
status("Analyzing meal...", false);
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
String vision = "";
|
||||
if (selectedImageUri != null) {
|
||||
if (selectedImageDataUri == null) selectedImageDataUri = readImageAsDataUri(selectedImageUri);
|
||||
String visionModel = prefs.getString("vision_model", "").trim();
|
||||
if (!visionModel.isEmpty()) {
|
||||
vision = requestVisionDescription(baseUrl, visionModel, description, selectedImageDataUri);
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject estimate = requestTaskEstimate(baseUrl, taskModel, description, measure, vision);
|
||||
saveEntry(description, measure, vision, estimate, selectedImageUri != null);
|
||||
|
||||
runOnUiThread(() -> {
|
||||
selectedImageUri = null;
|
||||
selectedImageDataUri = null;
|
||||
selectedImageName = null;
|
||||
buildScreen();
|
||||
});
|
||||
} catch (Exception e) {
|
||||
runOnUiThread(() -> {
|
||||
analyzeButton.setEnabled(true);
|
||||
status("AI analysis failed: " + e.getMessage(), true);
|
||||
});
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private String requestVisionDescription(String baseUrl, String model, String description, String imageDataUri) throws Exception {
|
||||
JSONArray content = new JSONArray();
|
||||
content.put(new JSONObject()
|
||||
.put("type", "text")
|
||||
.put("text", "Describe this food photo for calorie estimation. Include likely foods, portion sizes, visible quantities, sauces, and uncertainty. User description: " + description));
|
||||
content.put(new JSONObject()
|
||||
.put("type", "image_url")
|
||||
.put("image_url", new JSONObject().put("url", imageDataUri)));
|
||||
|
||||
JSONArray messages = new JSONArray();
|
||||
messages.put(new JSONObject().put("role", "user").put("content", content));
|
||||
|
||||
JSONObject body = new JSONObject()
|
||||
.put("model", model)
|
||||
.put("messages", messages)
|
||||
.put("temperature", 0.2);
|
||||
|
||||
return chatCompletion(baseUrl, body);
|
||||
}
|
||||
|
||||
private JSONObject requestTaskEstimate(String baseUrl, String model, String description, String measure, String vision) throws Exception {
|
||||
String prompt = "You are a nutrition logging assistant. Estimate calories and macros for one meal. "
|
||||
+ "Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, notes. "
|
||||
+ "Use integer grams and calories. Mention uncertainty in notes.\n\n"
|
||||
+ "User description: " + description + "\n"
|
||||
+ "User measure: " + measure + "\n"
|
||||
+ "Vision description: " + vision;
|
||||
|
||||
JSONArray messages = new JSONArray();
|
||||
messages.put(new JSONObject().put("role", "system").put("content", "Return strict JSON only."));
|
||||
messages.put(new JSONObject().put("role", "user").put("content", prompt));
|
||||
|
||||
JSONObject body = new JSONObject()
|
||||
.put("model", model)
|
||||
.put("messages", messages)
|
||||
.put("temperature", 0.1);
|
||||
|
||||
String content = chatCompletion(baseUrl, body);
|
||||
return parseEstimate(content);
|
||||
}
|
||||
|
||||
private String chatCompletion(String baseUrl, JSONObject body) throws Exception {
|
||||
String urlString = baseUrl.replaceAll("/+$", "") + "/chat/completions";
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(urlString).openConnection();
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setConnectTimeout(20000);
|
||||
connection.setReadTimeout(60000);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
|
||||
String apiKey = prefs.getString("api_key", "").trim();
|
||||
if (!apiKey.isEmpty()) connection.setRequestProperty("Authorization", "Bearer " + apiKey);
|
||||
|
||||
connection.setDoOutput(true);
|
||||
try (OutputStream output = connection.getOutputStream()) {
|
||||
output.write(body.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
int code = connection.getResponseCode();
|
||||
InputStream stream = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream();
|
||||
String response = readText(stream);
|
||||
if (code < 200 || code >= 300) throw new IllegalStateException("HTTP " + code + ": " + response);
|
||||
|
||||
JSONObject json = new JSONObject(response);
|
||||
return json.getJSONArray("choices")
|
||||
.getJSONObject(0)
|
||||
.getJSONObject("message")
|
||||
.getString("content")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private JSONObject parseEstimate(String content) throws JSONException {
|
||||
String cleaned = content.trim();
|
||||
if (cleaned.startsWith("```")) {
|
||||
cleaned = cleaned.replaceFirst("^```json", "").replaceFirst("^```", "");
|
||||
cleaned = cleaned.replaceFirst("```$", "").trim();
|
||||
}
|
||||
int start = cleaned.indexOf('{');
|
||||
int end = cleaned.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) cleaned = cleaned.substring(start, end + 1);
|
||||
|
||||
JSONObject estimate = new JSONObject(cleaned);
|
||||
return new JSONObject()
|
||||
.put("mealName", estimate.optString("mealName", "Meal"))
|
||||
.put("calories", Math.max(0, estimate.optInt("calories")))
|
||||
.put("proteinGrams", Math.max(0, estimate.optInt("proteinGrams")))
|
||||
.put("carbsGrams", Math.max(0, estimate.optInt("carbsGrams")))
|
||||
.put("fatGrams", Math.max(0, estimate.optInt("fatGrams")))
|
||||
.put("notes", estimate.optString("notes", ""))
|
||||
.put("raw", content);
|
||||
}
|
||||
|
||||
private void saveEntry(String description, String measure, String vision, JSONObject estimate, boolean hasImage) throws JSONException {
|
||||
JSONArray entries = allEntries();
|
||||
JSONObject entry = new JSONObject()
|
||||
.put("date", todayKey())
|
||||
.put("time", LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")))
|
||||
.put("description", description)
|
||||
.put("measure", measure)
|
||||
.put("imageIncluded", hasImage)
|
||||
.put("visionDescription", vision)
|
||||
.put("mealName", estimate.optString("mealName", "Meal"))
|
||||
.put("calories", estimate.optInt("calories"))
|
||||
.put("proteinGrams", estimate.optInt("proteinGrams"))
|
||||
.put("carbsGrams", estimate.optInt("carbsGrams"))
|
||||
.put("fatGrams", estimate.optInt("fatGrams"))
|
||||
.put("notes", estimate.optString("notes"))
|
||||
.put("rawAi", estimate.optString("raw"));
|
||||
entries.put(entry);
|
||||
prefs.edit().putString("entries", entries.toString()).apply();
|
||||
}
|
||||
|
||||
private JSONArray allEntries() {
|
||||
try {
|
||||
return new JSONArray(prefs.getString("entries", "[]"));
|
||||
} catch (JSONException e) {
|
||||
return new JSONArray();
|
||||
}
|
||||
}
|
||||
|
||||
private JSONArray entriesForDate(String date) {
|
||||
JSONArray filtered = new JSONArray();
|
||||
JSONArray entries = allEntries();
|
||||
for (int i = 0; i < entries.length(); i++) {
|
||||
JSONObject entry = entries.optJSONObject(i);
|
||||
if (entry != null && date.equals(entry.optString("date"))) filtered.put(entry);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private String readImageAsDataUri(Uri uri) throws Exception {
|
||||
try (InputStream input = getContentResolver().openInputStream(uri)) {
|
||||
if (input == null) throw new IllegalStateException("Could not read selected image");
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] data = new byte[8192];
|
||||
int read;
|
||||
while ((read = input.read(data)) != -1) buffer.write(data, 0, read);
|
||||
String type = getContentResolver().getType(uri);
|
||||
if (type == null) type = "image/jpeg";
|
||||
return "data:" + type + ";base64," + Base64.encodeToString(buffer.toByteArray(), Base64.NO_WRAP);
|
||||
}
|
||||
}
|
||||
|
||||
private String displayName(Uri uri) {
|
||||
try (Cursor cursor = getContentResolver().query(uri, null, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (index >= 0) return cursor.getString(index);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return uri.getLastPathSegment() == null ? "selected image" : uri.getLastPathSegment();
|
||||
}
|
||||
|
||||
private String readText(InputStream input) throws Exception {
|
||||
if (input == null) return "";
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) builder.append(line);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String todayKey() {
|
||||
return LocalDate.now().toString();
|
||||
}
|
||||
|
||||
private void status(String message, boolean error) {
|
||||
statusText.setText(message);
|
||||
statusText.setTextColor(error ? Color.rgb(164, 52, 52) : Color.rgb(47, 125, 89));
|
||||
}
|
||||
|
||||
private LinearLayout card() {
|
||||
LinearLayout layout = new LinearLayout(this);
|
||||
layout.setOrientation(LinearLayout.VERTICAL);
|
||||
layout.setPadding(dp(16), dp(16), dp(16), dp(16));
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.setMargins(0, 0, 0, dp(14));
|
||||
layout.setLayoutParams(params);
|
||||
|
||||
GradientDrawable background = new GradientDrawable();
|
||||
background.setColor(Color.WHITE);
|
||||
background.setCornerRadius(dp(18));
|
||||
background.setStroke(dp(1), Color.rgb(229, 221, 207));
|
||||
layout.setBackground(background);
|
||||
return layout;
|
||||
}
|
||||
|
||||
private TextView text(String value, int sp, boolean bold) {
|
||||
TextView view = new TextView(this);
|
||||
view.setText(value);
|
||||
view.setTextSize(sp);
|
||||
view.setTextColor(Color.rgb(38, 55, 45));
|
||||
view.setLineSpacing(0, 1.12f);
|
||||
if (bold) view.setTypeface(Typeface.DEFAULT, Typeface.BOLD);
|
||||
return view;
|
||||
}
|
||||
|
||||
private EditText input(String hint) {
|
||||
EditText editText = new EditText(this);
|
||||
editText.setHint(hint);
|
||||
editText.setTextSize(14);
|
||||
editText.setSingleLine(false);
|
||||
editText.setPadding(dp(12), dp(10), dp(12), dp(10));
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.setMargins(0, dp(10), 0, 0);
|
||||
editText.setLayoutParams(params);
|
||||
|
||||
GradientDrawable background = new GradientDrawable();
|
||||
background.setColor(Color.rgb(252, 250, 246));
|
||||
background.setCornerRadius(dp(12));
|
||||
background.setStroke(dp(1), Color.rgb(219, 211, 197));
|
||||
editText.setBackground(background);
|
||||
return editText;
|
||||
}
|
||||
|
||||
private Button button(String label) {
|
||||
Button button = new Button(this);
|
||||
button.setText(label);
|
||||
button.setTextColor(Color.WHITE);
|
||||
button.setAllCaps(false);
|
||||
GradientDrawable background = new GradientDrawable();
|
||||
background.setColor(Color.rgb(47, 125, 89));
|
||||
background.setCornerRadius(dp(12));
|
||||
button.setBackground(background);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.setMargins(0, dp(10), 0, 0);
|
||||
button.setLayoutParams(params);
|
||||
return button;
|
||||
}
|
||||
|
||||
private int dp(int value) {
|
||||
return Math.round(value * getResources().getDisplayMetrics().density);
|
||||
}
|
||||
}
|
||||
473
app/src/main/java/com/danvics/calorieai/MainActivity.kt
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
package com.danvics.calorieai
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.health.connect.client.contracts.HealthPermissionsRequestContract
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.FileProvider
|
||||
import com.danvics.calorieai.ai.ImagePayload
|
||||
import com.danvics.calorieai.ai.NutritionParser
|
||||
import com.danvics.calorieai.data.*
|
||||
import com.danvics.calorieai.integrations.HealthConnectAvailability
|
||||
import com.danvics.calorieai.integrations.HealthConnectSync
|
||||
import com.danvics.calorieai.ui.CalorieAiApp
|
||||
import com.danvics.calorieai.ui.CalorieTheme
|
||||
import com.danvics.calorieai.ui.ConnectScreen
|
||||
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
|
||||
import java.util.UUID
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val repo = ApiRepository(getSharedPreferences("calorie_ai", MODE_PRIVATE))
|
||||
setContent { AppRoot(repo) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRoot(repo: ApiRepository) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val parser = remember { NutritionParser() }
|
||||
val healthConnect = remember { HealthConnectSync(context) }
|
||||
|
||||
val cameraImageFile = remember { File(context.cacheDir, "cam_capture.jpg") }
|
||||
val cameraImageUri: Uri = remember {
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", cameraImageFile)
|
||||
}
|
||||
|
||||
var connected by remember { mutableStateOf(repo.isConnected()) }
|
||||
var connectError by remember { mutableStateOf("") }
|
||||
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 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) }
|
||||
|
||||
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 imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
if (bytes != null) {
|
||||
val bitmap = decodeSampledBitmap(bytes)
|
||||
val payload = ImagePayload.fromBytes(
|
||||
uri.lastPathSegment ?: "image",
|
||||
context.contentResolver.getType(uri) ?: "image/jpeg",
|
||||
bytes
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
selectedImage = payload
|
||||
selectedImageBitmap = bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val camera = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val bytes = cameraImageFile.readBytes()
|
||||
if (bytes.isNotEmpty()) {
|
||||
val bitmap = decodeSampledBitmap(bytes)
|
||||
val payload = ImagePayload.fromBytes("photo.jpg", "image/jpeg", bytes)
|
||||
withContext(Dispatchers.Main) {
|
||||
selectedImage = payload
|
||||
selectedImageBitmap = bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val healthPermissionLauncher = rememberLauncherForActivityResult(HealthPermissionsRequestContract()) { granted ->
|
||||
if (granted.containsAll(healthConnect.permissions)) performHealthConnectSync()
|
||||
else healthConnectStatus = "Health Connect permission was not granted."
|
||||
}
|
||||
|
||||
fun handleUnauth() { connected = false; appState = AppState() }
|
||||
|
||||
fun syncHealthConnect() {
|
||||
when (healthConnect.availability()) {
|
||||
HealthConnectAvailability.Unavailable -> {
|
||||
healthConnectStatus = "Health Connect is not available on this device."
|
||||
}
|
||||
HealthConnectAvailability.UpdateRequired -> {
|
||||
healthConnectStatus = "Install or update Health Connect, then try again."
|
||||
}
|
||||
HealthConnectAvailability.Available -> {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val granted = runCatching { healthConnect.hasPermissions() }.getOrDefault(false)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (granted) performHealthConnectSync()
|
||||
else healthPermissionLauncher.launch(healthConnect.permissions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun syncState() {
|
||||
syncing = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
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(
|
||||
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(cameraImageUri) },
|
||||
onCancelEdit = {
|
||||
editing = null
|
||||
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 (appState.settings.taskModel.isBlank()) { status = "No AI model configured on server."; return@CalorieAiApp }
|
||||
busy = true
|
||||
status = "Analyzing meal..."
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
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
|
||||
)
|
||||
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 ->
|
||||
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() } }
|
||||
}
|
||||
},
|
||||
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"
|
||||
)))
|
||||
20
app/src/main/java/com/danvics/calorieai/ai/AiClient.kt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package com.danvics.calorieai.ai
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Base64
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
data class ImagePayload(val name: String, val dataUri: String) {
|
||||
companion object {
|
||||
fun fromBytes(name: String, mimeType: String, bytes: ByteArray) = ImagePayload(
|
||||
name = name,
|
||||
dataUri = "data:$mimeType;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
)
|
||||
|
||||
fun fromBitmap(name: String, bitmap: Bitmap): ImagePayload {
|
||||
val output = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
||||
return fromBytes(name, "image/jpeg", output.toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.danvics.calorieai.ai
|
||||
|
||||
import com.danvics.calorieai.data.NutritionEstimate
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class NutritionParser {
|
||||
fun parse(content: String): NutritionEstimate {
|
||||
var cleaned = content.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)
|
||||
val json = JSONObject(cleaned)
|
||||
return NutritionEstimate(
|
||||
mealName = json.optString("mealName", "Meal").ifBlank { "Meal" },
|
||||
calories = json.optInt("calories").coerceAtLeast(0),
|
||||
proteinGrams = json.optInt("proteinGrams").coerceAtLeast(0),
|
||||
carbsGrams = json.optInt("carbsGrams").coerceAtLeast(0),
|
||||
fatGrams = json.optInt("fatGrams").coerceAtLeast(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
|
||||
)
|
||||
}
|
||||
|
||||
private fun stringifyGroups(value: Any?): String = when (value) {
|
||||
null, JSONObject.NULL -> ""
|
||||
is JSONArray -> (0 until value.length()).joinToString(", ") { value.optString(it) }
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
271
app/src/main/java/com/danvics/calorieai/data/AppRepository.kt
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
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 UnauthorizedException : Exception("Session expired. Please reconnect.")
|
||||
|
||||
class ApiRepository(private val prefs: SharedPreferences) {
|
||||
private var config = loadConfig()
|
||||
|
||||
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", "") ?: ""
|
||||
)
|
||||
|
||||
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 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 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 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("foodGroups", estimate.foodGroups).put("notes", estimate.notes).put("rawAi", estimate.raw)
|
||||
|
||||
fun JSONObject.toMealEntry(): MealEntry = MealEntry(
|
||||
id = optString("id", System.currentTimeMillis().toString()),
|
||||
date = optString("date"),
|
||||
time = optString("time"),
|
||||
mealType = optString("mealType", "Other"),
|
||||
description = optString("description"),
|
||||
measure = optString("measure"),
|
||||
imageIncluded = optBoolean("imageIncluded"),
|
||||
imageName = optString("imageName"),
|
||||
visionEstimate = optString("visionEstimate"),
|
||||
trashedAt = optString("trashedAt"),
|
||||
estimate = NutritionEstimate(
|
||||
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
|
||||
26
app/src/main/java/com/danvics/calorieai/data/MealStats.kt
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package com.danvics.calorieai.data
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
object MealStats {
|
||||
fun totalsForDate(entries: List<MealEntry>, date: String): DayTotals {
|
||||
val rows = entries.filter { it.date == date && it.trashedAt.isBlank() }
|
||||
return DayTotals(
|
||||
date = date,
|
||||
label = date.takeLast(5),
|
||||
meals = rows.size,
|
||||
calories = rows.sumOf { it.estimate.calories },
|
||||
protein = rows.sumOf { it.estimate.proteinGrams },
|
||||
carbs = rows.sumOf { it.estimate.carbsGrams },
|
||||
fat = rows.sumOf { it.estimate.fatGrams },
|
||||
fruit = rows.sumOf { it.estimate.fruitServings },
|
||||
vegetables = rows.sumOf { it.estimate.vegetableServings }
|
||||
)
|
||||
}
|
||||
|
||||
fun lastSevenDays(entries: List<MealEntry>, today: LocalDate = LocalDate.now()): List<DayTotals> =
|
||||
(6 downTo 0).map { offset ->
|
||||
val day = today.minusDays(offset.toLong())
|
||||
totalsForDate(entries, day.toString()).copy(label = day.dayOfWeek.name.take(3))
|
||||
}
|
||||
}
|
||||
102
app/src/main/java/com/danvics/calorieai/data/Models.kt
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package com.danvics.calorieai.data
|
||||
|
||||
data class NutritionEstimate(
|
||||
val mealName: String = "Meal",
|
||||
val calories: Int = 0,
|
||||
val proteinGrams: Int = 0,
|
||||
val carbsGrams: Int = 0,
|
||||
val fatGrams: Int = 0,
|
||||
val fruitServings: Double = 0.0,
|
||||
val vegetableServings: Double = 0.0,
|
||||
val foodGroups: String = "",
|
||||
val notes: String = "",
|
||||
val raw: String = ""
|
||||
)
|
||||
|
||||
data class MealEntry(
|
||||
val id: String,
|
||||
val date: String,
|
||||
val time: String,
|
||||
val mealType: String,
|
||||
val description: String,
|
||||
val measure: String,
|
||||
val imageIncluded: Boolean,
|
||||
val imageName: String,
|
||||
val visionEstimate: String,
|
||||
val estimate: NutritionEstimate,
|
||||
val trashedAt: String = ""
|
||||
)
|
||||
|
||||
data class DayTotals(
|
||||
val date: String,
|
||||
val label: String,
|
||||
val meals: Int,
|
||||
val calories: Int,
|
||||
val protein: Int,
|
||||
val carbs: Int,
|
||||
val fat: Int,
|
||||
val fruit: Double,
|
||||
val vegetables: Double
|
||||
)
|
||||
|
||||
data class ModelOption(val id: String, val name: String = id)
|
||||
|
||||
data class ActivityRecord(
|
||||
val id: String,
|
||||
val source: String,
|
||||
val sourceName: String,
|
||||
val type: String,
|
||||
val title: String,
|
||||
val startAt: String,
|
||||
val endAt: String,
|
||||
val calories: Double = 0.0,
|
||||
val steps: Long = 0,
|
||||
val distanceMeters: Double = 0.0,
|
||||
val syncedAt: String = ""
|
||||
)
|
||||
|
||||
data class LocalConfig(val serverUrl: String = "", val sessionCookie: String = "") {
|
||||
fun isConnected() = serverUrl.isNotBlank() && sessionCookie.isNotBlank()
|
||||
}
|
||||
|
||||
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 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,
|
||||
}
|
||||
132
app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
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, 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(
|
||||
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,
|
||||
onSaveManualEdit: (MealEntry) -> Unit,
|
||||
onAnalyze: (MealEntry) -> Unit,
|
||||
onEdit: (MealEntry) -> Unit,
|
||||
onMoveToTrash: (Set<String>) -> Unit,
|
||||
onClearTrash: () -> Unit,
|
||||
onRestore: (String) -> Unit,
|
||||
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, style = MaterialTheme.typography.labelSmall) },
|
||||
icon = { Icon(item.icon, contentDescription = item.label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(Modifier.padding(padding).fillMaxSize()) {
|
||||
when (screen) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Page(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), content = content)
|
||||
}
|
||||
144
app/src/main/java/com/danvics/calorieai/ui/Components.kt
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package com.danvics.calorieai.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.danvics.calorieai.data.DayTotals
|
||||
|
||||
@Composable
|
||||
fun SectionCard(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
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.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatTile(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
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, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
102
app/src/main/java/com/danvics/calorieai/ui/DashboardScreen.kt
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package com.danvics.calorieai.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.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>, activities: List<ActivityRecord>, settings: ServerSettings, onLog: () -> Unit) = Page {
|
||||
val today = MealStats.totalsForDate(entries, LocalDate.now().toString())
|
||||
val todayActivities = activities.filter { it.startAt.take(10) == LocalDate.now().toString() }
|
||||
val activityCalories = todayActivities.sumOf { it.calories }.toInt()
|
||||
val activitySteps = todayActivities.sumOf { it.steps }
|
||||
val week = MealStats.lastSevenDays(entries)
|
||||
val avgCalories = if (week.isEmpty()) 0 else week.sumOf { it.calories } / week.size
|
||||
val macroTotal = today.protein + today.carbs + today.fat
|
||||
val calorieTarget = settings.calorieTarget.toIntOrNull() ?: 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("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("Activity", "$activityCalories kcal", Modifier.weight(1f))
|
||||
StatTile("Steps", activitySteps.toString(), Modifier.weight(1f))
|
||||
}
|
||||
|
||||
if (calorieTarget > 0) {
|
||||
SectionCard("Daily calorie goal") {
|
||||
CalorieGoalBar(today.calories, calorieTarget)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
app/src/main/java/com/danvics/calorieai/ui/DiaryScreen.kt
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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>,
|
||||
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, 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) {
|
||||
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(
|
||||
if (query.isBlank()) "No meals logged yet." else "No meals match your search.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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 ->
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
222
app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
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,
|
||||
onTakePhoto: () -> Unit,
|
||||
onAnalyze: (MealEntry) -> Unit,
|
||||
onSaveManualEdit: (MealEntry) -> Unit,
|
||||
onCancelEdit: () -> Unit
|
||||
) = Page {
|
||||
val context = LocalContext.current
|
||||
|
||||
var date by remember(editing?.id) { mutableStateOf(editing?.date ?: LocalDate.now().toString()) }
|
||||
var time by remember(editing?.id) { mutableStateOf(editing?.time ?: LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"))) }
|
||||
var mealType by remember(editing?.id) { mutableStateOf(editing?.mealType ?: "Breakfast") }
|
||||
var description by remember(editing?.id) { mutableStateOf(editing?.description ?: "") }
|
||||
var measure by remember(editing?.id) { mutableStateOf(editing?.measure ?: "") }
|
||||
var estimate by remember(editing?.id) { mutableStateOf(editing?.estimate ?: NutritionEstimate()) }
|
||||
var showImageSheet by remember { mutableStateOf(false) }
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDatePicker) {
|
||||
val cal = Calendar.getInstance()
|
||||
runCatching {
|
||||
val d = LocalDate.parse(date)
|
||||
cal.set(d.year, d.monthValue - 1, d.dayOfMonth)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
val dialog = DatePickerDialog(
|
||||
context,
|
||||
{ _, year, month, day -> date = "%04d-%02d-%02d".format(year, month + 1, day) },
|
||||
cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)
|
||||
)
|
||||
dialog.setOnDismissListener { showDatePicker = false }
|
||||
dialog.show()
|
||||
onDispose { if (dialog.isShowing) dialog.dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
if (showTimePicker) {
|
||||
val h = time.split(":").getOrNull(0)?.toIntOrNull() ?: LocalTime.now().hour
|
||||
val m = time.split(":").getOrNull(1)?.toIntOrNull() ?: LocalTime.now().minute
|
||||
DisposableEffect(Unit) {
|
||||
val dialog = TimePickerDialog(
|
||||
context,
|
||||
{ _, hour, minute -> time = "%02d:%02d".format(hour, minute) },
|
||||
h, m, true
|
||||
)
|
||||
dialog.setOnDismissListener { showTimePicker = false }
|
||||
dialog.show()
|
||||
onDispose { if (dialog.isShowing) dialog.dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
if (showImageSheet) {
|
||||
ModalBottomSheet(onDismissRequest = { showImageSheet = false }) {
|
||||
Column(Modifier.padding(bottom = 32.dp)) {
|
||||
Text(
|
||||
"Add photo",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
headlineContent = { Text("Take photo") },
|
||||
leadingContent = { Icon(Icons.Default.CameraAlt, contentDescription = null) },
|
||||
modifier = Modifier.clickable { showImageSheet = false; onTakePhoto() }
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Choose from gallery") },
|
||||
leadingContent = { Icon(Icons.Default.PhotoLibrary, contentDescription = null) },
|
||||
modifier = Modifier.clickable { showImageSheet = false; onPickImage() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard("Meal details") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
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(buildDraft(editing, date, time, mealType, description, measure, estimate)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(if (busy) "Analyzing..." else if (editing == null) "Analyze and save" else "Analyze again") }
|
||||
|
||||
if (status.isNotBlank()) {
|
||||
Text(
|
||||
status,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (status.startsWith("Analysis failed") || status.startsWith("No AI") || status.startsWith("Save failed"))
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (editing != null) {
|
||||
SectionCard("Edit estimate") {
|
||||
Field("Meal name", estimate.mealName, { estimate = estimate.copy(mealName = it) })
|
||||
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(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 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
96
app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package com.danvics.calorieai.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.danvics.calorieai.data.calculateCalorieTarget
|
||||
import com.danvics.calorieai.data.calorieTargetSummary
|
||||
import com.danvics.calorieai.data.ServerSettings
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private val SEX_OPTIONS = listOf("Female", "Male")
|
||||
private val GOAL_OPTIONS = listOf("Lose weight", "Maintain weight", "Gain weight", "Build muscle")
|
||||
private val ACTIVITY_LEVELS = listOf("Low", "Moderate", "High")
|
||||
private val PACE_OPTIONS = listOf("Gentle", "Steady", "Aggressive")
|
||||
private val MOTIVATIONS = listOf("Improve my overall health", "Feel more confident", "Increase my fitness level", "Prepare for an event", "Improve my relationship with food")
|
||||
private val CHALLENGES = listOf("Resisting cravings", "Staying motivated", "Reducing portion sizes", "Knowing what to eat", "Being too busy")
|
||||
private val TRACKING_STYLES = listOf("Photo log meals", "Log before eating", "Meal prep and plan ahead", "Track calories closely", "Build a streak")
|
||||
private val CALORIE_EXPERIENCE = listOf("New to calorie counting", "Tried before", "Experienced")
|
||||
private val FASTING_INTEREST = listOf("Interested", "Tried it before", "Not interested")
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
settings: ServerSettings,
|
||||
serverUrl: String,
|
||||
healthConnectStatus: String,
|
||||
onSettingsChange: (ServerSettings) -> Unit,
|
||||
onHealthConnectSync: () -> Unit,
|
||||
onDisconnect: () -> Unit
|
||||
) = Page {
|
||||
var draft by remember(settings) { mutableStateOf(settings) }
|
||||
var status by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(status) {
|
||||
if (status.isNotBlank()) {
|
||||
delay(3000)
|
||||
status = ""
|
||||
}
|
||||
}
|
||||
|
||||
SectionCard("Profile") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DropdownField("Sex", SEX_OPTIONS, draft.sex, { draft = draft.copy(sex = it) }, modifier = Modifier.weight(1f))
|
||||
Field("Age", draft.age, { draft = draft.copy(age = it) }, modifier = Modifier.weight(1f), placeholder = "36")
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Field("Height (cm)", draft.heightCm, { draft = draft.copy(heightCm = it) }, modifier = Modifier.weight(1f), placeholder = "175")
|
||||
Field("Weight (kg)", draft.weightKg, { draft = draft.copy(weightKg = it) }, modifier = Modifier.weight(1f), placeholder = "80")
|
||||
}
|
||||
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") }
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/danvics/calorieai/ui/Theme.kt
Normal file
|
|
@ -0,0 +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 LightColors = lightColorScheme(
|
||||
primary = Color(0xFF2563EB),
|
||||
secondary = Color(0xFF10B981),
|
||||
tertiary = Color(0xFFF59E0B),
|
||||
background = Color(0xFFF8FAFC),
|
||||
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 = 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>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package com.danvics.calorieai.ai
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
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)
|
||||
assertEquals("grain, vegetable", estimate.foodGroups)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.danvics.calorieai.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MealStatsTest {
|
||||
@Test fun totalsIgnoreTrashedMeals() {
|
||||
val entries = listOf(
|
||||
meal("1", calories = 500, protein = 30),
|
||||
meal("2", calories = 300, protein = 10, trashedAt = "2026-05-19"),
|
||||
meal("3", date = "2026-05-18", calories = 900, protein = 20)
|
||||
)
|
||||
|
||||
val totals = MealStats.totalsForDate(entries, "2026-05-19")
|
||||
|
||||
assertEquals(1, totals.meals)
|
||||
assertEquals(500, totals.calories)
|
||||
assertEquals(30, totals.protein)
|
||||
}
|
||||
|
||||
private fun meal(id: String, date: String = "2026-05-19", calories: Int, protein: Int, trashedAt: String = "") = MealEntry(
|
||||
id = id,
|
||||
date = date,
|
||||
time = "08:00",
|
||||
mealType = "Breakfast",
|
||||
description = "meal",
|
||||
measure = "one bowl",
|
||||
imageIncluded = false,
|
||||
imageName = "",
|
||||
visionEstimate = "",
|
||||
estimate = NutritionEstimate(calories = calories, proteinGrams = protein),
|
||||
trashedAt = trashedAt
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
plugins {
|
||||
id 'com.android.application' version '8.7.3' apply false
|
||||
id 'org.jetbrains.kotlin.android' version '2.0.21' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' apply false
|
||||
}
|
||||
|
|
|
|||
3
gradle.properties
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
5
web/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
data
|
||||
.env
|
||||
6
web/.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
CALORIE_AI_WEB_USER=admin
|
||||
CALORIE_AI_WEB_PASSWORD=change-this-password
|
||||
CALORIE_AI_SESSION_SECRET=change-this-session-secret
|
||||
LITELLM_API_BASE=http://litellm:4000/v1
|
||||
LITELLM_API_KEY=change-this-litellm-key
|
||||
LITELLM_MODEL=openrouter-claude-haiku-4.5
|
||||
14
web/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM node:22-alpine
|
||||
|
||||
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
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
CMD ["node", "server.js"]
|
||||
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;
|
||||
36
web/docker-compose.yml
Normal file
|
|
@ -0,0 +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:
|
||||
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
|
||||
13
web/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Calorie AI</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
3697
web/package-lock.json
generated
Normal file
29
web/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "calorie-ai-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"start": "node server.js",
|
||||
"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",
|
||||
"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",
|
||||
"pg": "^8.21.0"
|
||||
}
|
||||
}
|
||||
22
web/playwright.config.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
webServer: {
|
||||
command: 'npm run build && node server.js',
|
||||
url: 'http://127.0.0.1:8096',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
PORT: '8096',
|
||||
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: {
|
||||
baseURL: 'http://127.0.0.1:8096',
|
||||
},
|
||||
});
|
||||
5
web/public/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="16" fill="#2f7d59"/>
|
||||
<path d="M20 40c0-10 8-22 24-22 1 16-8 26-21 26-1 0-2 0-3-.2 3-6 8-11 15-15-8 2-14 6-18 13-2-4-2-9 3-14z" fill="#f8f4ec"/>
|
||||
<circle cx="42" cy="42" r="6" fill="#da9648"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 298 B |