commit 5cdc1c1142910c784fede6974c175f7011844ae7 Author: Daniel Date: Sun May 17 17:43:16 2026 +0200 Add Calorie AI Android app diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml new file mode 100644 index 0000000..0bf0bee --- /dev/null +++ b/.forgejo/workflows/android.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9ebd5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +app/build/ +local.properties +*.iml +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..adf8400 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..528c031 --- /dev/null +++ b/app/build.gradle @@ -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' + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..da1c1ca --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/app/src/main/java/com/danvics/calorieai/MainActivity.java b/app/src/main/java/com/danvics/calorieai/MainActivity.java new file mode 100644 index 0000000..d01d1d9 --- /dev/null +++ b/app/src/main/java/com/danvics/calorieai/MainActivity.java @@ -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); + } +} diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..f3bbbfa --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..5b7ccba --- /dev/null +++ b/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'com.android.application' version '8.7.3' apply false +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..54bc2d6 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..359885c --- /dev/null +++ b/settings.gradle @@ -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'