Rewrite Android app with Kotlin Compose
This commit is contained in:
parent
5837b1c8dc
commit
87102dbc5b
21 changed files with 829 additions and 834 deletions
|
|
@ -29,8 +29,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
|
||||
- name: Build installable APK
|
||||
run: |
|
||||
gradle --no-daemon :app:assembleDebug
|
||||
gradle --no-daemon :app:testDebugUnitTest :app:assembleDebug
|
||||
mkdir -p dist
|
||||
cp app/build/outputs/apk/debug/app-debug.apk "dist/calorie-ai-${GITHUB_REF_NAME}.apk"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.compose'
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
@ -13,4 +15,34 @@ android {
|
|||
versionCode 1
|
||||
versionName '1.0'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
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.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
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,831 +0,0 @@
|
|||
package com.danvics.calorieai;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
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.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
|
||||
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("Log meals with text, portions, and uploaded images. The vision model estimates the food in the image, then the task model normalizes calories, macros, fruit, and vegetable servings.", 15, false);
|
||||
subtitle.setTextColor(Color.rgb(91, 99, 92));
|
||||
subtitle.setPadding(0, dp(6), 0, dp(18));
|
||||
root.addView(subtitle);
|
||||
|
||||
addTodaySummary();
|
||||
addAnalytics();
|
||||
addMealForm();
|
||||
addEntries();
|
||||
addAdminSettings();
|
||||
|
||||
setContentView(scrollView);
|
||||
}
|
||||
|
||||
private void addTodaySummary() {
|
||||
DayTotals today = totalsForDate(todayKey());
|
||||
LinearLayout card = card();
|
||||
card.addView(text("Today", 18, true));
|
||||
card.addView(text(today.date + " | " + today.meals + " meals", 13, false));
|
||||
|
||||
TextView totals = text(today.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 " + today.protein + "g | Carbs " + today.carbs + "g | Fat " + today.fat + "g", 14, false));
|
||||
card.addView(text("Fruit " + servings(today.fruit) + " servings | Vegetables " + servings(today.vegetables) + " servings", 14, false));
|
||||
root.addView(card);
|
||||
}
|
||||
|
||||
private void addAnalytics() {
|
||||
DayTotals today = totalsForDate(todayKey());
|
||||
DayTotals[] week = weeklyTotals();
|
||||
|
||||
LinearLayout card = card();
|
||||
card.addView(text("Charts", 20, true));
|
||||
TextView note = text("Calories, macros, and produce intake for today and the last 7 days.", 13, false);
|
||||
note.setTextColor(Color.rgb(91, 99, 92));
|
||||
card.addView(note);
|
||||
|
||||
MacroRingView macroRing = new MacroRingView(this, today.protein, today.carbs, today.fat);
|
||||
macroRing.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(180)));
|
||||
card.addView(macroRing);
|
||||
|
||||
WeeklyCaloriesView caloriesView = new WeeklyCaloriesView(this, week);
|
||||
caloriesView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(190)));
|
||||
card.addView(caloriesView);
|
||||
|
||||
ProduceView produceView = new ProduceView(this, week);
|
||||
produceView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(160)));
|
||||
card.addView(produceView);
|
||||
|
||||
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 and side salad");
|
||||
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("Upload meal 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 image/text 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("Fruit " + servings(entry.optDouble("fruitServings"))
|
||||
+ " | Vegetables " + servings(entry.optDouble("vegetableServings")), 13, false));
|
||||
if (entry.optBoolean("imageIncluded")) {
|
||||
TextView image = text("Image analyzed by vision model" + imageSuffix(entry), 12, false);
|
||||
image.setTextColor(Color.rgb(91, 99, 92));
|
||||
card.addView(image);
|
||||
}
|
||||
card.addView(text(entry.optString("description"), 13, false));
|
||||
String foodGroups = entry.optString("foodGroups");
|
||||
if (!foodGroups.isEmpty()) {
|
||||
TextView groups = text("Groups: " + foodGroups, 12, false);
|
||||
groups.setTextColor(Color.rgb(91, 99, 92));
|
||||
card.addView(groups);
|
||||
}
|
||||
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 String imageSuffix(JSONObject entry) {
|
||||
String imageName = entry.optString("imageName");
|
||||
return imageName.isEmpty() ? "" : " (" + imageName + ")";
|
||||
}
|
||||
|
||||
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 for uploaded food images");
|
||||
visionModel.setText(prefs.getString("vision_model", "gpt-4o-mini"));
|
||||
card.addView(visionModel);
|
||||
|
||||
EditText taskModel = input("Tasking model for nutrition JSON and daily tracking");
|
||||
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 estimates visible food, quantities, calories, macros, fruit servings, and vegetable servings from the image. The tasking model normalizes that estimate with your text. 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 upload 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(selectedImageUri == null ? "Analyzing meal text..." : "Uploading image to vision model...", false);
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
String visionEstimate = "";
|
||||
if (selectedImageUri != null) {
|
||||
if (selectedImageDataUri == null) selectedImageDataUri = readImageAsDataUri(selectedImageUri);
|
||||
String visionModel = prefs.getString("vision_model", "").trim();
|
||||
if (!visionModel.isEmpty()) {
|
||||
visionEstimate = requestVisionEstimate(baseUrl, visionModel, description, measure, selectedImageDataUri);
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject estimate = requestTaskEstimate(baseUrl, taskModel, description, measure, visionEstimate);
|
||||
saveEntry(description, measure, visionEstimate, estimate, selectedImageUri != null, selectedImageName);
|
||||
|
||||
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 requestVisionEstimate(String baseUrl, String model, String description, String measure, String imageDataUri) throws Exception {
|
||||
JSONArray content = new JSONArray();
|
||||
content.put(new JSONObject()
|
||||
.put("type", "text")
|
||||
.put("text", "Analyze this meal photo for nutrition tracking. Estimate visible foods, portions, calories, protein grams, carbs grams, fat grams, fruit servings, and vegetable servings. One produce serving is about 80g or one cup raw leafy vegetables. Return concise JSON if possible. User description: " + description + "; user portion note: " + measure));
|
||||
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.15);
|
||||
|
||||
return chatCompletion(baseUrl, body);
|
||||
}
|
||||
|
||||
private JSONObject requestTaskEstimate(String baseUrl, String model, String description, String measure, String visionEstimate) throws Exception {
|
||||
String prompt = "You are a nutrition logging assistant. Produce a final nutrition estimate for one meal. "
|
||||
+ "If a vision estimate is provided, use it as the primary evidence for visible ingredients and portion sizes. "
|
||||
+ "Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. "
|
||||
+ "Use integer grams and calories. fruitServings and vegetableServings can be decimal numbers. foodGroups should be a short comma-separated string. Mention uncertainty in notes.\n\n"
|
||||
+ "User description: " + description + "\n"
|
||||
+ "User measure: " + measure + "\n"
|
||||
+ "Vision nutrition estimate: " + visionEstimate;
|
||||
|
||||
JSONArray messages = new JSONArray();
|
||||
messages.put(new JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap the response in markdown."));
|
||||
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(90000);
|
||||
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("fruitServings", Math.max(0, estimate.optDouble("fruitServings", estimate.optDouble("fruitsServings", 0))))
|
||||
.put("vegetableServings", Math.max(0, estimate.optDouble("vegetableServings", estimate.optDouble("vegetablesServings", 0))))
|
||||
.put("foodGroups", stringifyFoodGroups(estimate.opt("foodGroups")))
|
||||
.put("notes", estimate.optString("notes", ""))
|
||||
.put("raw", content);
|
||||
}
|
||||
|
||||
private String stringifyFoodGroups(Object value) {
|
||||
if (value == null || value == JSONObject.NULL) return "";
|
||||
if (value instanceof JSONArray) {
|
||||
JSONArray array = (JSONArray) value;
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < array.length(); i++) {
|
||||
if (i > 0) builder.append(", ");
|
||||
builder.append(array.optString(i));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
return String.valueOf(value);
|
||||
}
|
||||
|
||||
private void saveEntry(String description, String measure, String visionEstimate, JSONObject estimate, boolean hasImage, String imageName) 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("imageName", imageName == null ? "" : imageName)
|
||||
.put("visionEstimate", visionEstimate)
|
||||
.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("fruitServings", estimate.optDouble("fruitServings"))
|
||||
.put("vegetableServings", estimate.optDouble("vegetableServings"))
|
||||
.put("foodGroups", estimate.optString("foodGroups"))
|
||||
.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 DayTotals totalsForDate(String date) {
|
||||
DayTotals totals = new DayTotals(date, date.substring(5));
|
||||
JSONArray entries = entriesForDate(date);
|
||||
totals.meals = entries.length();
|
||||
for (int i = 0; i < entries.length(); i++) {
|
||||
JSONObject entry = entries.optJSONObject(i);
|
||||
if (entry == null) continue;
|
||||
totals.calories += entry.optInt("calories");
|
||||
totals.protein += entry.optInt("proteinGrams");
|
||||
totals.carbs += entry.optInt("carbsGrams");
|
||||
totals.fat += entry.optInt("fatGrams");
|
||||
totals.fruit += entry.optDouble("fruitServings", entry.optDouble("fruitsServings", 0));
|
||||
totals.vegetables += entry.optDouble("vegetableServings", entry.optDouble("vegetablesServings", 0));
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
private DayTotals[] weeklyTotals() {
|
||||
DayTotals[] days = new DayTotals[7];
|
||||
LocalDate today = LocalDate.now();
|
||||
for (int i = 0; i < 7; i++) {
|
||||
LocalDate day = today.minusDays(6 - i);
|
||||
days[i] = totalsForDate(day.toString());
|
||||
days[i].label = day.getDayOfWeek().toString().substring(0, 3);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
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 String servings(double value) {
|
||||
return String.format(java.util.Locale.US, "%.1f", value);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static class DayTotals {
|
||||
String date;
|
||||
String label;
|
||||
int calories;
|
||||
int protein;
|
||||
int carbs;
|
||||
int fat;
|
||||
double fruit;
|
||||
double vegetables;
|
||||
int meals;
|
||||
|
||||
DayTotals(String date, String label) {
|
||||
this.date = date;
|
||||
this.label = label;
|
||||
}
|
||||
}
|
||||
|
||||
private static class MacroRingView extends View {
|
||||
private final int protein;
|
||||
private final int carbs;
|
||||
private final int fat;
|
||||
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
|
||||
MacroRingView(Context context, int protein, int carbs, int fat) {
|
||||
super(context);
|
||||
this.protein = protein;
|
||||
this.carbs = carbs;
|
||||
this.fat = fat;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
int total = Math.max(0, protein) + Math.max(0, carbs) + Math.max(0, fat);
|
||||
float left = 22 * density;
|
||||
float top = 24 * density;
|
||||
float size = Math.min(getHeight() - 42 * density, getWidth() * 0.42f);
|
||||
RectF oval = new RectF(left, top, left + size, top + size);
|
||||
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
paint.setStrokeWidth(18 * density);
|
||||
paint.setStrokeCap(Paint.Cap.ROUND);
|
||||
if (total == 0) {
|
||||
paint.setColor(Color.rgb(229, 221, 207));
|
||||
canvas.drawArc(oval, -90, 360, false, paint);
|
||||
} else {
|
||||
float start = -90;
|
||||
start = arc(canvas, oval, start, protein * 360f / total, Color.rgb(47, 125, 89));
|
||||
start = arc(canvas, oval, start, carbs * 360f / total, Color.rgb(218, 150, 72));
|
||||
arc(canvas, oval, start, fat * 360f / total, Color.rgb(97, 119, 196));
|
||||
}
|
||||
|
||||
paint.setStyle(Paint.Style.FILL);
|
||||
paint.setTextSize(15 * density);
|
||||
paint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
paint.setColor(Color.rgb(38, 55, 45));
|
||||
float x = left + size + 28 * density;
|
||||
canvas.drawText("Macros today", x, top + 18 * density, paint);
|
||||
paint.setTypeface(Typeface.DEFAULT);
|
||||
legend(canvas, x, top + 50 * density, Color.rgb(47, 125, 89), "Protein " + protein + "g");
|
||||
legend(canvas, x, top + 78 * density, Color.rgb(218, 150, 72), "Carbs " + carbs + "g");
|
||||
legend(canvas, x, top + 106 * density, Color.rgb(97, 119, 196), "Fat " + fat + "g");
|
||||
}
|
||||
|
||||
private float arc(Canvas canvas, RectF oval, float start, float sweep, int color) {
|
||||
paint.setColor(color);
|
||||
canvas.drawArc(oval, start, sweep, false, paint);
|
||||
return start + sweep;
|
||||
}
|
||||
|
||||
private void legend(Canvas canvas, float x, float y, int color, String label) {
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
paint.setStyle(Paint.Style.FILL);
|
||||
paint.setColor(color);
|
||||
canvas.drawCircle(x, y - 5 * density, 5 * density, paint);
|
||||
paint.setColor(Color.rgb(38, 55, 45));
|
||||
paint.setTextSize(13 * density);
|
||||
canvas.drawText(label, x + 14 * density, y, paint);
|
||||
}
|
||||
}
|
||||
|
||||
private static class WeeklyCaloriesView extends View {
|
||||
private final DayTotals[] days;
|
||||
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
|
||||
WeeklyCaloriesView(Context context, DayTotals[] days) {
|
||||
super(context);
|
||||
this.days = days;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
paint.setColor(Color.rgb(38, 55, 45));
|
||||
paint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
paint.setTextSize(15 * density);
|
||||
canvas.drawText("Calories by day", 8 * density, 22 * density, paint);
|
||||
|
||||
int max = 1;
|
||||
for (DayTotals day : days) max = Math.max(max, day.calories);
|
||||
float chartTop = 40 * density;
|
||||
float chartHeight = getHeight() - 80 * density;
|
||||
float barGap = 8 * density;
|
||||
float barWidth = (getWidth() - 16 * density - barGap * (days.length - 1)) / days.length;
|
||||
|
||||
paint.setTypeface(Typeface.DEFAULT);
|
||||
for (int i = 0; i < days.length; i++) {
|
||||
DayTotals day = days[i];
|
||||
float left = 8 * density + i * (barWidth + barGap);
|
||||
float height = chartHeight * day.calories / max;
|
||||
float top = chartTop + chartHeight - height;
|
||||
paint.setColor(Color.rgb(232, 238, 232));
|
||||
canvas.drawRoundRect(left, chartTop, left + barWidth, chartTop + chartHeight, 8 * density, 8 * density, paint);
|
||||
paint.setColor(Color.rgb(47, 125, 89));
|
||||
canvas.drawRoundRect(left, top, left + barWidth, chartTop + chartHeight, 8 * density, 8 * density, paint);
|
||||
paint.setColor(Color.rgb(91, 99, 92));
|
||||
paint.setTextSize(10 * density);
|
||||
canvas.drawText(day.label, left, chartTop + chartHeight + 18 * density, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class ProduceView extends View {
|
||||
private final DayTotals[] days;
|
||||
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
|
||||
ProduceView(Context context, DayTotals[] days) {
|
||||
super(context);
|
||||
this.days = days;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
paint.setColor(Color.rgb(38, 55, 45));
|
||||
paint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
paint.setTextSize(15 * density);
|
||||
canvas.drawText("Fruit and vegetables", 8 * density, 22 * density, paint);
|
||||
|
||||
double max = 1;
|
||||
for (DayTotals day : days) max = Math.max(max, day.fruit + day.vegetables);
|
||||
float chartTop = 42 * density;
|
||||
float chartHeight = getHeight() - 72 * density;
|
||||
float groupGap = 8 * density;
|
||||
float groupWidth = (getWidth() - 16 * density - groupGap * (days.length - 1)) / days.length;
|
||||
float barWidth = (groupWidth - 4 * density) / 2f;
|
||||
|
||||
paint.setTypeface(Typeface.DEFAULT);
|
||||
for (int i = 0; i < days.length; i++) {
|
||||
DayTotals day = days[i];
|
||||
float left = 8 * density + i * (groupWidth + groupGap);
|
||||
drawProduceBar(canvas, left, chartTop, chartHeight, barWidth, day.fruit, max, Color.rgb(218, 150, 72));
|
||||
drawProduceBar(canvas, left + barWidth + 4 * density, chartTop, chartHeight, barWidth, day.vegetables, max, Color.rgb(47, 125, 89));
|
||||
paint.setColor(Color.rgb(91, 99, 92));
|
||||
paint.setTextSize(10 * density);
|
||||
canvas.drawText(day.label, left, chartTop + chartHeight + 18 * density, paint);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawProduceBar(Canvas canvas, float left, float chartTop, float chartHeight, float barWidth, double value, double max, int color) {
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
float height = (float) (chartHeight * value / max);
|
||||
float top = chartTop + chartHeight - height;
|
||||
paint.setColor(Color.rgb(232, 238, 232));
|
||||
canvas.drawRoundRect(left, chartTop, left + barWidth, chartTop + chartHeight, 6 * density, 6 * density, paint);
|
||||
paint.setColor(color);
|
||||
canvas.drawRoundRect(left, top, left + barWidth, chartTop + chartHeight, 6 * density, 6 * density, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
app/src/main/java/com/danvics/calorieai/MainActivity.kt
Normal file
109
app/src/main/java/com/danvics/calorieai/MainActivity.kt
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package com.danvics.calorieai
|
||||
|
||||
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.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.danvics.calorieai.ai.AiClient
|
||||
import com.danvics.calorieai.ai.ImagePayload
|
||||
import com.danvics.calorieai.data.*
|
||||
import com.danvics.calorieai.ui.CalorieAiApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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 = AppRepository(getSharedPreferences("calorie_ai", MODE_PRIVATE))
|
||||
val ai = AiClient()
|
||||
setContent { AppRoot(repo, ai) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppRoot(repo: AppRepository, ai: AiClient) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var entries by remember { mutableStateOf(repo.entries()) }
|
||||
var trash by remember { mutableStateOf(repo.trash()) }
|
||||
var settings by remember { mutableStateOf(repo.settings()) }
|
||||
var selectedImage by remember { mutableStateOf<ImagePayload?>(null) }
|
||||
var status by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<MealEntry?>(null) }
|
||||
|
||||
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
if (uri != null) selectedImage = context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
val bytes = input.readBytes()
|
||||
ImagePayload.fromBytes(uri.lastPathSegment ?: "meal image", context.contentResolver.getType(uri) ?: "image/jpeg", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fun persistEntries(next: List<MealEntry>) { entries = next; repo.saveEntries(next) }
|
||||
fun persistTrash(next: List<MealEntry>) { trash = next; repo.saveTrash(next) }
|
||||
fun persistSettings(next: AppSettings) { settings = next; repo.saveSettings(next) }
|
||||
|
||||
CalorieAiApp(
|
||||
entries = entries,
|
||||
trash = trash,
|
||||
settings = settings,
|
||||
status = status,
|
||||
busy = busy,
|
||||
editing = editing,
|
||||
selectedImageName = selectedImage?.name.orEmpty(),
|
||||
onPickImage = { imagePicker.launch("image/*") },
|
||||
onCancelEdit = { editing = null; status = "" },
|
||||
onSaveManualEdit = { updated ->
|
||||
persistEntries(entries.map { if (it.id == updated.id) updated else it })
|
||||
editing = null
|
||||
status = "Meal updated."
|
||||
},
|
||||
onAnalyze = { draft ->
|
||||
if (!ai.isConfigured(settings)) { status = "Set API base URL and nutrition model in Settings first."; return@CalorieAiApp }
|
||||
busy = true
|
||||
status = "Analyzing meal..."
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { ai.estimateMeal(settings, draft.description, draft.measure, selectedImage) }
|
||||
}.onSuccess { estimate ->
|
||||
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(),
|
||||
estimate = estimate
|
||||
)
|
||||
persistEntries(if (editing == null) entries + meal else entries.map { if (it.id == meal.id) meal else it })
|
||||
editing = meal
|
||||
status = "Saved. You can edit values or analyze again."
|
||||
}.onFailure { status = "Analysis failed: ${it.message}" }
|
||||
busy = false
|
||||
}
|
||||
},
|
||||
onEdit = { editing = it },
|
||||
onMoveToTrash = { ids ->
|
||||
val moving = entries.filter { it.id in ids }.map { it.copy(trashedAt = LocalDate.now().toString()) }
|
||||
persistEntries(entries.filterNot { it.id in ids })
|
||||
persistTrash(moving + trash)
|
||||
},
|
||||
onRestore = { id ->
|
||||
trash.find { it.id == id }?.let { found ->
|
||||
persistTrash(trash.filterNot { it.id == id })
|
||||
persistEntries(listOf(found.copy(trashedAt = "")) + entries)
|
||||
}
|
||||
},
|
||||
onSettingsChange = ::persistSettings,
|
||||
onSearchModels = { query -> withContext(Dispatchers.IO) { ai.searchModels(settings, query) } },
|
||||
onGeneratePlan = { withContext(Dispatchers.IO) { ai.weightPlan(settings) } }
|
||||
)
|
||||
}
|
||||
96
app/src/main/java/com/danvics/calorieai/ai/AiClient.kt
Normal file
96
app/src/main/java/com/danvics/calorieai/ai/AiClient.kt
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package com.danvics.calorieai.ai
|
||||
|
||||
import android.util.Base64
|
||||
import com.danvics.calorieai.data.AppSettings
|
||||
import com.danvics.calorieai.data.ModelOption
|
||||
import com.danvics.calorieai.data.NutritionEstimate
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class AiClient(private val parser: NutritionParser = NutritionParser()) {
|
||||
fun isConfigured(settings: AppSettings) = settings.apiBaseUrl.isNotBlank() && settings.nutritionModel.isNotBlank()
|
||||
|
||||
fun estimateMeal(settings: AppSettings, description: String, measure: String, image: ImagePayload?): NutritionEstimate {
|
||||
val vision = if (image != null && settings.visionModel.isNotBlank()) visionEstimate(settings, description, measure, image) else ""
|
||||
val prompt = "Estimate nutrition for one meal. Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\n\nDescription: $description\nPortion: $measure\nImage estimate: $vision"
|
||||
val body = JSONObject()
|
||||
.put("model", settings.nutritionModel)
|
||||
.put("temperature", 0.1)
|
||||
.put("messages", JSONArray()
|
||||
.put(JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap the response in markdown."))
|
||||
.put(JSONObject().put("role", "user").put("content", prompt)))
|
||||
return parser.parse(chat(settings, body))
|
||||
}
|
||||
|
||||
fun weightPlan(settings: AppSettings): String {
|
||||
val prompt = "Create a safe personalized weight-loss plan. Height ${settings.heightCm} cm, current weight ${settings.weightKg} kg, target ${settings.targetWeightKg.ifBlank { "not set" }} kg, activity ${settings.activityLevel}, pace ${settings.pace}. Include daily calorie range, protein target, habits, and safety notes."
|
||||
val body = JSONObject()
|
||||
.put("model", settings.nutritionModel)
|
||||
.put("temperature", 0.2)
|
||||
.put("messages", JSONArray()
|
||||
.put(JSONObject().put("role", "system").put("content", "Give concise nutrition guidance. Do not diagnose disease. Recommend medical review for pregnancy, eating disorder history, chronic disease, or aggressive goals."))
|
||||
.put(JSONObject().put("role", "user").put("content", prompt)))
|
||||
return chat(settings, body)
|
||||
}
|
||||
|
||||
fun searchModels(settings: AppSettings, query: String): List<ModelOption> {
|
||||
val base = settings.apiBaseUrl.trimEnd('/').removeSuffix("/v1")
|
||||
val response = runCatching { request(settings, "$base/model/info", null, "GET") }.getOrElse {
|
||||
request(settings, settings.apiBaseUrl.trimEnd('/') + "/models", null, "GET")
|
||||
}
|
||||
val root = JSONObject(response)
|
||||
val rows = root.optJSONArray("data") ?: JSONArray()
|
||||
val q = query.trim().lowercase()
|
||||
return (0 until rows.length()).mapNotNull { rows.optJSONObject(it) }.mapNotNull { item ->
|
||||
val id = item.optString("model_name", item.optString("id"))
|
||||
if (id.isBlank()) null else ModelOption(id, id)
|
||||
}.distinctBy { it.id }.filter { q.isBlank() || it.id.lowercase().contains(q) }.take(100)
|
||||
}
|
||||
|
||||
private fun visionEstimate(settings: AppSettings, description: String, measure: String, image: ImagePayload): String {
|
||||
val content = JSONArray()
|
||||
.put(JSONObject().put("type", "text").put("text", "Analyze this meal photo for nutrition tracking. Estimate foods, portions, calories, protein grams, carbs grams, fat grams, fruit servings, and vegetable servings. Description: $description; portion: $measure"))
|
||||
.put(JSONObject().put("type", "image_url").put("image_url", JSONObject().put("url", image.dataUri)))
|
||||
val body = JSONObject().put("model", settings.visionModel).put("temperature", 0.15)
|
||||
.put("messages", JSONArray().put(JSONObject().put("role", "user").put("content", content)))
|
||||
return chat(settings, body)
|
||||
}
|
||||
|
||||
private fun chat(settings: AppSettings, body: JSONObject): String {
|
||||
val response = request(settings, settings.apiBaseUrl.trimEnd('/') + "/chat/completions", body.toString(), "POST")
|
||||
return JSONObject(response).getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content").trim()
|
||||
}
|
||||
|
||||
private fun request(settings: AppSettings, url: String, body: String?, method: String): String {
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 90_000
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
if (settings.apiKey.isNotBlank()) setRequestProperty("Authorization", "Bearer ${settings.apiKey}")
|
||||
}
|
||||
if (body != null) {
|
||||
connection.doOutput = true
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
}
|
||||
val code = connection.responseCode
|
||||
val text = (if (code in 200..299) connection.inputStream else connection.errorStream).readTextSafely()
|
||||
if (code !in 200..299) error("HTTP $code: $text")
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
data class ImagePayload(val name: String, val dataUri: String) {
|
||||
companion object {
|
||||
fun fromBytes(name: String, mimeType: String, bytes: ByteArray) = ImagePayload(
|
||||
name = name,
|
||||
dataUri = "data:$mimeType;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun InputStream?.readTextSafely(): String = this?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } ?: ""
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
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()
|
||||
.removePrefix("```json")
|
||||
.removePrefix("```")
|
||||
.removeSuffix("```")
|
||||
.trim()
|
||||
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", json.optDouble("fruitsServings", 0.0)).coerceAtLeast(0.0),
|
||||
vegetableServings = json.optDouble("vegetableServings", json.optDouble("vegetablesServings", 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package com.danvics.calorieai.data
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class AppRepository(private val prefs: SharedPreferences) {
|
||||
fun entries(): List<MealEntry> = readEntries("entries")
|
||||
fun trash(): List<MealEntry> = readEntries("trash")
|
||||
|
||||
fun saveEntries(entries: List<MealEntry>) = writeEntries("entries", entries)
|
||||
fun saveTrash(entries: List<MealEntry>) = writeEntries("trash", entries)
|
||||
|
||||
fun settings(): AppSettings = AppSettings(
|
||||
apiBaseUrl = prefs.getString("api_base_url", "") ?: "",
|
||||
apiKey = prefs.getString("api_key", "") ?: "",
|
||||
visionModel = prefs.getString("vision_model", "gpt-4o-mini") ?: "gpt-4o-mini",
|
||||
nutritionModel = prefs.getString("nutrition_model", prefs.getString("task_model", "gpt-4o-mini")) ?: "gpt-4o-mini",
|
||||
heightCm = prefs.getString("height_cm", "") ?: "",
|
||||
weightKg = prefs.getString("weight_kg", "") ?: "",
|
||||
targetWeightKg = prefs.getString("target_weight_kg", "") ?: "",
|
||||
activityLevel = prefs.getString("activity_level", "Moderate") ?: "Moderate",
|
||||
pace = prefs.getString("pace", "Steady") ?: "Steady",
|
||||
models = readModels()
|
||||
)
|
||||
|
||||
fun saveSettings(settings: AppSettings) {
|
||||
prefs.edit()
|
||||
.putString("api_base_url", settings.apiBaseUrl)
|
||||
.putString("api_key", settings.apiKey)
|
||||
.putString("vision_model", settings.visionModel)
|
||||
.putString("nutrition_model", settings.nutritionModel)
|
||||
.putString("height_cm", settings.heightCm)
|
||||
.putString("weight_kg", settings.weightKg)
|
||||
.putString("target_weight_kg", settings.targetWeightKg)
|
||||
.putString("activity_level", settings.activityLevel)
|
||||
.putString("pace", settings.pace)
|
||||
.putString("models", JSONArray(settings.models.map { JSONObject().put("id", it.id).put("name", it.name) }).toString())
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun readEntries(key: String): List<MealEntry> = runCatching {
|
||||
val array = JSONArray(prefs.getString(key, "[]"))
|
||||
(0 until array.length()).mapNotNull { index -> array.optJSONObject(index)?.toMealEntry() }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun writeEntries(key: String, entries: List<MealEntry>) {
|
||||
prefs.edit().putString(key, JSONArray(entries.map { it.toJson() }).toString()).apply()
|
||||
}
|
||||
|
||||
private fun readModels(): List<ModelOption> = runCatching {
|
||||
val array = JSONArray(prefs.getString("models", "[]"))
|
||||
(0 until array.length()).mapNotNull { array.optJSONObject(it) }.map { ModelOption(it.optString("id"), it.optString("name", it.optString("id"))) }
|
||||
}.getOrDefault(emptyList()).ifEmpty { listOf(ModelOption("gpt-4o-mini")) }
|
||||
}
|
||||
|
||||
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"), 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")
|
||||
)
|
||||
)
|
||||
26
app/src/main/java/com/danvics/calorieai/data/MealStats.kt
Normal file
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))
|
||||
}
|
||||
}
|
||||
55
app/src/main/java/com/danvics/calorieai/data/Models.kt
Normal file
55
app/src/main/java/com/danvics/calorieai/data/Models.kt
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
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 AppSettings(
|
||||
val apiBaseUrl: String = "",
|
||||
val apiKey: String = "",
|
||||
val visionModel: String = "gpt-4o-mini",
|
||||
val nutritionModel: String = "gpt-4o-mini",
|
||||
val heightCm: String = "",
|
||||
val weightKg: String = "",
|
||||
val targetWeightKg: String = "",
|
||||
val activityLevel: String = "Moderate",
|
||||
val pace: String = "Steady",
|
||||
val models: List<ModelOption> = listOf(ModelOption("gpt-4o-mini"))
|
||||
)
|
||||
59
app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt
Normal file
59
app/src/main/java/com/danvics/calorieai/ui/CalorieAiApp.kt
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package com.danvics.calorieai.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.danvics.calorieai.data.*
|
||||
|
||||
private enum class Screen(val label: String) { Dashboard("Dashboard"), Log("Log"), Diary("Diary"), Trash("Trash"), Settings("Settings") }
|
||||
|
||||
@Composable
|
||||
fun CalorieAiApp(
|
||||
entries: List<MealEntry>,
|
||||
trash: List<MealEntry>,
|
||||
settings: AppSettings,
|
||||
status: String,
|
||||
busy: Boolean,
|
||||
editing: MealEntry?,
|
||||
selectedImageName: String,
|
||||
onPickImage: () -> Unit,
|
||||
onCancelEdit: () -> Unit,
|
||||
onSaveManualEdit: (MealEntry) -> Unit,
|
||||
onAnalyze: (MealEntry) -> Unit,
|
||||
onEdit: (MealEntry) -> Unit,
|
||||
onMoveToTrash: (Set<String>) -> Unit,
|
||||
onRestore: (String) -> Unit,
|
||||
onSettingsChange: (AppSettings) -> Unit,
|
||||
onSearchModels: suspend (String) -> List<ModelOption>,
|
||||
onGeneratePlan: suspend () -> String
|
||||
) {
|
||||
CalorieTheme {
|
||||
var screen by remember { mutableStateOf(Screen.Dashboard) }
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar { Screen.entries.forEach { item -> NavigationBarItem(selected = screen == item, onClick = { screen = item }, label = { Text(item.label) }, icon = {}) } }
|
||||
}
|
||||
) { padding ->
|
||||
Box(Modifier.padding(padding).fillMaxSize()) {
|
||||
when (screen) {
|
||||
Screen.Dashboard -> DashboardScreen(entries, onLog = { screen = Screen.Log }, onSettings = { screen = Screen.Settings })
|
||||
Screen.Log -> LogMealScreen(editing, selectedImageName, status, busy, onPickImage, onAnalyze, onSaveManualEdit, onCancelEdit)
|
||||
Screen.Diary -> DiaryScreen(entries, onEdit = { onEdit(it); screen = Screen.Log }, onMoveToTrash = onMoveToTrash)
|
||||
Screen.Trash -> TrashScreen(trash, onRestore)
|
||||
Screen.Settings -> SettingsScreen(settings, onSettingsChange, onSearchModels, onGeneratePlan)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Page(content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), content = content)
|
||||
}
|
||||
|
||||
private fun blankMeal() = MealEntry("", "", "", "Breakfast", "", "", false, "", "", NutritionEstimate())
|
||||
39
app/src/main/java/com/danvics/calorieai/ui/Components.kt
Normal file
39
app/src/main/java/com/danvics/calorieai/ui/Components.kt
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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
|
||||
|
||||
@Composable
|
||||
fun SectionCard(title: String, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatTile(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(value, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Black)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Field(label: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, singleLine: Boolean = true) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
singleLine = singleLine,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
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.MealEntry
|
||||
import com.danvics.calorieai.data.MealStats
|
||||
import java.time.LocalDate
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen(entries: List<MealEntry>, onLog: () -> Unit, onSettings: () -> Unit) = Page {
|
||||
val today = MealStats.totalsForDate(entries, LocalDate.now().toString())
|
||||
Text("Calorie AI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Black)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
StatTile("Today", "${today.calories} kcal", Modifier.weight(1f))
|
||||
StatTile("Meals", today.meals.toString(), Modifier.weight(1f))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
StatTile("Protein", "${today.protein}g", Modifier.weight(1f))
|
||||
StatTile("Produce", "%.1f".format(today.fruit + today.vegetables), Modifier.weight(1f))
|
||||
}
|
||||
SectionCard("Quick actions") {
|
||||
Button(onClick = onLog, modifier = Modifier.fillMaxWidth()) { Text("Log a meal") }
|
||||
OutlinedButton(onClick = onSettings, modifier = Modifier.fillMaxWidth()) { Text("Settings") }
|
||||
}
|
||||
SectionCard("Last 7 days") {
|
||||
MealStats.lastSevenDays(entries).forEach { day ->
|
||||
Text("${day.label}: ${day.calories} kcal, produce ${"%.1f".format(day.fruit + day.vegetables)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/src/main/java/com/danvics/calorieai/ui/DiaryScreen.kt
Normal file
53
app/src/main/java/com/danvics/calorieai/ui/DiaryScreen.kt
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package com.danvics.calorieai.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.danvics.calorieai.data.MealEntry
|
||||
|
||||
@Composable
|
||||
fun DiaryScreen(entries: List<MealEntry>, onEdit: (MealEntry) -> Unit, onMoveToTrash: (Set<String>) -> Unit) = Page {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var selected by remember { mutableStateOf(setOf<String>()) }
|
||||
val rows = entries.filter { query.isBlank() || listOf(it.description, it.estimate.mealName, it.estimate.foodGroups).any { value -> value.contains(query, true) } }
|
||||
Text("Diary", style = MaterialTheme.typography.headlineMedium)
|
||||
Field("Search meals", query, { query = it })
|
||||
Button(enabled = selected.isNotEmpty(), onClick = { onMoveToTrash(selected); selected = emptySet() }, modifier = Modifier.fillMaxWidth()) { Text("Move selected to trash") }
|
||||
rows.groupBy { it.date }.toSortedMap(reverseOrder()).forEach { (date, dayRows) ->
|
||||
SectionCard(date) {
|
||||
OutlinedButton(onClick = { onMoveToTrash(dayRows.map { it.id }.toSet()) }, modifier = Modifier.fillMaxWidth()) { Text("Move day to trash") }
|
||||
dayRows.forEach { meal ->
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Checkbox(checked = meal.id in selected, onCheckedChange = { checked -> selected = if (checked) selected + meal.id else selected - meal.id })
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(meal.estimate.mealName, fontWeight = FontWeight.Bold)
|
||||
Text("${meal.time} · ${meal.mealType} · ${meal.estimate.calories} kcal")
|
||||
}
|
||||
}
|
||||
Text("P ${meal.estimate.proteinGrams}g · C ${meal.estimate.carbsGrams}g · F ${meal.estimate.fatGrams}g")
|
||||
if (meal.description.isNotBlank()) Text(meal.description)
|
||||
OutlinedButton(onClick = { onEdit(meal) }) { Text("Edit") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rows.isEmpty()) Text("No meals match the current filters.")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TrashScreen(trash: List<MealEntry>, onRestore: (String) -> Unit) = Page {
|
||||
Text("Trash", style = MaterialTheme.typography.headlineMedium)
|
||||
if (trash.isEmpty()) Text("Trash is empty.")
|
||||
trash.forEach { meal ->
|
||||
SectionCard(meal.estimate.mealName) {
|
||||
Text("${meal.date} ${meal.time} · ${meal.estimate.calories} kcal")
|
||||
OutlinedButton(onClick = { onRestore(meal.id) }) { Text("Restore") }
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt
Normal file
67
app/src/main/java/com/danvics/calorieai/ui/LogMealScreen.kt
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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.MealEntry
|
||||
import com.danvics.calorieai.data.NutritionEstimate
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun LogMealScreen(
|
||||
editing: MealEntry?,
|
||||
selectedImageName: String,
|
||||
status: String,
|
||||
busy: Boolean,
|
||||
onPickImage: () -> Unit,
|
||||
onAnalyze: (MealEntry) -> Unit,
|
||||
onSaveManualEdit: (MealEntry) -> Unit,
|
||||
onCancelEdit: () -> Unit
|
||||
) = Page {
|
||||
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()) }
|
||||
|
||||
Text(if (editing == null) "Log meal" else "Edit meal", style = MaterialTheme.typography.headlineMedium)
|
||||
SectionCard("Meal details") {
|
||||
Field("Date", date, { date = it })
|
||||
Field("Time", time, { time = it })
|
||||
Field("Meal type", mealType, { mealType = it })
|
||||
Field("Description", description, { description = it }, singleLine = false)
|
||||
Field("Portion or measure", measure, { measure = it })
|
||||
OutlinedButton(onClick = onPickImage, modifier = Modifier.fillMaxWidth()) { Text(if (selectedImageName.isBlank()) "Add image or camera photo" else selectedImageName) }
|
||||
Button(
|
||||
enabled = !busy && (description.isNotBlank() || selectedImageName.isNotBlank()),
|
||||
onClick = { onAnalyze(draft(editing, date, time, mealType, description, measure, estimate)) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text(if (busy) "Analyzing..." else if (editing == null) "Analyze and save" else "Analyze again") }
|
||||
if (status.isNotBlank()) Text(status, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
if (editing != null) {
|
||||
SectionCard("Editable estimate") {
|
||||
Field("Meal name", estimate.mealName, { estimate = estimate.copy(mealName = it) })
|
||||
Field("Calories", estimate.calories.toString(), { estimate = estimate.copy(calories = it.toIntOrNull() ?: 0) })
|
||||
Field("Protein grams", estimate.proteinGrams.toString(), { estimate = estimate.copy(proteinGrams = it.toIntOrNull() ?: 0) })
|
||||
Field("Carbs grams", estimate.carbsGrams.toString(), { estimate = estimate.copy(carbsGrams = it.toIntOrNull() ?: 0) })
|
||||
Field("Fat grams", estimate.fatGrams.toString(), { estimate = estimate.copy(fatGrams = it.toIntOrNull() ?: 0) })
|
||||
Field("Fruit servings", estimate.fruitServings.toString(), { estimate = estimate.copy(fruitServings = it.toDoubleOrNull() ?: 0.0) })
|
||||
Field("Vegetable servings", estimate.vegetableServings.toString(), { estimate = estimate.copy(vegetableServings = it.toDoubleOrNull() ?: 0.0) })
|
||||
Field("Food groups", estimate.foodGroups, { estimate = estimate.copy(foodGroups = it) })
|
||||
Field("Notes", estimate.notes, { estimate = estimate.copy(notes = it) }, singleLine = false)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { onSaveManualEdit(draft(editing, date, time, mealType, description, measure, estimate)) }) { Text("Save edits") }
|
||||
OutlinedButton(onClick = onCancelEdit) { Text("Cancel") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun draft(editing: MealEntry?, date: String, time: String, mealType: String, description: String, measure: String, estimate: NutritionEstimate) =
|
||||
MealEntry(editing?.id.orEmpty(), date, time, mealType, description, measure, editing?.imageIncluded ?: false, editing?.imageName.orEmpty(), editing?.visionEstimate.orEmpty(), estimate)
|
||||
53
app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt
Normal file
53
app/src/main/java/com/danvics/calorieai/ui/SettingsScreen.kt
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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.AppSettings
|
||||
import com.danvics.calorieai.data.ModelOption
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
settings: AppSettings,
|
||||
onSettingsChange: (AppSettings) -> Unit,
|
||||
onSearchModels: suspend (String) -> List<ModelOption>,
|
||||
onGeneratePlan: suspend () -> String
|
||||
) = Page {
|
||||
val scope = rememberCoroutineScope()
|
||||
var draft by remember(settings) { mutableStateOf(settings) }
|
||||
var modelQuery by remember { mutableStateOf("") }
|
||||
var discovered by remember { mutableStateOf(emptyList<ModelOption>()) }
|
||||
var plan by remember { mutableStateOf("") }
|
||||
var status by remember { mutableStateOf("") }
|
||||
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium)
|
||||
SectionCard("AI connection") {
|
||||
Field("API base URL", draft.apiBaseUrl, { draft = draft.copy(apiBaseUrl = it) })
|
||||
Field("API key", draft.apiKey, { draft = draft.copy(apiKey = it) })
|
||||
Field("Image model", draft.visionModel, { draft = draft.copy(visionModel = it) })
|
||||
Field("Nutrition and advice model", draft.nutritionModel, { draft = draft.copy(nutritionModel = it) })
|
||||
Button(onClick = { onSettingsChange(draft); status = "Settings saved." }, modifier = Modifier.fillMaxWidth()) { Text("Save settings") }
|
||||
if (status.isNotBlank()) Text(status)
|
||||
}
|
||||
SectionCard("Model search") {
|
||||
Field("Search current provider models", modelQuery, { modelQuery = it })
|
||||
Button(onClick = { scope.launch { discovered = onSearchModels(modelQuery) } }, modifier = Modifier.fillMaxWidth()) { Text("Search") }
|
||||
Column(Modifier.heightIn(max = 260.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
discovered.forEach { model ->
|
||||
OutlinedButton(onClick = { draft = draft.copy(models = (draft.models + model).distinctBy { it.id }, nutritionModel = model.id) }, modifier = Modifier.fillMaxWidth()) { Text("+ ${model.name}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionCard("Weight-loss plan") {
|
||||
Field("Height cm", draft.heightCm, { draft = draft.copy(heightCm = it) })
|
||||
Field("Current weight kg", draft.weightKg, { draft = draft.copy(weightKg = it) })
|
||||
Field("Target weight kg", draft.targetWeightKg, { draft = draft.copy(targetWeightKg = it) })
|
||||
Field("Activity", draft.activityLevel, { draft = draft.copy(activityLevel = it) })
|
||||
Field("Pace", draft.pace, { draft = draft.copy(pace = it) })
|
||||
Button(onClick = { onSettingsChange(draft); scope.launch { plan = onGeneratePlan() } }, modifier = Modifier.fillMaxWidth()) { Text("Generate plan") }
|
||||
if (plan.isNotBlank()) Text(plan)
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/com/danvics/calorieai/ui/Theme.kt
Normal file
19
app/src/main/java/com/danvics/calorieai/ui/Theme.kt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package com.danvics.calorieai.ui
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val Colors = lightColorScheme(
|
||||
primary = Color(0xFF2563EB),
|
||||
secondary = Color(0xFF10B981),
|
||||
tertiary = Color(0xFFF59E0B),
|
||||
background = Color(0xFFF8FAFC),
|
||||
surface = Color.White
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CalorieTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = Colors, content = content)
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.danvics.calorieai.ai
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class NutritionParserTest {
|
||||
private val parser = NutritionParser()
|
||||
|
||||
@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 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
3
gradle.properties
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
Loading…
Reference in a new issue