Add Calorie AI Android app
This commit is contained in:
commit
5cdc1c1142
10 changed files with 728 additions and 0 deletions
51
.forgejo/workflows/android.yml
Normal file
51
.forgejo/workflows/android.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Android CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
GRADLE_VERSION: 8.10.2
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates curl unzip openjdk-17-jdk
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o /tmp/android-tools.zip
|
||||
unzip -q /tmp/android-tools.zip -d /tmp/android-tools
|
||||
mv /tmp/android-tools/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||
"platform-tools" \
|
||||
"platforms;android-35" \
|
||||
"build-tools;35.0.0"
|
||||
|
||||
- name: Install Gradle
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Build debug APK
|
||||
run: gradle --no-daemon :app:assembleDebug
|
||||
|
||||
- name: Upload debug APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: calorie-ai-debug-apk
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
*.iml
|
||||
.idea/
|
||||
37
README.md
Normal file
37
README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Calorie AI Android
|
||||
|
||||
Native Android calorie tracker with optional meal images and admin-configurable AI models.
|
||||
|
||||
## Features
|
||||
|
||||
- Add meals by description, portion estimate, and optional 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.
|
||||
|
||||
## Build
|
||||
|
||||
Open this folder in Android Studio and run the `app` configuration, or build with Gradle if available:
|
||||
|
||||
```bash
|
||||
gradle :app:assembleDebug
|
||||
```
|
||||
|
||||
Forgejo Actions builds the debug APK on every push to `main` and uploads it as the `calorie-ai-debug-apk` artifact.
|
||||
|
||||
## AI Endpoint
|
||||
|
||||
The app expects an OpenAI-compatible endpoint at:
|
||||
|
||||
```text
|
||||
{API base URL}/chat/completions
|
||||
```
|
||||
|
||||
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.
|
||||
16
app/build.gradle
Normal file
16
app/build.gradle
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.danvics.calorieai'
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'com.danvics.calorieai'
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 1
|
||||
versionName '1.0'
|
||||
}
|
||||
}
|
||||
18
app/src/main/AndroidManifest.xml
Normal file
18
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="Calorie AI"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
569
app/src/main/java/com/danvics/calorieai/MainActivity.java
Normal file
569
app/src/main/java/com/danvics/calorieai/MainActivity.java
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/values/styles.xml
Normal file
10
app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?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>
|
||||
</style>
|
||||
</resources>
|
||||
3
build.gradle
Normal file
3
build.gradle
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id 'com.android.application' version '8.7.3' apply false
|
||||
}
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
18
settings.gradle
Normal file
18
settings.gradle
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'Calorie AI'
|
||||
include ':app'
|
||||
Loading…
Reference in a new issue