]> Nutra Git (v2) - nutratech/gui.git/commitdiff
initial commit master 1/head v0.0.0-alpha.0
authorShane Jaroch <chown_tee@proton.me>
Tue, 20 Jan 2026 21:02:48 +0000 (16:02 -0500)
committerShane Jaroch <chown_tee@proton.me>
Tue, 20 Jan 2026 21:02:48 +0000 (16:02 -0500)
- 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)

33 files changed:
.clang-tidy [new file with mode: 0644]
.clangd [new file with mode: 0644]
.github/workflows/arch.yml.disabled [new file with mode: 0644]
.github/workflows/ci-full.yml [new file with mode: 0644]
.github/workflows/macos.yml [new file with mode: 0644]
.github/workflows/ubuntu-20.04.yml [new file with mode: 0644]
.github/workflows/ubuntu-22.04.yml [new file with mode: 0644]
.github/workflows/ubuntu-24.04.yml [new file with mode: 0644]
.github/workflows/windows.yml [new file with mode: 0644]
.gitignore [new file with mode: 0644]
.gitmodules [new file with mode: 0644]
CMakeLists.txt [new file with mode: 0644]
Makefile [new file with mode: 0644]
include/db/databasemanager.h [new file with mode: 0644]
include/db/foodrepository.h [new file with mode: 0644]
include/mainwindow.h [new file with mode: 0644]
include/utils/string_utils.h [new file with mode: 0644]
include/widgets/detailswidget.h [new file with mode: 0644]
include/widgets/mealwidget.h [new file with mode: 0644]
include/widgets/searchwidget.h [new file with mode: 0644]
nutra.desktop [new file with mode: 0644]
resources.qrc [new file with mode: 0644]
resources/nutrition_icon-no_bg.png [new file with mode: 0644]
src/db/databasemanager.cpp [new file with mode: 0644]
src/db/foodrepository.cpp [new file with mode: 0644]
src/main.cpp [new file with mode: 0644]
src/mainwindow.cpp [new file with mode: 0644]
src/utils/string_utils.cpp [new file with mode: 0644]
src/widgets/detailswidget.cpp [new file with mode: 0644]
src/widgets/mealwidget.cpp [new file with mode: 0644]
src/widgets/searchwidget.cpp [new file with mode: 0644]
tests/test_foodrepository.cpp [new file with mode: 0644]
usdasqlite [new submodule]

diff --git a/.clang-tidy b/.clang-tidy
new file mode 100644 (file)
index 0000000..9c64a3b
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..f8e5ea6
--- /dev/null
@@ -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 (file)
index 0000000..9d628df
--- /dev/null
@@ -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 (file)
index 0000000..b20da2d
--- /dev/null
@@ -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 (file)
index 0000000..c3f75f6
--- /dev/null
@@ -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 (file)
index 0000000..0ec2dc6
--- /dev/null
@@ -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 (file)
index 0000000..0395eff
--- /dev/null
@@ -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 (file)
index 0000000..0139cb1
--- /dev/null
@@ -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 (file)
index 0000000..b25244e
--- /dev/null
@@ -0,0 +1,10 @@
+# macOS/backup files
+.~*
+._*
+~$*
+.DS_Store
+*.swp
+
+# C++ stuff
+build/
+
diff --git a/.gitmodules b/.gitmodules
new file mode 100644 (file)
index 0000000..ae4efcd
--- /dev/null
@@ -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 (file)
index 0000000..aaec69d
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..32ad4ea
--- /dev/null
@@ -0,0 +1,25 @@
+#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
diff --git a/include/db/foodrepository.h b/include/db/foodrepository.h
new file mode 100644 (file)
index 0000000..f90fc2b
--- /dev/null
@@ -0,0 +1,50 @@
+#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
diff --git a/include/mainwindow.h b/include/mainwindow.h
new file mode 100644 (file)
index 0000000..7253a88
--- /dev/null
@@ -0,0 +1,26 @@
+#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
diff --git a/include/utils/string_utils.h b/include/utils/string_utils.h
new file mode 100644 (file)
index 0000000..58c07b1
--- /dev/null
@@ -0,0 +1,19 @@
+#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
diff --git a/include/widgets/detailswidget.h b/include/widgets/detailswidget.h
new file mode 100644 (file)
index 0000000..51b74e6
--- /dev/null
@@ -0,0 +1,34 @@
+#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
diff --git a/include/widgets/mealwidget.h b/include/widgets/mealwidget.h
new file mode 100644 (file)
index 0000000..cbf163b
--- /dev/null
@@ -0,0 +1,40 @@
+#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
diff --git a/include/widgets/searchwidget.h b/include/widgets/searchwidget.h
new file mode 100644 (file)
index 0000000..7c95bb3
--- /dev/null
@@ -0,0 +1,32 @@
+#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
diff --git a/nutra.desktop b/nutra.desktop
new file mode 100644 (file)
index 0000000..099e32c
--- /dev/null
@@ -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 (file)
index 0000000..d9595b2
--- /dev/null
@@ -0,0 +1,6 @@
+<RCC>
+    <qresource prefix="/">
+        <file>resources/nutrition_icon-no_bg.png</file>
+    </qresource>
+
+</RCC>
diff --git a/resources/nutrition_icon-no_bg.png b/resources/nutrition_icon-no_bg.png
new file mode 100644 (file)
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 (file)
index 0000000..abda07d
--- /dev/null
@@ -0,0 +1,43 @@
+#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; }
diff --git a/src/db/foodrepository.cpp b/src/db/foodrepository.cpp
new file mode 100644 (file)
index 0000000..3d18417
--- /dev/null
@@ -0,0 +1,177 @@
+#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;
+}
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644 (file)
index 0000000..a6857d0
--- /dev/null
@@ -0,0 +1,68 @@
+#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();
+}
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
new file mode 100644 (file)
index 0000000..4cf1803
--- /dev/null
@@ -0,0 +1,52 @@
+#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);
+          });
+}
diff --git a/src/utils/string_utils.cpp b/src/utils/string_utils.cpp
new file mode 100644 (file)
index 0000000..a6d1635
--- /dev/null
@@ -0,0 +1,112 @@
+#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
diff --git a/src/widgets/detailswidget.cpp b/src/widgets/detailswidget.cpp
new file mode 100644 (file)
index 0000000..c775d05
--- /dev/null
@@ -0,0 +1,63 @@
+#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);
+  }
+}
diff --git a/src/widgets/mealwidget.cpp b/src/widgets/mealwidget.cpp
new file mode 100644 (file)
index 0000000..9078ebd
--- /dev/null
@@ -0,0 +1,98 @@
+#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++;
+  }
+}
diff --git a/src/widgets/searchwidget.cpp b/src/widgets/searchwidget.cpp
new file mode 100644 (file)
index 0000000..a035191
--- /dev/null
@@ -0,0 +1,85 @@
+#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());
+  }
+}
diff --git a/tests/test_foodrepository.cpp b/tests/test_foodrepository.cpp
new file mode 100644 (file)
index 0000000..60a11c2
--- /dev/null
@@ -0,0 +1,58 @@
+#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"
diff --git a/usdasqlite b/usdasqlite
new file mode 160000 (submodule)
index 0000000..4644288
--- /dev/null
@@ -0,0 +1 @@
+Subproject commit 4644288e1cfc0bac44dd3a0bfdaafa5bd72cf819