--- /dev/null
+---
+Checks: '-*,readability-*,performance-*,bugprone-*,modernize-*,-modernize-use-trailing-return-type,-readability-identifier-length,-readability-magic-numbers,-readability-braces-around-statements,-bugprone-easily-swappable-parameters,-readability-convert-member-functions-to-static,-readability-redundant-access-specifiers,-readability-redundant-casting'
+WarningsAsErrors: ''
+HeaderFilterRegex: 'src/.*'
+# Ignore specific Qt/moc artifacts if they leak in
+CheckOptions:
+ - key: readability-identifier-naming.ClassCase
+ value: CamelCase
--- /dev/null
+CompileFlags:
+ Remove:
+ - -mno-direct-extern-access
--- /dev/null
+name: Arch Linux
+
+on:
+ push:
+ branches: [main, master, dev]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ build:
+ name: "Arch Linux"
+ runs-on: ubuntu-latest
+ container:
+ image: archlinux:latest
+
+ steps:
+ - name: install dependencies
+ run: pacman -Syyu --noconfirm git cmake base-devel unbound boost qrencode qt6-base qt6-svg qt6-websockets qt6-wayland qt6-multimedia libzip hidapi protobuf zxing-cpp
+
+ - name: configure git
+ run: git config --global --add safe.directory '*'
+
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: build
+ run: |
+ cmake -S . -B build
+ cmake --build build -j $(nproc)
--- /dev/null
+name: Full CI
+
+on:
+ push:
+ branches: [main, master, dev]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ full-check:
+ name: Full CI Check
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Install Dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y git cmake build-essential ccache \
+ qtbase5-dev libqt5sql5-sqlite libgl1-mesa-dev \
+ clang-format cppcheck clang-tidy
+
+ - name: Check Formatting
+ run: |
+ make format
+ git diff
+ git diff --stat
+ git diff --quiet --exit-code
+
+ - name: Lint
+ run: make lint
+
+ - name: Build Release
+ run: make release
+
+ - name: Test
+ run: make test
--- /dev/null
+name: macOS
+
+on:
+ push:
+ branches: [main, master, dev]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ build:
+ name: Build
+ runs-on: macos-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Install Qt
+ uses: jurplel/install-qt-action@v3
+ with:
+ version: "6.5.0"
+ host: "mac"
+
+ - name: Build Release
+ run: make release
+
+ - name: Test
+ run: make test
+
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: nutra-macos
+ path: build/nutra.app
--- /dev/null
+---
+name: Ubuntu 20.04
+
+on:
+ push:
+ branches: [main, master, dev]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ build:
+ name: "Ubuntu 20.04 (Qt 5.12)"
+ runs-on: ubuntu-24.04
+ container:
+ image: ubuntu:20.04
+ env:
+ DEBIAN_FRONTEND: noninteractive
+
+ steps:
+ - name: update apt
+ run: apt update
+
+ - name: install dependencies
+ run: |
+ apt -y install git cmake build-essential ccache \
+ qtbase5-dev libqt5sql5-sqlite libgl1-mesa-dev
+
+ - name: configure git
+ run: git config --global --add safe.directory '*'
+
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Build Release
+ run: make release
+
+ - name: Test
+ run: make test
+
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: nutra-ubuntu-20.04
+ path: build/nutra
--- /dev/null
+---
+name: Ubuntu 22.04
+
+on:
+ push:
+ branches: [main, master, dev]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ build:
+ name: "Ubuntu 22.04 (Qt 5.15)"
+ runs-on: [self-hosted, ubuntu-22.04]
+
+ steps:
+ # NOTE: Dependencies are already installed on the dev runner
+ # - name: update apt
+ # run: sudo apt-get update
+
+ # - name: install dependencies
+ # run: |
+ # sudo apt-get install -y git cmake build-essential ccache \
+ # qtbase5-dev libqt5sql5-sqlite libgl1-mesa-dev
+
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Build Release
+ run: make release
+
+ - name: Test
+ run: make test
+
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: nutra-ubuntu-22.04
+ path: build/nutra
--- /dev/null
+---
+name: Ubuntu 24.04
+
+on:
+ push:
+ branches: [main, master, dev]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ build:
+ name: "Ubuntu 24.04 (Qt 6)"
+ runs-on: [self-hosted, ubuntu-24.04]
+
+ steps:
+ # NOTE: Dependencies are already installed on the dev runner
+ # - name: update apt
+ # run: sudo apt-get update
+
+ # - name: install dependencies
+ # run: |
+ # sudo apt-get install -y git cmake build-essential ccache \
+ # qtbase5-dev libqt5sql5-sqlite libgl1-mesa-dev
+
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Build Release
+ run: make release
+
+ - name: Test
+ run: make test
+
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: nutra-ubuntu-24.04
+ path: build/nutra
--- /dev/null
+name: Windows
+
+on:
+ push:
+ branches: [main, master, dev]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ build:
+ name: Build
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Install Qt
+ uses: jurplel/install-qt-action@v3
+ with:
+ version: "6.5.0"
+ host: "windows"
+
+ - name: Build
+ run: make release
+
+ - name: Test
+ run: make test
+
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: nutra-win64
+ path: build/Release/nutra.exe
--- /dev/null
+# macOS/backup files
+.~*
+._*
+~$*
+.DS_Store
+*.swp
+
+# C++ stuff
+build/
+
--- /dev/null
+[submodule "usdasqlite"]
+ path = usdasqlite
+ url = https://github.com/nutratech/usda-sqlite.git
--- /dev/null
+cmake_minimum_required(VERSION 3.16)
+
+project(nutra LANGUAGES CXX)
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+set(CMAKE_AUTOMOC ON)
+set(CMAKE_AUTORCC ON)
+set(CMAKE_AUTOUIC ON)
+
+# Find Qt6 or Qt5
+find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets Sql)
+find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets Sql)
+
+set(PROJECT_SOURCES
+ src/main.cpp
+ src/mainwindow.cpp
+ include/mainwindow.h
+ src/db/databasemanager.cpp
+ include/db/databasemanager.h
+ src/db/foodrepository.cpp
+ include/db/foodrepository.h
+ src/widgets/searchwidget.cpp
+ include/widgets/searchwidget.h
+ src/widgets/detailswidget.cpp
+ include/widgets/detailswidget.h
+ src/widgets/mealwidget.cpp
+ include/widgets/mealwidget.h
+ src/utils/string_utils.cpp
+ include/utils/string_utils.h
+ resources.qrc
+)
+
+
+
+
+
+
+add_executable(nutra
+ ${PROJECT_SOURCES}
+)
+
+target_include_directories(nutra PRIVATE ${CMAKE_SOURCE_DIR}/include)
+
+target_link_libraries(nutra PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Sql)
+
+enable_testing()
+find_package(Qt${QT_VERSION_MAJOR}Test REQUIRED)
+
+add_executable(test_nutra EXCLUDE_FROM_ALL tests/test_foodrepository.cpp src/db/databasemanager.cpp src/db/foodrepository.cpp src/utils/string_utils.cpp)
+target_include_directories(test_nutra PRIVATE ${CMAKE_SOURCE_DIR}/include)
+target_link_libraries(test_nutra PRIVATE Qt${QT_VERSION_MAJOR}::Test Qt${QT_VERSION_MAJOR}::Sql)
+
+add_test(NAME FoodRepoTest COMMAND test_nutra)
+
+
+install(TARGETS nutra DESTINATION bin)
+install(FILES nutra.desktop DESTINATION share/applications)
+install(FILES resources/nutrition_icon-no_bg.png DESTINATION share/icons/hicolor/128x128/apps RENAME nutra.png)
+
+if(NUTRA_DB_FILE AND EXISTS "${NUTRA_DB_FILE}")
+ install(FILES "${NUTRA_DB_FILE}" DESTINATION share/nutra RENAME usda.sqlite3)
+endif()
--- /dev/null
+SHELL := /bin/bash
+BUILD_DIR := build
+CMAKE := cmake
+CTEST := ctest
+SRC_DIRS := src
+
+# Find source files for linting
+LINT_LOCS_CPP ?= $(shell git ls-files '*.cpp')
+LINT_LOCS_H ?= $(shell git ls-files '*.h')
+
+.PHONY: config
+config:
+ $(CMAKE) -E make_directory $(BUILD_DIR)
+ $(CMAKE) \
+ -DCMAKE_BUILD_TYPE=Debug \
+ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
+ -B $(BUILD_DIR)
+
+.PHONY: debug
+debug: config
+ $(CMAKE) --build $(BUILD_DIR) --config Debug
+
+.PHONY: release
+release:
+ $(CMAKE) -E make_directory $(BUILD_DIR)
+ $(CMAKE) -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=Release
+ $(CMAKE) --build $(BUILD_DIR) --config Release
+
+.PHONY: clean
+clean:
+ $(CMAKE) -E remove_directory $(BUILD_DIR)
+
+
+.PHONY: test
+test: release
+ $(CMAKE) --build $(BUILD_DIR) --target test_nutra --config Release
+ cd $(BUILD_DIR) && $(CTEST) --output-on-failure -C Release
+
+.PHONY: run
+run: debug
+ ./$(BUILD_DIR)/nutra
+
+
+.PHONY: format
+format:
+ -prettier --write .github/
+ clang-format -i $(LINT_LOCS_CPP) $(LINT_LOCS_H)
+
+
+.PHONY: lint
+lint: config
+ @echo "Linting..."
+ @# Build test target first to generate MOC files for tests
+ @$(CMAKE) --build $(BUILD_DIR) --target test_nutra --config Debug 2>/dev/null || true
+ @echo "Running cppcheck..."
+ cppcheck --enable=warning,performance,portability \
+ --language=c++ --std=c++17 \
+ --suppress=missingIncludeSystem \
+ -Dslots= -Dsignals= -Demit= \
+ --quiet --error-exitcode=1 \
+ $(SRC_DIRS) include tests
+ @if [ ! -f $(BUILD_DIR)/compile_commands.json ]; then \
+ echo "Error: compile_commands.json not found in $(BUILD_DIR). Run 'make config' first."; \
+ exit 1; \
+ fi
+ @# Create a temp clean compile_commands.json to avoid modifying the original
+ @mkdir -p $(BUILD_DIR)/lint_tmp
+ @sed 's/-mno-direct-extern-access//g' $(BUILD_DIR)/compile_commands.json > $(BUILD_DIR)/lint_tmp/compile_commands.json
+ @echo "Running clang-tidy in parallel..."
+ @echo $(LINT_LOCS_CPP) $(LINT_LOCS_H) | xargs -n 1 -P $(shell nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 1) clang-tidy --quiet -p $(BUILD_DIR)/lint_tmp -extra-arg=-Wno-unknown-argument
+ @rm -rf $(BUILD_DIR)/lint_tmp
+
+
+.PHONY: install
+install: release
+ @echo "Installing..."
+ @$(MAKE) -C $(BUILD_DIR) install || ( \
+ $(CMAKE) -DCMAKE_INSTALL_PREFIX=$(HOME)/.local -B $(BUILD_DIR) && \
+ $(MAKE) -C $(BUILD_DIR) install \
+ )
--- /dev/null
+#ifndef DATABASEMANAGER_H
+#define DATABASEMANAGER_H
+
+#include <QSqlDatabase>
+#include <QSqlQuery>
+#include <QString>
+
+class DatabaseManager {
+public:
+ static DatabaseManager &instance();
+ bool connect(const QString &path);
+ [[nodiscard]] bool isOpen() const;
+ [[nodiscard]] QSqlDatabase database() const;
+
+ DatabaseManager(const DatabaseManager &) = delete;
+ DatabaseManager &operator=(const DatabaseManager &) = delete;
+
+private:
+ DatabaseManager();
+ ~DatabaseManager();
+
+ QSqlDatabase m_db;
+};
+
+#endif // DATABASEMANAGER_H
--- /dev/null
+#ifndef FOODREPOSITORY_H
+#define FOODREPOSITORY_H
+
+#include <QString>
+#include <QVariantMap>
+#include <vector>
+
+struct Nutrient {
+ int id;
+ QString description;
+ double amount;
+ QString unit;
+ double rdaPercentage; // Calculated
+};
+
+struct FoodItem {
+ int id;
+ QString description;
+ int foodGroupId;
+ int nutrientCount;
+ int aminoCount;
+ int flavCount;
+ int score; // For search results
+ std::vector<Nutrient> nutrients; // Full details for results
+};
+
+class FoodRepository {
+public:
+ explicit FoodRepository();
+
+ // Search foods by keyword
+ std::vector<FoodItem> searchFoods(const QString &query);
+
+ // Get detailed nutrients for a generic food (100g)
+ // Returns a list of nutrients
+ std::vector<Nutrient> getFoodNutrients(int foodId);
+
+ // Helper to get nutrient definition basics if needed
+ // QString getNutrientName(int nutrientId);
+
+private:
+ // Internal helper methods
+ void ensureCacheLoaded();
+
+ bool m_cacheLoaded = false;
+ // Cache stores basic food info
+ std::vector<FoodItem> m_cache;
+};
+
+#endif // FOODREPOSITORY_H
--- /dev/null
+#ifndef MAINWINDOW_H
+#define MAINWINDOW_H
+
+#include "widgets/detailswidget.h"
+#include "widgets/mealwidget.h"
+#include "widgets/searchwidget.h"
+#include <QMainWindow>
+#include <QTabWidget>
+
+class MainWindow : public QMainWindow {
+ Q_OBJECT
+
+public:
+ MainWindow(QWidget *parent = nullptr);
+ ~MainWindow() override;
+
+private:
+ void setupUi();
+
+ QTabWidget *tabs;
+ SearchWidget *searchWidget;
+ DetailsWidget *detailsWidget;
+ MealWidget *mealWidget;
+};
+
+#endif // MAINWINDOW_H
--- /dev/null
+#ifndef STRING_UTILS_H
+#define STRING_UTILS_H
+
+#include <QString>
+#include <algorithm>
+#include <vector>
+
+namespace Utils {
+
+// Calculate Levenshtein distance between two strings
+int levenshteinDistance(const QString &s1, const QString &s2);
+
+// Calculate a simple fuzzy match score (0-100)
+// Higher is better.
+int calculateFuzzyScore(const QString &query, const QString &target);
+
+} // namespace Utils
+
+#endif // STRING_UTILS_H
--- /dev/null
+#ifndef DETAILSWIDGET_H
+#define DETAILSWIDGET_H
+
+#include "db/foodrepository.h"
+#include <QLabel>
+#include <QPushButton>
+#include <QTableWidget>
+#include <QWidget>
+
+class DetailsWidget : public QWidget {
+ Q_OBJECT
+
+public:
+ explicit DetailsWidget(QWidget *parent = nullptr);
+
+ void loadFood(int foodId, const QString &foodName);
+
+signals:
+ void addToMeal(int foodId, const QString &foodName, double grams);
+
+private slots:
+ void onAddClicked();
+
+private:
+ QLabel *nameLabel;
+ QTableWidget *nutrientsTable;
+ QPushButton *addButton;
+ FoodRepository repository;
+
+ int currentFoodId;
+ QString currentFoodName;
+};
+
+#endif // DETAILSWIDGET_H
--- /dev/null
+#ifndef MEALWIDGET_H
+#define MEALWIDGET_H
+
+#include "db/foodrepository.h"
+#include <QPushButton>
+#include <QTableWidget>
+#include <QWidget>
+#include <map>
+#include <vector>
+
+struct MealItem {
+ int foodId;
+ QString name;
+ double grams;
+ std::vector<Nutrient> nutrients_100g; // Base nutrients
+};
+
+class MealWidget : public QWidget {
+ Q_OBJECT
+
+public:
+ explicit MealWidget(QWidget *parent = nullptr);
+
+ void addFood(int foodId, const QString &foodName, double grams);
+
+private slots:
+ void clearMeal();
+
+private:
+ void updateTotals();
+
+ QTableWidget *itemsTable;
+ QTableWidget *totalsTable;
+ QPushButton *clearButton;
+
+ std::vector<MealItem> mealItems;
+ FoodRepository repository;
+};
+
+#endif // MEALWIDGET_H
--- /dev/null
+#ifndef SEARCHWIDGET_H
+#define SEARCHWIDGET_H
+
+#include "db/foodrepository.h"
+#include <QLineEdit>
+#include <QPushButton>
+#include <QTableWidget>
+#include <QTimer>
+#include <QWidget>
+
+class SearchWidget : public QWidget {
+ Q_OBJECT
+
+public:
+ explicit SearchWidget(QWidget *parent = nullptr);
+
+signals:
+ void foodSelected(int foodId, const QString &foodName);
+
+private slots:
+ void performSearch();
+ void onRowDoubleClicked(int row, int column);
+
+private:
+ QLineEdit *searchInput;
+ QPushButton *searchButton;
+ QTableWidget *resultsTable;
+ FoodRepository repository;
+ QTimer *searchTimer;
+};
+
+#endif // SEARCHWIDGET_H
--- /dev/null
+[Desktop Entry]
+Name=Nutra
+Comment=Nutrition Tracker and USDA Database
+Exec=nutra
+Icon=nutra
+Terminal=false
+Type=Application
+Categories=Utility;Database;Health;
+StartupNotify=true
+Keywords=nutrition;food;tracker;usda;diet;
--- /dev/null
+<RCC>
+ <qresource prefix="/">
+ <file>resources/nutrition_icon-no_bg.png</file>
+ </qresource>
+
+</RCC>
--- /dev/null
+#include "db/databasemanager.h"
+#include <QDebug>
+#include <QFileInfo>
+#include <QSqlError>
+
+DatabaseManager &DatabaseManager::instance() {
+ static DatabaseManager instance;
+ return instance;
+}
+
+DatabaseManager::DatabaseManager() = default;
+
+DatabaseManager::~DatabaseManager() {
+ if (m_db.isOpen()) {
+ m_db.close();
+ }
+}
+
+bool DatabaseManager::connect(const QString &path) {
+ if (m_db.isOpen()) {
+ return true;
+ }
+
+ if (!QFileInfo::exists(path)) {
+ qCritical() << "Database file not found:" << path;
+ return false;
+ }
+
+ m_db = QSqlDatabase::addDatabase("QSQLITE");
+ m_db.setDatabaseName(path);
+ m_db.setConnectOptions("QSQLITE_OPEN_READONLY");
+
+ if (!m_db.open()) {
+ qCritical() << "Error opening database:" << m_db.lastError().text();
+ return false;
+ }
+
+ return true;
+}
+
+bool DatabaseManager::isOpen() const { return m_db.isOpen(); }
+
+QSqlDatabase DatabaseManager::database() const { return m_db; }
--- /dev/null
+#include "db/foodrepository.h"
+#include "db/databasemanager.h"
+#include <QDebug>
+#include <QSqlError>
+#include <QSqlQuery>
+#include <QVariant>
+#include <map>
+
+FoodRepository::FoodRepository() {}
+
+#include "utils/string_utils.h"
+#include <algorithm>
+
+// ...
+
+void FoodRepository::ensureCacheLoaded() {
+ if (m_cacheLoaded)
+ return;
+
+ QSqlDatabase db = DatabaseManager::instance().database();
+ if (!db.isOpen())
+ return;
+
+ // 1. Load Food Items
+ QSqlQuery query("SELECT id, long_desc, fdgrp_id FROM food_des", db);
+ std::map<int, int> nutrientCounts;
+
+ // 2. Load Nutrient Counts (Bulk)
+ QSqlQuery countQuery(
+ "SELECT food_id, count(*) FROM nut_data GROUP BY food_id", db);
+ while (countQuery.next()) {
+ nutrientCounts[countQuery.value(0).toInt()] = countQuery.value(1).toInt();
+ }
+
+ while (query.next()) {
+ FoodItem item;
+ item.id = query.value(0).toInt();
+ item.description = query.value(1).toString();
+ item.foodGroupId = query.value(2).toInt();
+
+ // Set counts from map (default 0 if not found)
+ auto it = nutrientCounts.find(item.id);
+ item.nutrientCount = (it != nutrientCounts.end()) ? it->second : 0;
+
+ item.aminoCount = 0; // TODO: Implement specific counts if needed
+ item.flavCount = 0;
+ item.score = 0;
+ m_cache.push_back(item);
+ }
+ m_cacheLoaded = true;
+}
+
+std::vector<FoodItem> FoodRepository::searchFoods(const QString &query) {
+ ensureCacheLoaded();
+ std::vector<FoodItem> results;
+
+ if (query.trimmed().isEmpty())
+ return results;
+
+ // Calculate scores
+ // create a temporary list of pointers or indices to sort?
+ // Copying might be expensive if cache is huge (8k items is fine though)
+
+ // Let's iterate and keep top matches.
+ struct ScoredItem {
+ const FoodItem *item;
+ int score;
+ };
+ std::vector<ScoredItem> scoredItems;
+ scoredItems.reserve(m_cache.size());
+
+ for (const auto &item : m_cache) {
+ int score = Utils::calculateFuzzyScore(query, item.description);
+ if (score > 40) { // Threshold
+ scoredItems.push_back({&item, score});
+ }
+ }
+
+ // Sort by score desc
+ std::sort(scoredItems.begin(), scoredItems.end(),
+ [](const ScoredItem &a, const ScoredItem &b) {
+ return a.score > b.score;
+ });
+
+ // Take top 100
+ int count = 0;
+ std::vector<int> resultIds;
+ std::map<int, int> idToIndex;
+
+ for (const auto &si : scoredItems) {
+ if (count >= 100)
+ break;
+ FoodItem res = *si.item;
+ res.score = si.score;
+ // We will populate nutrients shortly
+ results.push_back(res);
+ resultIds.push_back(res.id);
+ idToIndex[res.id] = count;
+ count++;
+ }
+
+ // Batch fetch nutrients for these results
+ if (!resultIds.empty()) {
+ QSqlDatabase db = DatabaseManager::instance().database();
+ QStringList idStrings;
+ for (int id : resultIds)
+ idStrings << QString::number(id);
+
+ QString sql =
+ QString("SELECT n.food_id, n.nutr_id, n.nutr_val, d.nutr_desc, d.unit "
+ "FROM nut_data n "
+ "JOIN nutr_def d ON n.nutr_id = d.id "
+ "WHERE n.food_id IN (%1)")
+ .arg(idStrings.join(","));
+
+ QSqlQuery nutQuery(sql, db);
+ while (nutQuery.next()) {
+ int fid = nutQuery.value(0).toInt();
+ Nutrient nut;
+ nut.id = nutQuery.value(1).toInt();
+ nut.amount = nutQuery.value(2).toDouble();
+ nut.description = nutQuery.value(3).toString();
+ nut.unit = nutQuery.value(4).toString();
+ nut.rdaPercentage = 0.0;
+
+ if (idToIndex.count(fid) != 0U) {
+ results[idToIndex[fid]].nutrients.push_back(nut);
+ }
+ }
+
+ // Update counts based on actual data
+ for (auto &res : results) {
+ res.nutrientCount = static_cast<int>(res.nutrients.size());
+ // TODO: Logic for amino/flav counts if we have ranges of IDs
+ }
+ }
+
+ return results;
+}
+
+std::vector<Nutrient> FoodRepository::getFoodNutrients(int foodId) {
+ std::vector<Nutrient> results;
+ QSqlDatabase db = DatabaseManager::instance().database();
+
+ if (!db.isOpen())
+ return results;
+
+ QSqlQuery query(db);
+ if (!query.prepare("SELECT n.nutr_id, n.nutr_val, d.nutr_desc, d.unit "
+ "FROM nut_data n "
+ "JOIN nutr_def d ON n.nutr_id = d.id "
+ "WHERE n.food_id = ?")) {
+
+ qCritical() << "Prepare failed:" << query.lastError().text();
+ return results;
+ }
+
+ query.bindValue(0, foodId);
+
+ if (query.exec()) {
+ while (query.next()) {
+ Nutrient nut;
+ nut.id = query.value(0).toInt();
+ nut.amount = query.value(1).toDouble();
+ nut.description = query.value(2).toString();
+ nut.unit = query.value(3).toString();
+ nut.rdaPercentage = 0.0;
+
+ results.push_back(nut);
+ }
+
+ } else {
+ qCritical() << "Nutrient query failed:" << query.lastError().text();
+ }
+
+ return results;
+}
--- /dev/null
+#include "db/databasemanager.h"
+#include "mainwindow.h"
+#include <QApplication>
+#include <QDebug>
+#include <QDir>
+#include <QFileInfo>
+#include <QIcon>
+#include <QMessageBox>
+#include <QStandardPaths>
+
+int main(int argc, char *argv[]) {
+ QApplication app(argc, argv);
+ QApplication::setApplicationName("Nutra");
+ QApplication::setOrganizationName("NutraTech");
+ QApplication::setWindowIcon(QIcon(":/resources/nutrition_icon-no_bg.png"));
+
+ // Connect to database
+ // Search order:
+ // 1. Environment variable NUTRA_DB_PATH
+ // 2. Local user data: ~/.local/share/nutra/usda.sqlite3
+ // 3. System install: /usr/local/share/nutra/usda.sqlite3
+ // 4. System install: /usr/share/nutra/usda.sqlite3
+ // 5. Legacy: ~/.nutra/usda.sqlite3
+
+ QStringList searchPaths;
+ QString envPath = qEnvironmentVariable("NUTRA_DB_PATH");
+ if (!envPath.isEmpty())
+ searchPaths << envPath;
+
+ searchPaths << QStandardPaths::locate(QStandardPaths::AppDataLocation,
+ "usda.sqlite3",
+ QStandardPaths::LocateFile);
+ searchPaths << QDir::homePath() + "/.local/share/nutra/usda.sqlite3";
+ searchPaths << "/usr/local/share/nutra/usda.sqlite3";
+ searchPaths << "/usr/share/nutra/usda.sqlite3";
+ searchPaths << QDir::homePath() + "/.nutra/usda.sqlite3";
+
+ QString dbPath;
+ for (const QString &path : searchPaths) {
+ if (!path.isEmpty() && QFileInfo::exists(path)) {
+ dbPath = path;
+ break;
+ }
+ }
+
+ if (dbPath.isEmpty()) {
+ // If not found, default to XDG AppData location for error message/setup
+ // But we can't create it here.
+ dbPath = QDir::homePath() + "/.nutra/usda.sqlite3"; // Fallback default
+ qWarning() << "Database not found in standard locations.";
+ }
+
+ if (!DatabaseManager::instance().connect(dbPath)) {
+ QString errorMsg =
+ QString("Failed to connect to database at:\n%1\n\nPlease ensure the "
+ "database file exists or reinstall the application.")
+ .arg(dbPath);
+ qCritical() << errorMsg;
+ QMessageBox::critical(nullptr, "Database Error", errorMsg);
+ return 1;
+ }
+ qDebug() << "Connected to database at:" << dbPath;
+
+ MainWindow window;
+ window.show();
+
+ return QApplication::exec();
+}
--- /dev/null
+#include "mainwindow.h"
+#include <QVBoxLayout>
+
+#include <QDebug>
+#include <QLabel>
+#include <QWidget>
+
+MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { setupUi(); }
+
+MainWindow::~MainWindow() = default;
+
+void MainWindow::setupUi() {
+ setWindowTitle("Nutrient Coach");
+ setWindowIcon(QIcon(":/resources/nutrition_icon-no_bg.png"));
+ resize(1000, 700);
+
+ auto *centralWidget = new QWidget(this);
+ setCentralWidget(centralWidget);
+
+ auto *mainLayout = new QVBoxLayout(centralWidget);
+
+ tabs = new QTabWidget(this);
+ mainLayout->addWidget(tabs);
+
+ // Search Tab
+ searchWidget = new SearchWidget(this);
+ tabs->addTab(searchWidget, "Search Foods");
+
+ // Connect signal
+ connect(searchWidget, &SearchWidget::foodSelected, this,
+ [=](int foodId, const QString &foodName) {
+ qDebug() << "Selected food:" << foodName << "(" << foodId << ")";
+ detailsWidget->loadFood(foodId, foodName);
+ tabs->setCurrentWidget(detailsWidget);
+ });
+
+ // Analysis Tab
+ detailsWidget = new DetailsWidget(this);
+ tabs->addTab(detailsWidget, "Analyze");
+
+ // Meal Tab
+ mealWidget = new MealWidget(this);
+ tabs->addTab(mealWidget, "Meal Tracker");
+
+ // Connect Analysis -> Meal
+ connect(detailsWidget, &DetailsWidget::addToMeal, this,
+ [=](int foodId, const QString &foodName, double grams) {
+ mealWidget->addFood(foodId, foodName, grams);
+ // Optional: switch tab?
+ // tabs->setCurrentWidget(mealWidget);
+ });
+}
--- /dev/null
+#include "utils/string_utils.h"
+#include <QRegularExpression>
+#include <QStringList>
+#include <QtGlobal>
+#include <algorithm> // Required for std::max
+#include <cmath>
+
+#ifndef QT_VERSION_CHECK
+#define QT_VERSION_CHECK(major, minor, patch) \
+ ((major << 16) | (minor << 8) | (patch))
+#endif
+
+namespace Utils {
+
+int levenshteinDistance(const QString &s1, const QString &s2) {
+ const auto m = s1.length();
+ const auto n = s2.length();
+
+ std::vector<std::vector<int>> dp(m + 1, std::vector<int>(n + 1));
+
+ for (int i = 0; i <= m; ++i) {
+ dp[i][0] = i;
+ }
+ for (int j = 0; j <= n; ++j) {
+ dp[0][j] = j;
+ }
+
+ for (int i = 1; i <= m; ++i) {
+ for (int j = 1; j <= n; ++j) {
+ if (s1[i - 1] == s2[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1];
+ } else {
+ dp[i][j] = 1 + std::min({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]});
+ }
+ }
+ }
+
+ return dp[m][n];
+}
+
+int calculateFuzzyScore(const QString &query, const QString &target) {
+ if (query.isEmpty()) {
+ return 0;
+ }
+ if (target.isEmpty()) {
+ return 0;
+ }
+
+ QString q = query.toLower();
+ QString t = target.toLower();
+
+ // 1. Exact match bonus
+ if (t == q) {
+ return 100;
+ }
+
+ // 2. Contains match bonus (very strong signal)
+ if (t.contains(q)) {
+ return 90; // Base score for containing the string
+ }
+
+ // 3. Token-based matching (handling "grass fed" vs "beef, grass-fed")
+ static const QRegularExpression regex("[\\s,-]+");
+#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
+ auto behavior = Qt::SkipEmptyParts;
+#else
+ auto behavior = QString::SkipEmptyParts;
+#endif
+ QStringList queryTokens = q.split(regex, behavior);
+ QStringList targetTokens = t.split(regex, behavior);
+
+ int totalScore = 0;
+ int matchedTokens = 0;
+
+ for (const QString &qToken : queryTokens) {
+ int maxTokenScore = 0;
+ for (const QString &tToken : targetTokens) {
+ int dist = levenshteinDistance(qToken, tToken);
+ int maxLen = static_cast<int>(std::max(qToken.length(), tToken.length()));
+ if (maxLen == 0)
+ continue;
+
+ int score = 0;
+ if (tToken.startsWith(qToken)) {
+ score = 95; // Prefix match is very good
+ } else {
+ double ratio = 1.0 - (static_cast<double>(dist) / maxLen);
+ score = static_cast<int>(ratio * 100);
+ }
+
+ maxTokenScore = std::max(maxTokenScore, score);
+ }
+ totalScore += maxTokenScore;
+ if (maxTokenScore > 60)
+ matchedTokens++;
+ }
+
+ if (queryTokens.isEmpty()) {
+ return 0;
+ }
+
+ int averageScore = static_cast<int>(totalScore / queryTokens.size());
+
+ // Penalize if not all tokens matched somewhat well
+ if (matchedTokens < queryTokens.size()) {
+ averageScore -= 20;
+ }
+
+ return std::max(0, averageScore);
+}
+
+} // namespace Utils
--- /dev/null
+#include "widgets/detailswidget.h"
+#include <QDebug>
+#include <QHBoxLayout>
+#include <QHeaderView>
+#include <QVBoxLayout>
+
+DetailsWidget::DetailsWidget(QWidget *parent)
+ : QWidget(parent), currentFoodId(-1) {
+ auto *layout = new QVBoxLayout(this);
+
+ // Header
+ auto *headerLayout = new QHBoxLayout();
+ nameLabel = new QLabel("No food selected", this);
+ QFont font = nameLabel->font();
+ font.setPointSize(14);
+ font.setBold(true);
+ nameLabel->setFont(font);
+
+ addButton = new QPushButton("Add to Meal", this);
+ addButton->setEnabled(false);
+ connect(addButton, &QPushButton::clicked, this, &DetailsWidget::onAddClicked);
+
+ headerLayout->addWidget(nameLabel);
+ headerLayout->addStretch();
+ headerLayout->addWidget(addButton);
+ layout->addLayout(headerLayout);
+
+ // Nutrients Table
+ nutrientsTable = new QTableWidget(this);
+ nutrientsTable->setColumnCount(3);
+ nutrientsTable->setHorizontalHeaderLabels({"Nutrient", "Amount", "Unit"});
+ nutrientsTable->horizontalHeader()->setSectionResizeMode(
+ 0, QHeaderView::Stretch);
+ nutrientsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
+ layout->addWidget(nutrientsTable);
+}
+
+void DetailsWidget::loadFood(int foodId, const QString &foodName) {
+ currentFoodId = foodId;
+ currentFoodName = foodName;
+ nameLabel->setText(foodName + QString(" (ID: %1)").arg(foodId));
+ addButton->setEnabled(true);
+
+ nutrientsTable->setRowCount(0);
+
+ std::vector<Nutrient> nutrients = repository.getFoodNutrients(foodId);
+
+ nutrientsTable->setRowCount(static_cast<int>(nutrients.size()));
+ for (int i = 0; i < static_cast<int>(nutrients.size()); ++i) {
+ const auto &nut = nutrients[i];
+ nutrientsTable->setItem(i, 0, new QTableWidgetItem(nut.description));
+ nutrientsTable->setItem(i, 1,
+ new QTableWidgetItem(QString::number(nut.amount)));
+ nutrientsTable->setItem(i, 2, new QTableWidgetItem(nut.unit));
+ }
+}
+
+void DetailsWidget::onAddClicked() {
+ if (currentFoodId != -1) {
+ // Default 100g
+ emit addToMeal(currentFoodId, currentFoodName, 100.0);
+ }
+}
--- /dev/null
+#include "widgets/mealwidget.h"
+#include <QDebug>
+#include <QHBoxLayout>
+#include <QHeaderView>
+#include <QLabel>
+#include <QVBoxLayout>
+
+MealWidget::MealWidget(QWidget *parent) : QWidget(parent) {
+ auto *layout = new QVBoxLayout(this);
+
+ // Items List
+ layout->addWidget(new QLabel("Meal Composition", this));
+ itemsTable = new QTableWidget(this);
+ itemsTable->setColumnCount(3);
+ itemsTable->setHorizontalHeaderLabels({"Food", "Grams", "Calories"});
+ itemsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch);
+ layout->addWidget(itemsTable);
+
+ // Controls
+ clearButton = new QPushButton("Clear Meal", this);
+ connect(clearButton, &QPushButton::clicked, this, &MealWidget::clearMeal);
+ layout->addWidget(clearButton);
+
+ // Totals
+ layout->addWidget(new QLabel("Total Nutrition", this));
+ totalsTable = new QTableWidget(this);
+ totalsTable->setColumnCount(3);
+ totalsTable->setHorizontalHeaderLabels({"Nutrient", "Total", "Unit"});
+ totalsTable->horizontalHeader()->setSectionResizeMode(0,
+ QHeaderView::Stretch);
+ layout->addWidget(totalsTable);
+}
+
+void MealWidget::addFood(int foodId, const QString &foodName, double grams) {
+ std::vector<Nutrient> baseNutrients = repository.getFoodNutrients(foodId);
+
+ MealItem item;
+ item.foodId = foodId;
+ item.name = foodName;
+ item.grams = grams;
+ item.nutrients_100g = baseNutrients;
+
+ mealItems.push_back(item);
+
+ // Update Items Table
+ int row = itemsTable->rowCount();
+ itemsTable->insertRow(row);
+ itemsTable->setItem(row, 0, new QTableWidgetItem(foodName));
+ itemsTable->setItem(row, 1, new QTableWidgetItem(QString::number(grams)));
+
+ // Calculate Calories (ID 208 usually, or find by name?)
+ // repository returns IDs based on DB. 208 is KCAL in SR28.
+ double kcal = 0;
+ for (const auto &nut : baseNutrients) {
+ if (nut.id == 208) {
+ kcal = (nut.amount * grams) / 100.0;
+ break;
+ }
+ }
+ itemsTable->setItem(row, 2,
+ new QTableWidgetItem(QString::number(kcal, 'f', 1)));
+
+ updateTotals();
+}
+
+void MealWidget::clearMeal() {
+ mealItems.clear();
+ itemsTable->setRowCount(0);
+ updateTotals();
+}
+
+void MealWidget::updateTotals() {
+ std::map<int, double> totals; // id -> amount
+ std::map<int, QString> units;
+ std::map<int, QString> names;
+
+ for (const auto &item : mealItems) {
+ double scale = item.grams / 100.0;
+ for (const auto &nut : item.nutrients_100g) {
+ totals[nut.id] += nut.amount * scale;
+ names.try_emplace(nut.id, nut.description);
+ units.try_emplace(nut.id, nut.unit);
+ }
+ }
+
+ totalsTable->setRowCount(static_cast<int>(totals.size()));
+ int row = 0;
+ for (const auto &pair : totals) {
+ int nid = pair.first;
+ double amount = pair.second;
+
+ totalsTable->setItem(row, 0, new QTableWidgetItem(names[nid]));
+ totalsTable->setItem(row, 1,
+ new QTableWidgetItem(QString::number(amount, 'f', 2)));
+ totalsTable->setItem(row, 2, new QTableWidgetItem(units[nid]));
+ row++;
+ }
+}
--- /dev/null
+#include "widgets/searchwidget.h"
+#include <QHBoxLayout>
+#include <QHeaderView>
+#include <QMessageBox>
+#include <QVBoxLayout>
+
+SearchWidget::SearchWidget(QWidget *parent) : QWidget(parent) {
+ auto *layout = new QVBoxLayout(this);
+
+ // Search bar
+ auto *searchLayout = new QHBoxLayout();
+ searchInput = new QLineEdit(this);
+ searchInput->setPlaceholderText("Search for food...");
+
+ searchTimer = new QTimer(this);
+ searchTimer->setSingleShot(true);
+ searchTimer->setInterval(600); // 600ms debounce
+
+ connect(searchInput, &QLineEdit::textChanged, this,
+ [=]() { searchTimer->start(); });
+ connect(searchTimer, &QTimer::timeout, this, &SearchWidget::performSearch);
+ connect(searchInput, &QLineEdit::returnPressed, this,
+ &SearchWidget::performSearch);
+
+ searchButton = new QPushButton("Search", this);
+ connect(searchButton, &QPushButton::clicked, this,
+ &SearchWidget::performSearch);
+
+ searchLayout->addWidget(searchInput);
+ searchLayout->addWidget(searchButton);
+ layout->addLayout(searchLayout);
+
+ // Results table
+ resultsTable = new QTableWidget(this);
+ resultsTable->setColumnCount(7);
+ resultsTable->setHorizontalHeaderLabels(
+ {"ID", "Description", "Group", "Nutr", "Amino", "Flav", "Score"});
+
+ resultsTable->horizontalHeader()->setSectionResizeMode(1,
+ QHeaderView::Stretch);
+ resultsTable->setSelectionBehavior(QAbstractItemView::SelectRows);
+ resultsTable->setSelectionMode(QAbstractItemView::SingleSelection);
+ resultsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
+ connect(resultsTable, &QTableWidget::cellDoubleClicked, this,
+ &SearchWidget::onRowDoubleClicked);
+
+ layout->addWidget(resultsTable);
+}
+
+void SearchWidget::performSearch() {
+ QString query = searchInput->text().trimmed();
+ if (query.length() < 2)
+ return;
+
+ resultsTable->setRowCount(0);
+
+ std::vector<FoodItem> results = repository.searchFoods(query);
+
+ resultsTable->setRowCount(static_cast<int>(results.size()));
+ for (int i = 0; i < static_cast<int>(results.size()); ++i) {
+ const auto &item = results[i];
+ resultsTable->setItem(i, 0, new QTableWidgetItem(QString::number(item.id)));
+ resultsTable->setItem(i, 1, new QTableWidgetItem(item.description));
+ resultsTable->setItem(
+ i, 2, new QTableWidgetItem(QString::number(item.foodGroupId)));
+ resultsTable->setItem(
+ i, 3, new QTableWidgetItem(QString::number(item.nutrientCount)));
+ resultsTable->setItem(
+ i, 4, new QTableWidgetItem(QString::number(item.aminoCount)));
+ resultsTable->setItem(
+ i, 5, new QTableWidgetItem(QString::number(item.flavCount)));
+ resultsTable->setItem(i, 6,
+ new QTableWidgetItem(QString::number(item.score)));
+ }
+}
+
+void SearchWidget::onRowDoubleClicked(int row, int column) {
+ Q_UNUSED(column);
+ QTableWidgetItem *idItem = resultsTable->item(row, 0);
+ QTableWidgetItem *descItem = resultsTable->item(row, 1);
+
+ if (idItem != nullptr && descItem != nullptr) {
+ emit foodSelected(idItem->text().toInt(), descItem->text());
+ }
+}
--- /dev/null
+#include "db/databasemanager.h"
+#include "db/foodrepository.h"
+#include <QDir>
+#include <QFileInfo>
+#include <QtTest>
+
+class TestFoodRepository : public QObject {
+ Q_OBJECT
+
+private slots:
+ void initTestCase() {
+ // Setup DB connection
+ // Allow override via environment variable for CI/testing
+ QString envPath = qgetenv("NUTRA_DB_PATH");
+ QString dbPath =
+ envPath.isEmpty() ? QDir::homePath() + "/.nutra/usda.sqlite3" : envPath;
+
+ if (!QFileInfo::exists(dbPath)) {
+ QSKIP("Database file not found (NUTRA_DB_PATH or ~/.nutra/usda.sqlite3). "
+ "Skipping DB tests.");
+ }
+
+ bool connected = DatabaseManager::instance().connect(dbPath);
+ QVERIFY2(connected, "Failed to connect to database");
+ }
+
+ void testSearchFoods() {
+ FoodRepository repo;
+ auto results = repo.searchFoods("apple");
+ QVERIFY2(!results.empty(), "Search should return results for 'apple'");
+ bool found = false;
+ for (const auto &item : results) {
+ if (item.description.contains("Apple", Qt::CaseInsensitive)) {
+ found = true;
+ break;
+ }
+ }
+ QVERIFY2(found, "Search results should contain 'Apple'");
+ }
+
+ void testGetFoodNutrients() {
+ FoodRepository repo;
+ // Known ID for "Apples, raw, with skin" might be 9003 in SR28, but let's
+ // search first or pick a known one if we knew it. Let's just use the first
+ // result from search.
+ auto results = repo.searchFoods("apple");
+ if (results.empty())
+ QSKIP("No foods found to test nutrients");
+
+ int foodId = results[0].id;
+ auto nutrients = repo.getFoodNutrients(foodId);
+ QVERIFY2(!nutrients.empty(),
+ "Nutrients should not be empty for a valid food");
+ }
+};
+
+QTEST_MAIN(TestFoodRepository)
+#include "test_foodrepository.moc"
--- /dev/null
+Subproject commit 4644288e1cfc0bac44dd3a0bfdaafa5bd72cf819