From: Shane Jaroch Date: Tue, 20 Jan 2026 21:02:48 +0000 (-0500) Subject: initial commit X-Git-Tag: v0.0.0-alpha.0^0 X-Git-Url: https://git.nutra.tk/v2?a=commitdiff_plain;h=HEAD;p=nutratech%2Fgui.git initial commit - ubuntu-20.04 (Qt 5.12) support - makefile - update title; import icon so it hopefully loads - install stuff (desktop file for linux launcher) - update app icon; add GitHub workflows - clang lint - fix lint error in tests (unregistered macro) --- efe888a50913a5df59a98317c345b213c5eabecc diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..9c64a3b --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,8 @@ +--- +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 diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..dddeaef --- /dev/null +++ b/.clangd @@ -0,0 +1,3 @@ +CompileFlags: + Remove: + - -mno-direct-extern-access diff --git a/.github/workflows/arch.yml.disabled b/.github/workflows/arch.yml.disabled new file mode 100644 index 0000000..f8e5ea6 --- /dev/null +++ b/.github/workflows/arch.yml.disabled @@ -0,0 +1,30 @@ +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) diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml new file mode 100644 index 0000000..9d628df --- /dev/null +++ b/.github/workflows/ci-full.yml @@ -0,0 +1,40 @@ +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 diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..b20da2d --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,35 @@ +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 diff --git a/.github/workflows/ubuntu-20.04.yml b/.github/workflows/ubuntu-20.04.yml new file mode 100644 index 0000000..c3f75f6 --- /dev/null +++ b/.github/workflows/ubuntu-20.04.yml @@ -0,0 +1,45 @@ +--- +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 diff --git a/.github/workflows/ubuntu-22.04.yml b/.github/workflows/ubuntu-22.04.yml new file mode 100644 index 0000000..0ec2dc6 --- /dev/null +++ b/.github/workflows/ubuntu-22.04.yml @@ -0,0 +1,39 @@ +--- +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 diff --git a/.github/workflows/ubuntu-24.04.yml b/.github/workflows/ubuntu-24.04.yml new file mode 100644 index 0000000..0395eff --- /dev/null +++ b/.github/workflows/ubuntu-24.04.yml @@ -0,0 +1,39 @@ +--- +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 diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..0139cb1 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,35 @@ +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b25244e --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# macOS/backup files +.~* +._* +~$* +.DS_Store +*.swp + +# C++ stuff +build/ + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ae4efcd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "usdasqlite"] + path = usdasqlite + url = https://github.com/nutratech/usda-sqlite.git diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..aaec69d --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,64 @@ +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() diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d10b8fc --- /dev/null +++ b/Makefile @@ -0,0 +1,80 @@ +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 \ + ) diff --git a/include/db/databasemanager.h b/include/db/databasemanager.h new file mode 100644 index 0000000..32ad4ea --- /dev/null +++ b/include/db/databasemanager.h @@ -0,0 +1,25 @@ +#ifndef DATABASEMANAGER_H +#define DATABASEMANAGER_H + +#include +#include +#include + +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 diff --git a/include/db/foodrepository.h b/include/db/foodrepository.h new file mode 100644 index 0000000..f90fc2b --- /dev/null +++ b/include/db/foodrepository.h @@ -0,0 +1,50 @@ +#ifndef FOODREPOSITORY_H +#define FOODREPOSITORY_H + +#include +#include +#include + +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 nutrients; // Full details for results +}; + +class FoodRepository { +public: + explicit FoodRepository(); + + // Search foods by keyword + std::vector searchFoods(const QString &query); + + // Get detailed nutrients for a generic food (100g) + // Returns a list of nutrients + std::vector 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 m_cache; +}; + +#endif // FOODREPOSITORY_H diff --git a/include/mainwindow.h b/include/mainwindow.h new file mode 100644 index 0000000..7253a88 --- /dev/null +++ b/include/mainwindow.h @@ -0,0 +1,26 @@ +#ifndef MAINWINDOW_H +#define MAINWINDOW_H + +#include "widgets/detailswidget.h" +#include "widgets/mealwidget.h" +#include "widgets/searchwidget.h" +#include +#include + +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 diff --git a/include/utils/string_utils.h b/include/utils/string_utils.h new file mode 100644 index 0000000..58c07b1 --- /dev/null +++ b/include/utils/string_utils.h @@ -0,0 +1,19 @@ +#ifndef STRING_UTILS_H +#define STRING_UTILS_H + +#include +#include +#include + +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 diff --git a/include/widgets/detailswidget.h b/include/widgets/detailswidget.h new file mode 100644 index 0000000..51b74e6 --- /dev/null +++ b/include/widgets/detailswidget.h @@ -0,0 +1,34 @@ +#ifndef DETAILSWIDGET_H +#define DETAILSWIDGET_H + +#include "db/foodrepository.h" +#include +#include +#include +#include + +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 diff --git a/include/widgets/mealwidget.h b/include/widgets/mealwidget.h new file mode 100644 index 0000000..cbf163b --- /dev/null +++ b/include/widgets/mealwidget.h @@ -0,0 +1,40 @@ +#ifndef MEALWIDGET_H +#define MEALWIDGET_H + +#include "db/foodrepository.h" +#include +#include +#include +#include +#include + +struct MealItem { + int foodId; + QString name; + double grams; + std::vector 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 mealItems; + FoodRepository repository; +}; + +#endif // MEALWIDGET_H diff --git a/include/widgets/searchwidget.h b/include/widgets/searchwidget.h new file mode 100644 index 0000000..7c95bb3 --- /dev/null +++ b/include/widgets/searchwidget.h @@ -0,0 +1,32 @@ +#ifndef SEARCHWIDGET_H +#define SEARCHWIDGET_H + +#include "db/foodrepository.h" +#include +#include +#include +#include +#include + +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 diff --git a/nutra.desktop b/nutra.desktop new file mode 100644 index 0000000..099e32c --- /dev/null +++ b/nutra.desktop @@ -0,0 +1,10 @@ +[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; diff --git a/resources.qrc b/resources.qrc new file mode 100644 index 0000000..d9595b2 --- /dev/null +++ b/resources.qrc @@ -0,0 +1,6 @@ + + + resources/nutrition_icon-no_bg.png + + + diff --git a/resources/nutrition_icon-no_bg.png b/resources/nutrition_icon-no_bg.png new file mode 100644 index 0000000..aa28f0c Binary files /dev/null and b/resources/nutrition_icon-no_bg.png differ diff --git a/src/db/databasemanager.cpp b/src/db/databasemanager.cpp new file mode 100644 index 0000000..abda07d --- /dev/null +++ b/src/db/databasemanager.cpp @@ -0,0 +1,43 @@ +#include "db/databasemanager.h" +#include +#include +#include + +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; } diff --git a/src/db/foodrepository.cpp b/src/db/foodrepository.cpp new file mode 100644 index 0000000..3d18417 --- /dev/null +++ b/src/db/foodrepository.cpp @@ -0,0 +1,177 @@ +#include "db/foodrepository.h" +#include "db/databasemanager.h" +#include +#include +#include +#include +#include + +FoodRepository::FoodRepository() {} + +#include "utils/string_utils.h" +#include + +// ... + +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 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 FoodRepository::searchFoods(const QString &query) { + ensureCacheLoaded(); + std::vector 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 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 resultIds; + std::map 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(res.nutrients.size()); + // TODO: Logic for amino/flav counts if we have ranges of IDs + } + } + + return results; +} + +std::vector FoodRepository::getFoodNutrients(int foodId) { + std::vector 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; +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..a6857d0 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,68 @@ +#include "db/databasemanager.h" +#include "mainwindow.h" +#include +#include +#include +#include +#include +#include +#include + +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(); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp new file mode 100644 index 0000000..4cf1803 --- /dev/null +++ b/src/mainwindow.cpp @@ -0,0 +1,52 @@ +#include "mainwindow.h" +#include + +#include +#include +#include + +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); + }); +} diff --git a/src/utils/string_utils.cpp b/src/utils/string_utils.cpp new file mode 100644 index 0000000..a6d1635 --- /dev/null +++ b/src/utils/string_utils.cpp @@ -0,0 +1,112 @@ +#include "utils/string_utils.h" +#include +#include +#include +#include // Required for std::max +#include + +#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> dp(m + 1, std::vector(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(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(dist) / maxLen); + score = static_cast(ratio * 100); + } + + maxTokenScore = std::max(maxTokenScore, score); + } + totalScore += maxTokenScore; + if (maxTokenScore > 60) + matchedTokens++; + } + + if (queryTokens.isEmpty()) { + return 0; + } + + int averageScore = static_cast(totalScore / queryTokens.size()); + + // Penalize if not all tokens matched somewhat well + if (matchedTokens < queryTokens.size()) { + averageScore -= 20; + } + + return std::max(0, averageScore); +} + +} // namespace Utils diff --git a/src/widgets/detailswidget.cpp b/src/widgets/detailswidget.cpp new file mode 100644 index 0000000..c775d05 --- /dev/null +++ b/src/widgets/detailswidget.cpp @@ -0,0 +1,63 @@ +#include "widgets/detailswidget.h" +#include +#include +#include +#include + +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 nutrients = repository.getFoodNutrients(foodId); + + nutrientsTable->setRowCount(static_cast(nutrients.size())); + for (int i = 0; i < static_cast(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); + } +} diff --git a/src/widgets/mealwidget.cpp b/src/widgets/mealwidget.cpp new file mode 100644 index 0000000..9078ebd --- /dev/null +++ b/src/widgets/mealwidget.cpp @@ -0,0 +1,98 @@ +#include "widgets/mealwidget.h" +#include +#include +#include +#include +#include + +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 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 totals; // id -> amount + std::map units; + std::map 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(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++; + } +} diff --git a/src/widgets/searchwidget.cpp b/src/widgets/searchwidget.cpp new file mode 100644 index 0000000..a035191 --- /dev/null +++ b/src/widgets/searchwidget.cpp @@ -0,0 +1,85 @@ +#include "widgets/searchwidget.h" +#include +#include +#include +#include + +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 results = repository.searchFoods(query); + + resultsTable->setRowCount(static_cast(results.size())); + for (int i = 0; i < static_cast(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()); + } +} diff --git a/tests/test_foodrepository.cpp b/tests/test_foodrepository.cpp new file mode 100644 index 0000000..60a11c2 --- /dev/null +++ b/tests/test_foodrepository.cpp @@ -0,0 +1,58 @@ +#include "db/databasemanager.h" +#include "db/foodrepository.h" +#include +#include +#include + +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" diff --git a/usdasqlite b/usdasqlite new file mode 160000 index 0000000..4644288 --- /dev/null +++ b/usdasqlite @@ -0,0 +1 @@ +Subproject commit 4644288e1cfc0bac44dd3a0bfdaafa5bd72cf819