diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..3f1dc11 --- /dev/null +++ b/.clang-format @@ -0,0 +1,7 @@ +BasedOnStyle: Google +IndentWidth: 4 +TabWidth: 4 +AccessModifierOffset: -4 +ColumnLimit: 100 +AllowShortFunctionsOnASingleLine: Empty +KeepEmptyLinesAtTheStartOfBlocks: false diff --git a/.github/workflows/arch.yml.disabled b/.github/workflows/arch.yml.disabled index f8e5ea6..b8f7e85 100644 --- a/.github/workflows/arch.yml.disabled +++ b/.github/workflows/arch.yml.disabled @@ -2,7 +2,7 @@ name: Arch Linux on: push: - branches: [main, master, dev] + branches: [main, master] pull_request: branches: [main, master] diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index 9d628df..8d8797d 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -1,8 +1,9 @@ +--- name: Full CI on: push: - branches: [main, master, dev] + branches: [main, master] pull_request: branches: [main, master] @@ -21,7 +22,28 @@ jobs: 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 + clang-format cppcheck clang-tidy sqlite3 + + - name: Install LinuxDeploy + run: | + # LinuxDeploy + wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-x86_64.AppImage + echo "c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d linuxdeploy-x86_64.AppImage" | sha256sum -c - + + # Qt Plugin + wget -q https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/1-alpha-20250213-1/linuxdeploy-plugin-qt-x86_64.AppImage + echo "15106be885c1c48a021198e7e1e9a48ce9d02a86dd0a1848f00bdbf3c1c92724 linuxdeploy-plugin-qt-x86_64.AppImage" | sha256sum -c - + + chmod +x linuxdeploy-x86_64.AppImage + chmod +x linuxdeploy-plugin-qt-x86_64.AppImage + sudo mv linuxdeploy-x86_64.AppImage /usr/local/bin/linuxdeploy + sudo mv linuxdeploy-plugin-qt-x86_64.AppImage /usr/local/bin/linuxdeploy-plugin-qt + + - name: Build NTDB + run: make build-db + + - name: Test NTDB + run: make test-db - name: Check Formatting run: | @@ -33,8 +55,14 @@ jobs: - name: Lint run: make lint - - name: Build Release - run: make release + - name: Build AppImage + run: make appimage - name: Test run: make test + + - name: Upload AppImage + uses: actions/upload-artifact@v4 + with: + name: nutra-linux-appimage + path: build/*.AppImage diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index b20da2d..4dd34b1 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -1,8 +1,9 @@ +--- name: macOS on: push: - branches: [main, master, dev] + branches: [main, master] pull_request: branches: [main, master] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2308604 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,177 @@ +--- +name: Release Build + +on: + push: + tags: + - "v*" + +jobs: + build-linux-20-04: + name: "Linux (Ubuntu 20.04)" + runs-on: ubuntu-20.04 + 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 \ + qtbase5-dev libqt5sql5-sqlite libgl1-mesa-dev + + - name: Build + run: make release + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: nutra-linux-20.04 + path: build/nutra + + build-linux-22-04: + name: "Linux (Ubuntu 22.04)" + runs-on: ubuntu-22.04 + 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 \ + qt6-base-dev libqt6sql6 libqt6sql6-sqlite libgl1-mesa-dev + + - name: Build + run: make release + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: nutra-linux-22.04 + path: build/nutra + + build-linux-24-04: + name: "Linux (Ubuntu 24.04)" + runs-on: ubuntu-24.04 + 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 \ + qt6-base-dev libqt6sql6 libqt6sql6-sqlite libgl1-mesa-dev + + - name: Build + run: make release + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: nutra-linux-24.04 + path: build/nutra + + build-windows: + name: "Windows" + 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: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: nutra-win64.exe + path: build/Release/nutra.exe + + build-macos: + name: "macOS" + 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 + run: make release + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: nutra-macos.app + path: build/nutra.app + + release: + name: "Create Release" + needs: + [ + build-linux-20-04, + build-linux-22-04, + build-linux-24-04, + build-windows, + build-macos, + ] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify tag is on master + run: | + git fetch origin master + if ! git merge-base --is-ancestor ${{ github.ref_name }} origin/master; then + echo "Error: Tag ${{ github.ref_name }} is not on master branch." + exit 1 + fi + + - name: Check for Pre-release + id: check_prerelease + run: | + if [[ "${{ github.ref_name }}" == *"-"* ]]; then + echo "is_prerelease=true" >> $GITHUB_OUTPUT + echo "Detected pre-release tag." + else + echo "is_prerelease=false" >> $GITHUB_OUTPUT + echo "Detected stable release tag." + fi + + - name: Download Artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }} + files: | + artifacts/nutra-linux-20.04/nutra + artifacts/nutra-linux-22.04/nutra + artifacts/nutra-linux-24.04/nutra + artifacts/nutra-win64.exe/nutra.exe + artifacts/nutra-macos.app/** + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ubuntu-20.04.yml b/.github/workflows/ubuntu-20.04.yml index c3f75f6..33b3661 100644 --- a/.github/workflows/ubuntu-20.04.yml +++ b/.github/workflows/ubuntu-20.04.yml @@ -3,7 +3,7 @@ name: Ubuntu 20.04 on: push: - branches: [main, master, dev] + branches: [main, master] pull_request: branches: [main, master] @@ -23,7 +23,7 @@ jobs: - name: install dependencies run: | apt -y install git cmake build-essential ccache \ - qtbase5-dev libqt5sql5-sqlite libgl1-mesa-dev + qtbase5-dev libqt5sql5-sqlite libgl1-mesa-dev sqlite3 python3 - name: configure git run: git config --global --add safe.directory '*' @@ -32,6 +32,12 @@ jobs: with: submodules: recursive + - name: Build NTDB + run: make build-db + + - name: Test NTDB + run: make test-db + - name: Build Release run: make release diff --git a/.github/workflows/ubuntu-22.04.yml b/.github/workflows/ubuntu-22.04.yml index 0ec2dc6..7b81ca0 100644 --- a/.github/workflows/ubuntu-22.04.yml +++ b/.github/workflows/ubuntu-22.04.yml @@ -3,7 +3,7 @@ name: Ubuntu 22.04 on: push: - branches: [main, master, dev] + branches: [main, master] pull_request: branches: [main, master] @@ -13,6 +13,16 @@ jobs: runs-on: [self-hosted, ubuntu-22.04] steps: + # NOTE: issue resolved in CMakeLists.txt + # - name: Debug Environment + # run: | + # echo "User: $(whoami)" + # echo "PWD: $(pwd)" + # echo "CI env var: $CI" + # ls -lah /usr/lib/x86_64-linux-gnu/libkeyutils.so* || echo "Not found in /usr/lib..." + # ls -lah /lib/x86_64-linux-gnu/libkeyutils.so.1* || echo "Not found in /lib..." + # /sbin/ldconfig -p | grep libkeyutils || echo "ldconfig failed" + # NOTE: Dependencies are already installed on the dev runner # - name: update apt # run: sudo apt-get update @@ -26,6 +36,15 @@ jobs: with: submodules: recursive + - name: Clean (between runs on VPS) + run: make clean + + - name: Build NTDB + run: make build-db + + - name: Test NTDB + run: make test-db + - name: Build Release run: make release diff --git a/.github/workflows/ubuntu-24.04.yml b/.github/workflows/ubuntu-24.04.yml index 0395eff..5ce5050 100644 --- a/.github/workflows/ubuntu-24.04.yml +++ b/.github/workflows/ubuntu-24.04.yml @@ -3,7 +3,7 @@ name: Ubuntu 24.04 on: push: - branches: [main, master, dev] + branches: [main, master] pull_request: branches: [main, master] @@ -26,6 +26,12 @@ jobs: with: submodules: recursive + - name: Build NTDB + run: make build-db + + - name: Test NTDB + run: make test-db + - name: Build Release run: make release diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 0000000..3923f22 --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -0,0 +1,41 @@ +--- +name: Bump Version + +on: + workflow_dispatch: + inputs: + bump_type: + description: "How to bump version" + required: true + default: "patch" + type: choice + options: + - patch + - minor + - major + pre_release_type: + description: "Pre-release type (optional)" + required: false + default: "none" + type: choice + options: + - none + - beta + - rc + +jobs: + bump-version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Git Config + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump Version + run: ./scripts/ci-version-bump.sh "${{ inputs.bump_type }}" "${{ inputs.pre_release_type }}" --push diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 0139cb1..a0fbf91 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -1,8 +1,9 @@ +--- name: Windows on: push: - branches: [main, master, dev] + branches: [main, master] pull_request: branches: [main, master] diff --git a/.gitignore b/.gitignore index b25244e..2c49320 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,26 @@ # C++ stuff build/ + +# CMake +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +install_manifest.txt +CTestTestfile.cmake +Makefile + +# Qt +*_autogen/ +*.qrc.cpp + +# Generated binaries and libs +*.a +*.so +*.dylib +*.exe + +# Specific executables (if in-source build happens) +/nutra +/test_* +/nutra.desktop diff --git a/.gitmodules b/.gitmodules index ae4efcd..46cf79b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ -[submodule "usdasqlite"] - path = usdasqlite +[submodule "lib/usdasqlite"] + path = lib/usdasqlite url = https://github.com/nutratech/usda-sqlite.git +[submodule "lib/ntsqlite"] + path = lib/ntsqlite + url = https://github.com/nutratech/nt-sqlite.git +[submodule "lib/pylang_serv"] + path = lib/pylang_serv + url = https://git.nutra.tk/nutratech/search-server.git diff --git a/CMakeLists.txt b/CMakeLists.txt index aaec69d..6e0f9db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,60 +5,148 @@ project(nutra LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) +# --- SECURITY FIX: Bypass compromised system symlink --- +if(EXISTS "/lib/x86_64-linux-gnu/libkeyutils.so.1.9") + message(STATUS "Security Bypass: Forcing linkage to explicit libkeyutils.so.1.9") + add_library(SystemKeyUtils SHARED IMPORTED) + set_target_properties(SystemKeyUtils PROPERTIES IMPORTED_LOCATION "/lib/x86_64-linux-gnu/libkeyutils.so.1.9") +endif() +# ------------------------------------------------------- + set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) -# Find Qt6 or Qt5 +# Find Qt find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets Sql) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets Sql) +find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Network) -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 -) +if(Qt${QT_VERSION_MAJOR}Network_FOUND) + message(STATUS "Qt Network module found. Enabling NLP interactions.") + add_compile_definitions(NUTRA_HAS_NETWORK) +else() + message(WARNING "Qt Network module NOT found. NLP interactions will be disabled.") +endif() +# --- AUTOMATIC SOURCE DISCOVERY --- +# 'CONFIGURE_DEPENDS' ensures CMake re-runs if you add new files +file(GLOB_RECURSE CORE_SOURCES CONFIGURE_DEPENDS "src/db/*.cpp" "src/utils/*.cpp" "include/db/*.h" "include/utils/*.h") +file(GLOB_RECURSE UI_SOURCES CONFIGURE_DEPENDS "src/widgets/*.cpp" "src/mainwindow.cpp" "src/main.cpp" "include/widgets/*.h" "include/mainwindow.h") +# Conditionally remove/add PythonServiceManager based on Network module +if(NOT Qt${QT_VERSION_MAJOR}Network_FOUND) + # Filter out the python service manager if we don't have network + list(FILTER CORE_SOURCES EXCLUDE REGEX "pythonservicemanager.cpp") +endif() +# Versioning Logic +if(NOT NUTRA_VERSION) + execute_process( + COMMAND git describe --tags --always + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GIT_VERSION + ERROR_QUIET + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + set(NUTRA_VERSION "${GIT_VERSION}") + if(NOT NUTRA_VERSION) + set(NUTRA_VERSION "v0.0.0-unknown") + endif() +endif() +add_compile_definitions(NUTRA_VERSION_STRING="${NUTRA_VERSION}") + +# --- ASSETS: Copy SQL/SQLite files from libraries --- + +# 1. ntsqlite: Copy tables.sql +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/lib/ntsqlite/sql") +file(COPY "${CMAKE_SOURCE_DIR}/lib/ntsqlite/sql/" + DESTINATION "${CMAKE_BINARY_DIR}/lib/ntsqlite/sql" + FILES_MATCHING PATTERN "*.sql" PATTERN "*.sqlite3") + +# 2. usdasqlite: Copy usda.sqlite3 and schema files +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/lib/usdasqlite/sql") +file(COPY "${CMAKE_SOURCE_DIR}/lib/usdasqlite/sql/" + DESTINATION "${CMAKE_BINARY_DIR}/lib/usdasqlite/sql" + FILES_MATCHING PATTERN "*.sql" PATTERN "*.sqlite3") +# ---------------------------------------------------- + +# Main Executable +add_executable(nutra ${CORE_SOURCES} ${UI_SOURCES} "resources.qrc") +target_include_directories(nutra PUBLIC ${CMAKE_SOURCE_DIR}/include) +target_link_libraries(nutra PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Sql) + +if(Qt${QT_VERSION_MAJOR}Network_FOUND) + target_link_libraries(nutra PRIVATE Qt${QT_VERSION_MAJOR}::Network) +endif() +# --- SECURITY FIX LINKING --- +if(TARGET SystemKeyUtils) + target_link_libraries(nutra PRIVATE SystemKeyUtils) +endif() +# Testing +enable_testing() +find_package(Qt${QT_VERSION_MAJOR}Test REQUIRED) -add_executable(nutra - ${PROJECT_SOURCES} -) +# Dynamic Test Discovery +file(GLOB TEST_SOURCES CONFIGURE_DEPENDS "tests/test_*.cpp") -target_include_directories(nutra PRIVATE ${CMAKE_SOURCE_DIR}/include) +add_custom_target(build_tests) -target_link_libraries(nutra PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt${QT_VERSION_MAJOR}::Sql) +foreach(TEST_SOURCE ${TEST_SOURCES}) + get_filename_component(TEST_NAME ${TEST_SOURCE} NAME_WE) -enable_testing() -find_package(Qt${QT_VERSION_MAJOR}Test REQUIRED) + add_executable(${TEST_NAME} EXCLUDE_FROM_ALL ${TEST_SOURCE} ${CORE_SOURCES}) + target_include_directories(${TEST_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/include) + target_link_libraries(${TEST_NAME} PRIVATE Qt${QT_VERSION_MAJOR}::Test Qt${QT_VERSION_MAJOR}::Sql) + if(Qt${QT_VERSION_MAJOR}Network_FOUND) + target_link_libraries(${TEST_NAME} PRIVATE Qt${QT_VERSION_MAJOR}::Network) + endif() -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) + if(TARGET SystemKeyUtils) + target_link_libraries(${TEST_NAME} PRIVATE SystemKeyUtils) + endif() -add_test(NAME FoodRepoTest COMMAND test_nutra) + add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) + add_dependencies(build_tests ${TEST_NAME}) +endforeach() +# Installation +include(GNUInstallDirs) +set(NUTRA_EXECUTABLE "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_BINDIR}/nutra") +configure_file(resources/nutra.desktop.in ${CMAKE_BINARY_DIR}/nutra.desktop @ONLY) -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) +install(TARGETS nutra DESTINATION ${CMAKE_INSTALL_BINDIR}) +install(FILES ${CMAKE_BINARY_DIR}/nutra.desktop DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/applications) +install(FILES resources/nutrition_icon-no_bg.png DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/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() + +# Install Python NLP service +if(EXISTS "${CMAKE_SOURCE_DIR}/lib/pylang_serv/pylang_serv") + install(DIRECTORY lib/pylang_serv/pylang_serv + DESTINATION share/nutra/pylang_serv + FILES_MATCHING PATTERN "*.py") + if(EXISTS "${CMAKE_SOURCE_DIR}/lib/pylang_serv/pyproject.toml") + install(FILES lib/pylang_serv/pyproject.toml + DESTINATION share/nutra/pylang_serv) + endif() +endif() + +# AppImage generation +find_program(LINUXDEPLOY linuxdeploy) + +if(LINUXDEPLOY) + add_custom_target(appimage + COMMAND ${CMAKE_COMMAND} --install . --prefix AppDir/usr + COMMAND sed -i "s|^Exec=.*|Exec=nutra|" AppDir/usr/share/applications/nutra.desktop + COMMAND ${LINUXDEPLOY} --appdir=AppDir --plugin=qt --output=appimage + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + COMMENT "Generating AppImage..." + VERBATIM + ) + add_dependencies(appimage nutra) +endif() diff --git a/Makefile b/Makefile index d10b8fc..79ac8aa 100644 --- a/Makefile +++ b/Makefile @@ -4,27 +4,51 @@ CMAKE := cmake CTEST := ctest SRC_DIRS := src +# Get version from git +VERSION := $(shell git describe --tags --always 2>/dev/null || echo "v0.0.0") + # Find source files for linting LINT_LOCS_CPP ?= $(shell git ls-files '*.cpp') LINT_LOCS_H ?= $(shell git ls-files '*.h') +PYLANG_SERV_PROJECT_ROOT ?= lib/pylang_serv +NTSQLITE_PROJECT_ROOT ?= lib/ntsqlite + +.PHONY: build-db +build-db: + @$(MAKE) -C $(NTSQLITE_PROJECT_ROOT) build + +.PHONY: test-db +test-db: + @$(MAKE) -C $(NTSQLITE_PROJECT_ROOT) test + +# Detect number of cores for parallel build +NPROC := $(shell nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 1) + .PHONY: config config: $(CMAKE) -E make_directory $(BUILD_DIR) $(CMAKE) \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DNUTRA_VERSION="$(VERSION)" \ -B $(BUILD_DIR) .PHONY: debug debug: config - $(CMAKE) --build $(BUILD_DIR) --config Debug + $(CMAKE) --build $(BUILD_DIR) --config Debug --parallel $(NPROC) .PHONY: release release: $(CMAKE) -E make_directory $(BUILD_DIR) - $(CMAKE) -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=Release - $(CMAKE) --build $(BUILD_DIR) --config Release + $(CMAKE) -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=Release -DNUTRA_VERSION="$(VERSION)" + $(CMAKE) --build $(BUILD_DIR) --config Release --parallel $(NPROC) + +.PHONY: appimage +appimage: + $(CMAKE) -B build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release + $(CMAKE) --build build -j$(NPROC) + $(CMAKE) --build build --target appimage .PHONY: clean clean: @@ -33,7 +57,7 @@ clean: .PHONY: test test: release - $(CMAKE) --build $(BUILD_DIR) --target test_nutra --config Release + $(CMAKE) --build $(BUILD_DIR) --target build_tests --config Release cd $(BUILD_DIR) && $(CTEST) --output-on-failure -C Release .PHONY: run @@ -44,19 +68,21 @@ run: debug .PHONY: format format: -prettier --write .github/ + -shfmt -w scripts/*.sh clang-format -i $(LINT_LOCS_CPP) $(LINT_LOCS_H) + -cd $(PYLANG_SERV_PROJECT_ROOT) && make format .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 + @$(CMAKE) --build $(BUILD_DIR) --target build_tests --config Debug 2>/dev/null || true @echo "Running cppcheck..." cppcheck --enable=warning,performance,portability \ --language=c++ --std=c++17 \ --suppress=missingIncludeSystem \ - -Dslots= -Dsignals= -Demit= \ + -Dslots= -Dsignals= -Demit= -DQT_VERSION_CHECK\(major,minor,patch\)=0 \ --quiet --error-exitcode=1 \ $(SRC_DIRS) include tests @if [ ! -f $(BUILD_DIR)/compile_commands.json ]; then \ @@ -67,7 +93,7 @@ lint: config @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 + @echo $(LINT_LOCS_CPP) $(LINT_LOCS_H) | xargs -n 1 -P $(NPROC) clang-tidy --quiet -p $(BUILD_DIR)/lint_tmp -extra-arg=-Wno-unknown-argument @rm -rf $(BUILD_DIR)/lint_tmp @@ -78,3 +104,28 @@ install: release $(CMAKE) -DCMAKE_INSTALL_PREFIX=$(HOME)/.local -B $(BUILD_DIR) && \ $(MAKE) -C $(BUILD_DIR) install \ ) + +# Version bumping +.PHONY: version +version: + @./scripts/ci-version-bump.sh + +.PHONY: version-patch +version-patch: + @./scripts/ci-version-bump.sh patch none --tag + @echo "Run 'git push --tags' to publish." + +.PHONY: version-minor +version-minor: + @./scripts/ci-version-bump.sh minor none --tag + @echo "Run 'git push --tags' to publish." + +.PHONY: version-major +version-major: + @./scripts/ci-version-bump.sh major none --tag + @echo "Run 'git push --tags' to publish." + +.PHONY: version-beta +version-beta: + @./scripts/ci-version-bump.sh patch beta --tag + @echo "Run 'git push --tags' to publish." diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..8f1bea9 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,79 @@ +Based on your current setup—which utilizes `usda-sqlite` to process the USDA SR-Legacy database—you have a solid foundation for commodity ingredients (raw fruits, vegetables, meats). However, SR-Legacy is static (stopped updating in 2018) and lacks the vast universe of branded products (UPC scanning) and modern micronutrient density data. + +To augment this for a "good user experience" (effortless search + rich data), you should integrate the following open-source and public APIs. + +### 1. USDA FoodData Central (FDC) API + +Since your project is already built on USDA logic, this is the most seamless integration. SR-Legacy (which you currently use) is now just one subset of FoodData Central. + +* **Why use it:** It includes **"Branded Foods"** (over 300,000 items from labels) and **"Foundation Foods"** (newer, highly granular micronutrient data that replaces SR-Legacy). +* **Integration Strategy:** +* Your `food_des` table uses `fdgrp_id`. FDC uses `fdcId`. You can create a mapping table to link your legacy IDs to new FDC IDs. +* **Search:** The FDC API allows for keyword search which returns `fdcId`. You can use this to backfill missing nutrients in your `nut_data` table. + +* **Cost/License:** Free, Public Domain (U.S. Government). + +### 2. Open Food Facts (OFF) + +This is the "Wikipedia of food." It is the best open-source resource for scanning barcodes and finding branded products internationally. + +* **Why use it:** +* **Barcodes:** It relies heavily on UPC/EAN codes. If your UX involves a camera/scanner, this is essential. +* **Crowdsourced:** It covers niche brands that the USDA might miss. +* **Nutri-Score:** It provides calculated scores (Nutri-Score, NOVA group) which you can store in your `food_des` or a new `food_metadata` table. + +* **Integration Strategy:** +* Query by barcode: `https://world.openfoodfacts.org/api/v0/product/[barcode].json` +* Map their JSON `nutriments` object to your `nutr_def` IDs (e.g., map OFF `saturated-fat_100g` to your `nutr_def` ID for saturated fat). + +* **Cost/License:** Free, Open Database License (ODbL). + +### 3. SQLite FTS5 (Full-Text Search) + +You are currently using SQLite. To make search "effortless" without calling an external API for every keystroke, you should utilize SQLite's native Full-Text Search extension. + +* **Why use it:** Your `food_des` table contains `long_desc`, `shrt_desc`, and `com_name`. Standard SQL `LIKE` queries are slow and bad at matching "natural" language. FTS5 allows for lightning-fast, ranked search results (e.g., typing "chk brst" finds "Chicken Breast"). +* **Integration:** +* Modify your `sql/tables.sql` to include a virtual table: + +```sql +CREATE VIRTUAL TABLE food_search USING fts5(long_desc, shrt_desc, com_name, content=food_des, content_rowid=id); + +``` + +* Add a trigger to keep it updated. This will make your local search instant. + +### 4. Natural Language Processing (NLP) Parsers + +If "effortless search" means the user types "1 cup of oatmeal with 2 tbsp honey," you need a parser, not just a database. + +* **New York Times Ingredient Phrase Tagger (CRF++):** A structured learning model released by NYT to parse ingredient lines into amount, unit, and food. +* **Price:** Open Source (Apache 2.0). +* **Integration:** You can run this as a microservice (Python) alongside your `process.py`. When a user types a sentence, parse it to extract the quantity (to calculate `gram` weight) and the food subject (to query your SQLite DB). + +### Recommended Architecture Update + +Given your file structure, here is how you should integrate these sources: + +1. **Augment `nutr_def`:** Ensure your `nutr_def` table aligns with Open Food Facts tag naming conventions (add a column `off_tag` to map `fat` -> `fat_100g`). +2. **New Table `external_links`:** +Don't pollute `food_des` with mixed data. Create a linking table: + +```sql +CREATE TABLE external_links ( + local_food_id INT, + service_name TEXT, -- 'FDC', 'OFF' + external_id TEXT, -- '123456' or UPC + last_updated DATETIME, + FOREIGN KEY(local_food_id) REFERENCES food_des(id) +); + +``` + +1. **Python Script (`data/process.py` extension):** +Write a new script (e.g., `fetch_external.py`) that: + +* Takes a user query. +* Checks local SQLite FTS5 first. +* If no hit, queries Open Food Facts API. +* Inserts the result into `food_des` and `nut_data`, and saves the link in `external_links`. diff --git a/include/db/databasemanager.h b/include/db/databasemanager.h index 32ad4ea..9ac85ac 100644 --- a/include/db/databasemanager.h +++ b/include/db/databasemanager.h @@ -7,19 +7,38 @@ class DatabaseManager { public: - static DatabaseManager &instance(); - bool connect(const QString &path); - [[nodiscard]] bool isOpen() const; - [[nodiscard]] QSqlDatabase database() const; + static DatabaseManager& instance(); + static constexpr int USER_SCHEMA_VERSION = 9; + static constexpr int USDA_SCHEMA_VERSION = 1; // Schema version for USDA data import + static constexpr int APP_ID_USDA = 0x55534441; // 'USDA' (ASCII) + static constexpr int APP_ID_USER = 0x4E544442; // 'NTDB' (ASCII) + bool connect(const QString& path); + [[nodiscard]] bool isOpen() const; + [[nodiscard]] QSqlDatabase database() const; + [[nodiscard]] QSqlDatabase userDatabase() const; + bool isValidNutraDatabase(const QSqlDatabase& db); - DatabaseManager(const DatabaseManager &) = delete; - DatabaseManager &operator=(const DatabaseManager &) = delete; + struct DatabaseInfo { + bool isValid; + QString type; // "USDA" or "User" + int version; + }; + + DatabaseInfo getDatabaseInfo(const QString& path); + + DatabaseManager(const DatabaseManager&) = delete; + DatabaseManager& operator=(const DatabaseManager&) = delete; private: - DatabaseManager(); - ~DatabaseManager(); + DatabaseManager(); + ~DatabaseManager(); + + void initUserDatabase(); + void applySchema(QSqlQuery& query, const QString& schemaPath); + int getSchemaVersion(const QSqlDatabase& db); - QSqlDatabase m_db; + QSqlDatabase m_db; + QSqlDatabase m_userDb; }; -#endif // DATABASEMANAGER_H +#endif // DATABASEMANAGER_H diff --git a/include/db/foodrepository.h b/include/db/foodrepository.h index f90fc2b..eeed937 100644 --- a/include/db/foodrepository.h +++ b/include/db/foodrepository.h @@ -6,45 +6,63 @@ #include struct Nutrient { - int id; - QString description; - double amount; - QString unit; - double rdaPercentage; // Calculated + int id; + QString description; + double amount; + QString unit; + double rdaPercentage; // Calculated +}; + +struct ServingWeight { + QString description; + double grams; }; 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 + int id; + int foodGroupId; + QString description; + QString foodGroupName; + int nutrientCount; + int aminoCount; + int flavCount; + int score; // For search results + std::vector nutrients; // Full details for results }; class FoodRepository { public: - explicit FoodRepository(); + 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); - // Search foods by keyword - std::vector searchFoods(const QString &query); + // Get available serving weights (units) for a food + std::vector getFoodServings(int foodId); - // Get detailed nutrients for a generic food (100g) - // Returns a list of nutrients - std::vector getFoodNutrients(int foodId); + // RDA methods + std::map getNutrientRdas(); + void updateRda(int nutrId, double value); - // Helper to get nutrient definition basics if needed - // QString getNutrientName(int nutrientId); + // Helper to get nutrient definition basics + QString getNutrientName(int nutrientId); + QString getNutrientUnit(int nutrientId); private: - // Internal helper methods - void ensureCacheLoaded(); + // Internal helper methods + void ensureCacheLoaded(); + void loadRdas(); - bool m_cacheLoaded = false; - // Cache stores basic food info - std::vector m_cache; + bool m_cacheLoaded = false; + // Cache stores basic food info + std::vector m_cache; + std::map m_rdas; + std::map m_nutrientNames; + std::map m_nutrientUnits; }; -#endif // FOODREPOSITORY_H +#endif // FOODREPOSITORY_H diff --git a/include/db/mealrepository.h b/include/db/mealrepository.h new file mode 100644 index 0000000..00ca2bf --- /dev/null +++ b/include/db/mealrepository.h @@ -0,0 +1,37 @@ +#ifndef MEALREPOSITORY_H +#define MEALREPOSITORY_H + +#include +#include +#include +#include + +struct MealLogItem { + int id; // log_food.id + int foodId; + double grams; + int mealId; + QString mealName; + // Potentially cached description? + QString foodName; // Joined from food_des if we query it +}; + +class MealRepository { +public: + MealRepository(); + + // Meal Names (Breakfast, Lunch, etc.) + std::map getMealNames(); + + // Logging + void addFoodLog(int foodId, double grams, int mealId, QDate date = QDate::currentDate()); + std::vector getDailyLogs(QDate date = QDate::currentDate()); + void clearDailyLogs(QDate date = QDate::currentDate()); + void removeLogEntry(int logId); + +private: + std::map m_mealNamesCache; + void ensureMealNamesLoaded(); +}; + +#endif // MEALREPOSITORY_H diff --git a/include/db/reciperepository.h b/include/db/reciperepository.h new file mode 100644 index 0000000..1beb30a --- /dev/null +++ b/include/db/reciperepository.h @@ -0,0 +1,51 @@ +#ifndef RECIPEREPOSITORY_H +#define RECIPEREPOSITORY_H + +#include +#include +#include +#include + +struct RecipeItem { + int id; + QString uuid; + QString name; + QString instructions; + QDateTime created; + double totalCalories = 0.0; // Calculated on the fly ideally +}; + +struct RecipeIngredient { + int foodId; + QString foodName; + double amount; // grams + // Potential for more info (calories contribution etc) +}; + +class RecipeRepository { +public: + RecipeRepository(); + + // CRUD + int createRecipe(const QString& name, const QString& instructions = ""); + bool updateRecipe(int id, const QString& name, const QString& instructions); + bool deleteRecipe(int id); + + std::vector getAllRecipes(); + RecipeItem getRecipe(int id); + + void loadCsvRecipes(const QString& directory); + + // Ingredients + bool addIngredient(int recipeId, int foodId, double amount); + bool removeIngredient(int recipeId, int foodId); + bool updateIngredient(int recipeId, int foodId, double amount); + std::vector getIngredients(int recipeId); + +private: + void processCsvFile(const QString& filePath, std::map& recipeMap); + int getOrCreateRecipe(const QString& name, const QString& instructions, + std::map& recipeMap); +}; + +#endif // RECIPEREPOSITORY_H diff --git a/include/mainwindow.h b/include/mainwindow.h index 7253a88..c730afc 100644 --- a/include/mainwindow.h +++ b/include/mainwindow.h @@ -1,26 +1,51 @@ #ifndef MAINWINDOW_H #define MAINWINDOW_H +#include +#include +#include +#include + +#include "widgets/dailylogwidget.h" #include "widgets/detailswidget.h" #include "widgets/mealwidget.h" +#include "widgets/recipewidget.h" #include "widgets/searchwidget.h" -#include -#include class MainWindow : public QMainWindow { - Q_OBJECT + Q_OBJECT public: - MainWindow(QWidget *parent = nullptr); - ~MainWindow() override; + MainWindow(QWidget* parent = nullptr); + ~MainWindow() override; + +private slots: + void onOpenDatabase(); + void onRecentFileClick(); + void onSettings(); + void onAbout(); + void onReloadRecipes(); private: - void setupUi(); + void setupUi(); + void updateRecentFileActions(); + void addToRecentFiles(const QString& path); + + QTabWidget* tabs; + SearchWidget* searchWidget; + DetailsWidget* detailsWidget; + MealWidget* mealWidget; + RecipeWidget* recipeWidget; + DailyLogWidget* dailyLogWidget; + FoodRepository repository; + + QMenu* recentFilesMenu; + static constexpr int MaxRecentFiles = 5; + std::array recentFileActions; - QTabWidget *tabs; - SearchWidget *searchWidget; - DetailsWidget *detailsWidget; - MealWidget *mealWidget; + // Status Bar + QLabel* dbStatusLabel; + void updateStatusBar(); }; -#endif // MAINWINDOW_H +#endif // MAINWINDOW_H diff --git a/include/utils/pythonservicemanager.h b/include/utils/pythonservicemanager.h new file mode 100644 index 0000000..c3fafaf --- /dev/null +++ b/include/utils/pythonservicemanager.h @@ -0,0 +1,62 @@ +#ifndef PYTHONSERVICEMANAGER_H +#define PYTHONSERVICEMANAGER_H + +#include +#include +#include +#include +#include + +/** + * @brief Manages the optional Python NLP microservice. + * + * This is an optional feature that can be enabled/disabled in settings. + * When enabled, spawns a Python Flask server for natural language + * ingredient parsing. + */ +class PythonServiceManager : public QObject { + Q_OBJECT + +public: + static PythonServiceManager& instance(); + + [[nodiscard]] bool isEnabled() const; + void setEnabled(bool enabled); + + [[nodiscard]] bool isRunning() const; + + /** + * @brief Parse an ingredient string using NLP. + * @param text Natural language ingredient (e.g., "2 cups flour") + * + * Emits parseComplete or parseError when done. + */ + void parseIngredient(const QString& text); + +signals: + void serviceStarted(); + void serviceStopped(); + void parseComplete(const QJsonObject& result); + void parseError(const QString& error); + +public slots: + void startService(); + void stopService(); + +private: + explicit PythonServiceManager(QObject* parent = nullptr); + ~PythonServiceManager() override; + + void findPythonPath(); + + QProcess* m_process = nullptr; + QNetworkAccessManager* m_network = nullptr; + QString m_pythonPath; + bool m_enabled = false; + int m_port = 5001; + + static constexpr const char* SETTING_ENABLED = "nlp/enabled"; + static constexpr const char* SETTING_PORT = "nlp/port"; +}; + +#endif // PYTHONSERVICEMANAGER_H diff --git a/include/utils/string_utils.h b/include/utils/string_utils.h index 58c07b1..1c52fdd 100644 --- a/include/utils/string_utils.h +++ b/include/utils/string_utils.h @@ -8,12 +8,12 @@ namespace Utils { // Calculate Levenshtein distance between two strings -int levenshteinDistance(const QString &s1, const QString &s2); +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); +int calculateFuzzyScore(const QString& query, const QString& target); -} // namespace Utils +} // namespace Utils -#endif // STRING_UTILS_H +#endif // STRING_UTILS_H diff --git a/include/widgets/dailylogwidget.h b/include/widgets/dailylogwidget.h new file mode 100644 index 0000000..24fc6a0 --- /dev/null +++ b/include/widgets/dailylogwidget.h @@ -0,0 +1,52 @@ +#ifndef DAILYLOGWIDGET_H +#define DAILYLOGWIDGET_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "db/foodrepository.h" +#include "db/mealrepository.h" + +class DailyLogWidget : public QWidget { + Q_OBJECT + +public: + explicit DailyLogWidget(QWidget* parent = nullptr); + +public slots: + void refresh(); + void prevDay(); + void nextDay(); + void setToday(); + void onDateChanged(); + +private: + void setupUi(); + void updateTable(); + + QTableWidget* logTable; + + // Analysis UI + QGroupBox* analysisBox; + QVBoxLayout* analysisLayout; + QTableWidget* analysisTable; + QSpinBox* scaleInput; + + // Date Nav + QDate currentDate; + QLabel* dateLabel; + + MealRepository m_mealRepo; + FoodRepository m_foodRepo; + + void updateAnalysis(); +}; + +#endif // DAILYLOGWIDGET_H diff --git a/include/widgets/detailswidget.h b/include/widgets/detailswidget.h index 51b74e6..9ba10e2 100644 --- a/include/widgets/detailswidget.h +++ b/include/widgets/detailswidget.h @@ -1,34 +1,51 @@ #ifndef DETAILSWIDGET_H #define DETAILSWIDGET_H -#include "db/foodrepository.h" +#include #include #include +#include #include +#include #include +#include "db/foodrepository.h" + class DetailsWidget : public QWidget { - Q_OBJECT + Q_OBJECT public: - explicit DetailsWidget(QWidget *parent = nullptr); + explicit DetailsWidget(QWidget* parent = nullptr); - void loadFood(int foodId, const QString &foodName); + void loadFood(int foodId, const QString& foodName); signals: - void addToMeal(int foodId, const QString &foodName, double grams); + void addToMeal(int foodId, const QString& foodName, double grams); private slots: - void onAddClicked(); + void onAddClicked(); + void updateTable(); private: - QLabel *nameLabel; - QTableWidget *nutrientsTable; - QPushButton *addButton; - FoodRepository repository; + QLabel* nameLabel; + QTableWidget* nutrientsTable; + QPushButton* addButton; + QCheckBox* scaleCheckbox; + QSpinBox* scaleSpinBox; + FoodRepository repository; + + int currentFoodId; + QString currentFoodName; + + QCheckBox* hideEmptyCheckbox; + QToolButton* copyIdBtn; + QPushButton* clearButton; + + double calculateScaleMultiplier(const std::vector& nutrients); + void addNutrientRow(const Nutrient& nut, double multiplier, const std::map& rdas); - int currentFoodId; - QString currentFoodName; +public slots: + void clear(); }; -#endif // DETAILSWIDGET_H +#endif // DETAILSWIDGET_H diff --git a/include/widgets/mealwidget.h b/include/widgets/mealwidget.h index cbf163b..e6c83df 100644 --- a/include/widgets/mealwidget.h +++ b/include/widgets/mealwidget.h @@ -1,40 +1,48 @@ #ifndef MEALWIDGET_H #define MEALWIDGET_H -#include "db/foodrepository.h" #include #include #include -#include #include +#include "db/foodrepository.h" +#include "db/mealrepository.h" + struct MealItem { - int foodId; - QString name; - double grams; - std::vector nutrients_100g; // Base nutrients + int foodId; + QString name; + double grams; + std::vector nutrients_100g; }; class MealWidget : public QWidget { - Q_OBJECT + Q_OBJECT public: - explicit MealWidget(QWidget *parent = nullptr); + explicit MealWidget(QWidget* parent = nullptr); - void addFood(int foodId, const QString &foodName, double grams); + void addFood(int foodId, const QString& foodName, double grams); + +signals: + void logUpdated(); private slots: - void clearMeal(); + void clearMeal(); + void onAddToLog(); private: - void updateTotals(); + void updateTotals(); + void refresh(); + + QTableWidget* itemsTable; + QPushButton* clearButton; + QTableWidget* totalsTable; - QTableWidget *itemsTable; - QTableWidget *totalsTable; - QPushButton *clearButton; + FoodRepository repository; + MealRepository m_mealRepo; - std::vector mealItems; - FoodRepository repository; + std::vector mealItems; }; -#endif // MEALWIDGET_H +#endif // MEALWIDGET_H diff --git a/include/widgets/preferencesdialog.h b/include/widgets/preferencesdialog.h new file mode 100644 index 0000000..897a8b3 --- /dev/null +++ b/include/widgets/preferencesdialog.h @@ -0,0 +1,54 @@ +#ifndef PREFERENCESDIALOG_H +#define PREFERENCESDIALOG_H + +#include + +#include "db/foodrepository.h" + +class QLabel; +class QTabWidget; +class RDASettingsWidget; +class ProfileSettingsWidget; +class QSpinBox; + +class PreferencesDialog : public QDialog { + Q_OBJECT + +public: + explicit PreferencesDialog(FoodRepository& repository, QWidget* parent = nullptr); + +public slots: + void save(); + +private: + void setupUi(); + void loadStatistics(); + void loadGeneralSettings(); + [[nodiscard]] QString formatBytes(qint64 bytes) const; + + QTabWidget* tabWidget; + + // General Settings + QSpinBox* debounceSpin; + class QCheckBox* nlpCheckBox; + + // Widgets + ProfileSettingsWidget* profileWidget; + RDASettingsWidget* rdaWidget; + + // Stats labels + QLabel* lblFoodLogs; + QLabel* lblCustomFoods; + QLabel* lblRdaOverrides; + QLabel* lblRecipes; + QLabel* lblSnapshots; + + // Size labels + QLabel* lblUsdaSize; + QLabel* lblUserSize; + QLabel* lblBackupSize; + + FoodRepository& m_repository; +}; + +#endif // PREFERENCESDIALOG_H diff --git a/include/widgets/profilesettingswidget.h b/include/widgets/profilesettingswidget.h new file mode 100644 index 0000000..f63f39d --- /dev/null +++ b/include/widgets/profilesettingswidget.h @@ -0,0 +1,37 @@ +#ifndef PROFILESETTINGSWIDGET_H +#define PROFILESETTINGSWIDGET_H + +#include +#include + +class QLineEdit; +class QDateEdit; +class QComboBox; +class QDoubleSpinBox; +class QSlider; +class QLabel; + +class ProfileSettingsWidget : public QWidget { + Q_OBJECT + +public: + explicit ProfileSettingsWidget(QWidget* parent = nullptr); + + // Save current profile data to database + void save(); + +private: + void setupUi(); + void loadProfile(); + void ensureSchema(); // Check and add columns if missing + + QLineEdit* nameEdit; + QDateEdit* dobEdit; + QComboBox* sexCombo; + QDoubleSpinBox* heightSpin; + QDoubleSpinBox* weightSpin; + QSlider* activitySlider; + QLabel* activityLabel; +}; + +#endif // PROFILESETTINGSWIDGET_H diff --git a/include/widgets/rdasettingswidget.h b/include/widgets/rdasettingswidget.h new file mode 100644 index 0000000..54c17cc --- /dev/null +++ b/include/widgets/rdasettingswidget.h @@ -0,0 +1,26 @@ +#ifndef RDASETTINGSWIDGET_H +#define RDASETTINGSWIDGET_H + +#include +#include + +#include "db/foodrepository.h" + +class RDASettingsWidget : public QDialog { + Q_OBJECT + +public: + explicit RDASettingsWidget(FoodRepository& repository, QWidget* parent = nullptr); + +private slots: + void onCellChanged(int row, int column); + +private: + void loadData(); + + FoodRepository& m_repository; + QTableWidget* m_table; + bool m_loading = false; +}; + +#endif // RDASETTINGSWIDGET_H diff --git a/include/widgets/recipewidget.h b/include/widgets/recipewidget.h new file mode 100644 index 0000000..af028a7 --- /dev/null +++ b/include/widgets/recipewidget.h @@ -0,0 +1,55 @@ +#ifndef RECIPEWIDGET_H +#define RECIPEWIDGET_H + +#include +#include +#include +#include +#include +#include +#include + +#include "db/foodrepository.h" +#include "db/reciperepository.h" + +class RecipeWidget : public QWidget { + Q_OBJECT + +public: + explicit RecipeWidget(QWidget* parent = nullptr); + void loadRecipes(); + +signals: + void recipeSelected(int recipeId); + +private slots: + void onNewRecipe(); + void onSaveRecipe(); + void onDeleteRecipe(); + void onRecipeListSelectionChanged(); + void onAddIngredient(); + void onRemoveIngredient(); + +private: + void setupUi(); + void loadRecipeDetails(int recipeId); + void clearDetails(); + + RecipeRepository repository; + FoodRepository foodRepo; // For ingredient search/lookup + + QListWidget* recipeList; + QLineEdit* nameEdit; + QTableWidget* ingredientsTable; + QTextEdit* instructionsEdit; + + QPushButton* saveButton; + QPushButton* deleteButton; + QPushButton* newButton; + QPushButton* addIngredientButton; + QPushButton* removeIngredientButton; + + int currentRecipeId = -1; +}; + +#endif // RECIPEWIDGET_H diff --git a/include/widgets/searchwidget.h b/include/widgets/searchwidget.h index 7c95bb3..530533c 100644 --- a/include/widgets/searchwidget.h +++ b/include/widgets/searchwidget.h @@ -1,32 +1,58 @@ #ifndef SEARCHWIDGET_H #define SEARCHWIDGET_H -#include "db/foodrepository.h" +#include +#include #include #include +#include #include #include #include +#include "db/foodrepository.h" + class SearchWidget : public QWidget { - Q_OBJECT + Q_OBJECT public: - explicit SearchWidget(QWidget *parent = nullptr); + explicit SearchWidget(QWidget* parent = nullptr); + bool eventFilter(QObject* obj, QEvent* event) override; + + void reloadSettings(); signals: - void foodSelected(int foodId, const QString &foodName); + void foodSelected(int foodId, const QString& foodName); + void addToMealRequested(int foodId, const QString& foodName, double grams); + void searchStatus(const QString& msg); private slots: - void performSearch(); - void onRowDoubleClicked(int row, int column); + void performSearch(); + void onRowDoubleClicked(int row, int column); + void onCustomContextMenu(const QPoint& pos); + void onHistoryContextMenu(const QPoint& pos); + void onCompleterActivated(const QString& text); private: - QLineEdit *searchInput; - QPushButton *searchButton; - QTableWidget *resultsTable; - FoodRepository repository; - QTimer *searchTimer; + void addToHistory(int foodId, const QString& foodName); + void removeFromHistory(int index); + void loadHistory(); + void updateCompleterModel(); + + QLineEdit* searchInput; + QTableWidget* resultsTable; + FoodRepository repository; + QTimer* searchTimer; + + QCompleter* historyCompleter; + QStringListModel* historyModel; + + struct HistoryItem { + int id; + QString name; + QDateTime timestamp; + }; + QList recentHistory; }; -#endif // SEARCHWIDGET_H +#endif // SEARCHWIDGET_H diff --git a/include/widgets/weightinputdialog.h b/include/widgets/weightinputdialog.h new file mode 100644 index 0000000..e406f1e --- /dev/null +++ b/include/widgets/weightinputdialog.h @@ -0,0 +1,29 @@ +#ifndef WEIGHTINPUTDIALOG_H +#define WEIGHTINPUTDIALOG_H + +#include +#include +#include +#include + +#include "db/foodrepository.h" + +class WeightInputDialog : public QDialog { + Q_OBJECT + +public: + explicit WeightInputDialog(const QString& foodName, const std::vector& servings, + QWidget* parent = nullptr); + + [[nodiscard]] double getGrams() const; + +private: + QDoubleSpinBox* amountSpinBox; + QComboBox* unitComboBox; + std::vector m_servings; + + static constexpr double GRAMS_PER_OZ = 28.3495; + static constexpr double GRAMS_PER_LB = 453.592; +}; + +#endif // WEIGHTINPUTDIALOG_H diff --git a/lib/ntsqlite b/lib/ntsqlite new file mode 160000 index 0000000..3eb6b33 --- /dev/null +++ b/lib/ntsqlite @@ -0,0 +1 @@ +Subproject commit 3eb6b336eb712a354bca4325f01ee3403aa9073a diff --git a/lib/pylang_serv b/lib/pylang_serv new file mode 160000 index 0000000..9e70dbb --- /dev/null +++ b/lib/pylang_serv @@ -0,0 +1 @@ +Subproject commit 9e70dbbe3812d6d98d570165a177bb567cdb0712 diff --git a/lib/usdasqlite b/lib/usdasqlite new file mode 160000 index 0000000..8324bf3 --- /dev/null +++ b/lib/usdasqlite @@ -0,0 +1 @@ +Subproject commit 8324bf302bc2417dbee8f5c7280acaaef620fd65 diff --git a/nutra.desktop b/resources/nutra.desktop.in similarity index 54% rename from nutra.desktop rename to resources/nutra.desktop.in index 099e32c..68d8f54 100644 --- a/nutra.desktop +++ b/resources/nutra.desktop.in @@ -1,10 +1,10 @@ [Desktop Entry] Name=Nutra Comment=Nutrition Tracker and USDA Database -Exec=nutra +Exec=@NUTRA_EXECUTABLE@ Icon=nutra Terminal=false Type=Application -Categories=Utility;Database;Health; +Categories=Utility;Office;Database; StartupNotify=true -Keywords=nutrition;food;tracker;usda;diet; +Keywords=nutrition;food;tracker;usda;diet;health; diff --git a/scripts/build_appimage.sh b/scripts/build_appimage.sh new file mode 100755 index 0000000..e6fa0cd --- /dev/null +++ b/scripts/build_appimage.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Build AppImage from project root +# Assumes linuxdeploy and linuxdeploy-plugin-qt are in PATH +set -e + +cd "$(dirname "$0")/.." || exit 1 + +cmake -B build -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Release +cmake --build build -j"$(nproc)" +cmake --build build --target appimage diff --git a/scripts/ci-version-bump.sh b/scripts/ci-version-bump.sh new file mode 100755 index 0000000..e194bd7 --- /dev/null +++ b/scripts/ci-version-bump.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Version bump script for semantic versioning with pre-release support +# Usage: ./scripts/ci-version-bump.sh [bump_type] [pre_release_type] [--tag] [--push] +# bump_type: major|minor|patch (default: patch) +# pre_release_type: none|alpha|beta|rc (default: none) +# --tag: Create the git tag +# --push: Push the tag to origin (implies --tag) + +set -euo pipefail + +BUMP_TYPE="${1:-patch}" +PRE_TYPE="${2:-none}" +DO_TAG=false +DO_PUSH=false + +# If PRE_TYPE looks like a flag, treat it as one +if [[ "$PRE_TYPE" == --* ]]; then + PRE_TYPE="none" + shift 1 2>/dev/null || true +else + shift 2 2>/dev/null || true +fi + +# Parse flags +for arg in "$@"; do + case $arg in + --tag) DO_TAG=true ;; + --push) + DO_TAG=true + DO_PUSH=true + ;; + esac +done + +# Get latest tag +LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") +VERSION=${LATEST_TAG#v} + +# Parse current version +BASE_VERSION=$(echo "$VERSION" | cut -d'-' -f1) +PRERELEASE_PART=$(echo "$VERSION" | cut -d'-' -f2- -s) + +IFS='.' read -r MAJOR MINOR PATCH <<<"$BASE_VERSION" + +# Compute new version +if [ -z "$PRERELEASE_PART" ]; then + if [ "$BUMP_TYPE" == "major" ]; then + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + elif [ "$BUMP_TYPE" == "minor" ]; then + MINOR=$((MINOR + 1)) + PATCH=0 + else + PATCH=$((PATCH + 1)) + fi + + if [ "$PRE_TYPE" != "none" ]; then + NEW_TAG="v$MAJOR.$MINOR.$PATCH-$PRE_TYPE.1" + else + NEW_TAG="v$MAJOR.$MINOR.$PATCH" + fi +else + CURRENT_PRE_TYPE=$(echo "$PRERELEASE_PART" | cut -d'.' -f1) + CURRENT_PRE_NUM=$(echo "$PRERELEASE_PART" | cut -d'.' -f2) + + if [ "$PRE_TYPE" == "none" ]; then + NEW_TAG="v$MAJOR.$MINOR.$PATCH" + elif [ "$PRE_TYPE" == "$CURRENT_PRE_TYPE" ]; then + NEW_NUM=$((CURRENT_PRE_NUM + 1)) + NEW_TAG="v$MAJOR.$MINOR.$PATCH-$PRE_TYPE.$NEW_NUM" + else + NEW_TAG="v$MAJOR.$MINOR.$PATCH-$PRE_TYPE.1" + fi +fi + +echo "Bumping from $LATEST_TAG to $NEW_TAG" + +if [ "$DO_TAG" = true ]; then + git tag -a "$NEW_TAG" -m "Release $NEW_TAG" + echo "Created tag $NEW_TAG" +fi + +if [ "$DO_PUSH" = true ]; then + git push origin "$NEW_TAG" + echo "Pushed tag $NEW_TAG to origin" +fi + +# Output just the tag for scripts that need to capture it +if [ "$DO_TAG" = false ]; then + echo "$NEW_TAG" +fi diff --git a/src/db/databasemanager.cpp b/src/db/databasemanager.cpp index abda07d..7e86ee7 100644 --- a/src/db/databasemanager.cpp +++ b/src/db/databasemanager.cpp @@ -1,43 +1,242 @@ #include "db/databasemanager.h" + #include +#include #include #include +#include -DatabaseManager &DatabaseManager::instance() { - static DatabaseManager instance; - return instance; +DatabaseManager& DatabaseManager::instance() { + static DatabaseManager instance; + return instance; } -DatabaseManager::DatabaseManager() = default; +DatabaseManager::DatabaseManager() { + m_userDb = QSqlDatabase::addDatabase("QSQLITE", "user_db"); + initUserDatabase(); +} DatabaseManager::~DatabaseManager() { - if (m_db.isOpen()) { - m_db.close(); - } + if (m_db.isOpen()) { + m_db.close(); + } + if (m_userDb.isOpen()) { + m_userDb.close(); + } } -bool DatabaseManager::connect(const QString &path) { - if (m_db.isOpen()) { +bool DatabaseManager::isValidNutraDatabase(const QSqlDatabase& db) { + if (!db.isOpen()) return false; + QSqlQuery query(db); + // Check for a critical table, e.g., food_des + return query.exec("SELECT 1 FROM food_des LIMIT 1"); +} + +bool DatabaseManager::connect(const QString& path) { + if (m_db.isOpen()) { + if (m_db.databaseName() == path) { + return true; + } + m_db.close(); + } + + if (!QFileInfo::exists(path)) { + qCritical() << "Database file not found:" << path; + return false; + } + + if (QSqlDatabase::contains(QSqlDatabase::defaultConnection)) { + m_db = QSqlDatabase::database(QSqlDatabase::defaultConnection); + } else { + 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; + } + + if (!isValidNutraDatabase(m_db)) { + qCritical() << "Invalid database: missing essential tables."; + m_db.close(); + return false; + } + return true; - } +} + +bool DatabaseManager::isOpen() const { + return m_db.isOpen(); +} + +QSqlDatabase DatabaseManager::database() const { + return m_db; +} + +QSqlDatabase DatabaseManager::userDatabase() const { + return m_userDb; +} + +DatabaseManager::DatabaseInfo DatabaseManager::getDatabaseInfo(const QString& path) { + DatabaseInfo info{false, "Unknown", 0}; + + if (!QFileInfo::exists(path)) return info; - if (!QFileInfo::exists(path)) { - qCritical() << "Database file not found:" << path; - return false; - } + { + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "info_connection"); + db.setDatabaseName(path); + if (db.open()) { + QSqlQuery query(db); - m_db = QSqlDatabase::addDatabase("QSQLITE"); - m_db.setDatabaseName(path); - m_db.setConnectOptions("QSQLITE_OPEN_READONLY"); + // Get Version + info.version = instance().getSchemaVersion(db); - if (!m_db.open()) { - qCritical() << "Error opening database:" << m_db.lastError().text(); - return false; - } + // Get App ID + int appId = 0; + if (query.exec("PRAGMA application_id") && query.next()) { + appId = query.value(0).toInt(); + } - return true; + // Determine Type + if (appId == APP_ID_USDA) { + info.type = "USDA"; + info.isValid = true; + } else if (appId == APP_ID_USER) { + info.type = "User"; + info.isValid = true; + } else { + // Fallback: Check tables + bool hasFoodDes = query.exec("SELECT 1 FROM food_des LIMIT 1"); + bool hasLogFood = query.exec("SELECT 1 FROM log_food LIMIT 1"); + + if (hasFoodDes) { + info.type = "USDA"; + info.isValid = true; + } else if (hasLogFood) { + info.type = "User"; + info.isValid = true; + } + } + + db.close(); + } + } + QSqlDatabase::removeDatabase("info_connection"); + return info; +} + +void DatabaseManager::initUserDatabase() { + QString dirPath = QDir::homePath() + "/.nutra"; + QDir().mkpath(dirPath); + QString path = dirPath + "/nt.sqlite3"; + m_userDb.setDatabaseName(path); + + if (!m_userDb.open()) { + qCritical() << "Failed to open user database:" << m_userDb.lastError().text(); + return; + } + + QSqlQuery query(m_userDb); + + // Check version + int schemaVersionOnDisk = getSchemaVersion(m_userDb); + + qDebug() << "User database version:" << schemaVersionOnDisk; + + if (schemaVersionOnDisk == 0) { + // Initialize from tables.sql + QString schemaPath = QDir::currentPath() + "/lib/ntsqlite/sql/tables.sql"; + if (!QFileInfo::exists(schemaPath)) { + // Fallback for installed location + QString fallbackPath = "/usr/share/nutra/sql/tables.sql"; + if (QFileInfo::exists(fallbackPath)) { + schemaPath = fallbackPath; + } else { + qCritical() << "Schema file not found at:" << schemaPath << "or" << fallbackPath; + return; + } + } + applySchema(query, schemaPath); + } } -bool DatabaseManager::isOpen() const { return m_db.isOpen(); } +void DatabaseManager::applySchema(QSqlQuery& query, const QString& schemaPath) { + if (!QFileInfo::exists(schemaPath)) { + qCritical() << "applySchema: Schema file does not exist:" << schemaPath; + return; + } + QFile schemaFile(schemaPath); + if (!schemaFile.open(QIODevice::ReadOnly)) { + qCritical() << "Could not open schema file:" << schemaPath; + return; + } -QSqlDatabase DatabaseManager::database() const { return m_db; } + QTextStream in(&schemaFile); + QString sql = in.readAll(); + + // 1. Strip comments (lines starting with --) + QString cleanSql; +#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) + QStringList lines = sql.split('\n', Qt::SkipEmptyParts); +#else + QStringList lines = sql.split('\n', QString::SkipEmptyParts); +#endif + for (const QString& line : lines) { + QString trimmedLine = line.trimmed(); + if (!trimmedLine.startsWith("--")) { + cleanSql += line + "\n"; + } + } + + // 2. Split by semicolon +#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) + QStringList statements = cleanSql.split(';', Qt::SkipEmptyParts); +#else + QStringList statements = cleanSql.split(';', QString::SkipEmptyParts); +#endif + + for (const QString& stmt : statements) { + QString trimmed = stmt.trimmed(); + if (!trimmed.isEmpty() && !trimmed.startsWith("--")) { + if (!query.exec(trimmed)) { + qWarning() << "Schema init warning:" << query.lastError().text() + << "\nStmt:" << trimmed; + } + } + } + // Ensure version and ID are set + if (!query.exec(QString("PRAGMA user_version = %1").arg(USER_SCHEMA_VERSION))) { + qCritical() << "Failed to set user_version:" << query.lastError().text(); + } + if (!query.exec(QString("PRAGMA application_id = %1").arg(APP_ID_USER))) { + qCritical() << "Failed to set application_id:" << query.lastError().text(); + } + qDebug() << "Upgraded user database version to" << USER_SCHEMA_VERSION << "and set App ID."; + + // --- Seeding Data --- + + // Ensure default profile exists + query.exec("INSERT OR IGNORE INTO profile (id, name) VALUES (1, 'default')"); + + // Seed standard meal names if table is empty + query.exec("SELECT count(*) FROM meal_name"); + if (query.next() && query.value(0).toInt() == 0) { + QStringList meals = {"Breakfast", "Lunch", "Dinner", "Snack", "Brunch"}; + for (const auto& meal : meals) { + query.prepare("INSERT INTO meal_name (name) VALUES (?)"); + query.addBindValue(meal); + query.exec(); + } + } +} + +int DatabaseManager::getSchemaVersion(const QSqlDatabase& db) { + if (!db.isOpen()) return 0; + QSqlQuery query(db); + if (query.exec("PRAGMA user_version") && query.next()) { + return query.value(0).toInt(); + } + return 0; +} diff --git a/src/db/foodrepository.cpp b/src/db/foodrepository.cpp index 3d18417..eb23891 100644 --- a/src/db/foodrepository.cpp +++ b/src/db/foodrepository.cpp @@ -1,177 +1,286 @@ #include "db/foodrepository.h" -#include "db/databasemanager.h" + #include #include #include #include #include +#include "db/databasemanager.h" + FoodRepository::FoodRepository() {} -#include "utils/string_utils.h" #include +#include "utils/string_utils.h" + // ... 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; -} + if (m_cacheLoaded) return; -std::vector FoodRepository::searchFoods(const QString &query) { - ensureCacheLoaded(); - std::vector results; + QSqlDatabase db = DatabaseManager::instance().database(); + if (!db.isOpen()) return; + + // 1. Load Food Items with Group Names + QSqlQuery query( + "SELECT f.id, f.long_desc, g.fdgrp_desc, f.fdgrp_id " + "FROM food_des f " + "JOIN fdgrp g ON f.fdgrp_id = g.id", + 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(); + } - if (query.trimmed().isEmpty()) - return results; + // 3. Load Nutrient Definition Metadata + m_nutrientNames.clear(); + m_nutrientUnits.clear(); + QSqlQuery defQuery("SELECT id, nutr_desc, unit FROM nutr_def", db); + while (defQuery.next()) { + int id = defQuery.value(0).toInt(); + m_nutrientNames[id] = defQuery.value(1).toString(); + m_nutrientUnits[id] = defQuery.value(2).toString(); + } - // 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()) { + while (query.next()) { + FoodItem item; + item.id = query.value(0).toInt(); + item.description = query.value(1).toString(); + item.foodGroupName = query.value(2).toString(); + item.foodGroupId = query.value(3).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); + } + loadRdas(); + m_cacheLoaded = true; +} + +void FoodRepository::loadRdas() { + m_rdas.clear(); 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; + QSqlDatabase userDb = DatabaseManager::instance().userDatabase(); + + // 1. Load Defaults from USDA + if (db.isOpen()) { + QSqlQuery query("SELECT id, rda FROM nutrients_overview", db); + while (query.next()) { + m_rdas[query.value(0).toInt()] = query.value(1).toDouble(); + } + } + + // 2. Load Overrides from User DB + if (userDb.isOpen()) { + QSqlQuery query("SELECT nutr_id, rda FROM rda WHERE profile_id = 1", userDb); + while (query.next()) { + m_rdas[query.value(0).toInt()] = query.value(1).toDouble(); + } + } +} + +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 nutrient counts + 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, " + "COUNT(n.nutr_id) as total_count, " + "SUM(CASE WHEN n.nutr_id BETWEEN 501 AND 521 THEN 1 ELSE 0 END) as " + "amino_count, " + "SUM(CASE WHEN d.flav_class IS NOT NULL AND d.flav_class != '' THEN 1 " + "ELSE 0 END) as flav_count " + "FROM nut_data n " + "JOIN nutr_def d ON n.nutr_id = d.id " + "WHERE n.food_id IN (%1) " + "GROUP BY n.food_id") + .arg(idStrings.join(",")); + + QSqlQuery nutQuery(sql, db); + while (nutQuery.next()) { + int fid = nutQuery.value(0).toInt(); + int total = nutQuery.value(1).toInt(); + int amino = nutQuery.value(2).toInt(); + int flav = nutQuery.value(3).toInt(); + + if (idToIndex.count(fid) != 0U) { + auto& item = results[idToIndex[fid]]; + item.nutrientCount = total; + item.aminoCount = amino; + item.flavCount = flav; + } + } + } + + return results; } std::vector FoodRepository::getFoodNutrients(int foodId) { - std::vector results; - QSqlDatabase db = DatabaseManager::instance().database(); + ensureCacheLoaded(); + 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(); + + if (m_rdas.count(nut.id) != 0U && m_rdas[nut.id] > 0) { + nut.rdaPercentage = (nut.amount / m_rdas[nut.id]) * 100.0; + } else { + nut.rdaPercentage = 0.0; + } + + results.push_back(nut); + } + + } else { + qCritical() << "Nutrient query failed:" << query.lastError().text(); + } - if (!db.isOpen()) return results; +} + +std::vector FoodRepository::getFoodServings(int foodId) { + std::vector results; + QSqlDatabase db = DatabaseManager::instance().database(); - 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 = ?")) { + if (!db.isOpen()) return results; + + QSqlQuery query(db); + if (!query.prepare("SELECT d.msre_desc, s.grams " + "FROM serving s " + "JOIN serv_desc d ON s.msre_id = d.id " + "WHERE s.food_id = ?")) { + qCritical() << "Prepare servings failed:" << query.lastError().text(); + return results; + } + + query.bindValue(0, foodId); + + if (query.exec()) { + while (query.next()) { + ServingWeight sw; + sw.description = query.value(0).toString(); + sw.grams = query.value(1).toDouble(); + results.push_back(sw); + } + } else { + qCritical() << "Servings query failed:" << query.lastError().text(); + } - qCritical() << "Prepare failed:" << query.lastError().text(); return results; - } +} - query.bindValue(0, foodId); +std::map FoodRepository::getNutrientRdas() { + ensureCacheLoaded(); + return m_rdas; +} - 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; +void FoodRepository::updateRda(int nutrId, double value) { + QSqlDatabase userDb = DatabaseManager::instance().userDatabase(); + if (!userDb.isOpen()) return; - results.push_back(nut); + QSqlQuery query(userDb); + if (!query.prepare("INSERT OR REPLACE INTO rda (profile_id, nutr_id, rda) " + "VALUES (1, ?, ?)")) { + qCritical() << "Failed to prepare RDA update:" << query.lastError().text(); + return; } + query.bindValue(0, nutrId); + query.bindValue(1, value); - } else { - qCritical() << "Nutrient query failed:" << query.lastError().text(); - } + if (query.exec()) { + m_rdas[nutrId] = value; + } else { + qCritical() << "Failed to update RDA:" << query.lastError().text(); + } +} + +QString FoodRepository::getNutrientName(int nutrientId) { + ensureCacheLoaded(); + if (m_nutrientNames.count(nutrientId) != 0U) { + return m_nutrientNames[nutrientId]; + } + return QString("Unknown Nutrient (%1)").arg(nutrientId); +} - return results; +QString FoodRepository::getNutrientUnit(int nutrientId) { + ensureCacheLoaded(); + if (m_nutrientUnits.count(nutrientId) != 0U) { + return m_nutrientUnits[nutrientId]; + } + return "?"; } diff --git a/src/db/mealrepository.cpp b/src/db/mealrepository.cpp new file mode 100644 index 0000000..3f98199 --- /dev/null +++ b/src/db/mealrepository.cpp @@ -0,0 +1,154 @@ +#include "db/mealrepository.h" + +#include +#include +#include +#include +#include + +#include "db/databasemanager.h" + +MealRepository::MealRepository() = default; + +void MealRepository::ensureMealNamesLoaded() { + if (!m_mealNamesCache.empty()) return; + + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return; + + QSqlQuery query("SELECT id, name FROM meal_name", db); + while (query.next()) { + m_mealNamesCache[query.value(0).toInt()] = query.value(1).toString(); + } +} + +std::map MealRepository::getMealNames() { + ensureMealNamesLoaded(); + return m_mealNamesCache; +} + +void MealRepository::addFoodLog(int foodId, double grams, int mealId, QDate date) { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return; + + // Use current time if today, otherwise noon of target date + qint64 timestamp; + if (date == QDate::currentDate()) { + timestamp = QDateTime::currentSecsSinceEpoch(); + } else { + timestamp = QDateTime(date, QTime(0, 0, 0)).toSecsSinceEpoch() + 43200; // Noon + } + + QSqlQuery query(db); + query.prepare( + "INSERT INTO log_food (profile_id, date, meal_id, food_id, msre_id, amt) " + "VALUES (1, ?, ?, ?, 0, ?)"); // msre_id 0 for default/grams + query.addBindValue(timestamp); + query.addBindValue(mealId); + query.addBindValue(foodId); + query.addBindValue(grams); + + if (!query.exec()) { + qCritical() << "Failed to add food log:" << query.lastError().text(); + } +} + +std::vector MealRepository::getDailyLogs(QDate date) { + std::vector results; + QSqlDatabase userDb = DatabaseManager::instance().userDatabase(); + QSqlDatabase mainDb = DatabaseManager::instance().database(); + + if (!userDb.isOpen()) return results; + + ensureMealNamesLoaded(); + + qint64 startOfDay = QDateTime(date, QTime(0, 0, 0)).toSecsSinceEpoch(); + qint64 endOfDay = QDateTime(date, QTime(23, 59, 59)).toSecsSinceEpoch(); + + QSqlQuery query(userDb); + query.prepare( + "SELECT id, food_id, meal_id, amt FROM log_food " + "WHERE date >= ? AND date <= ? AND profile_id = 1"); + query.addBindValue(startOfDay); + query.addBindValue(endOfDay); + + std::vector foodIds; + + if (query.exec()) { + while (query.next()) { + MealLogItem item; + item.id = query.value(0).toInt(); + item.foodId = query.value(1).toInt(); + item.mealId = query.value(2).toInt(); + item.grams = query.value(3).toDouble(); + + if (m_mealNamesCache.count(item.mealId) != 0U) { + item.mealName = m_mealNamesCache[item.mealId]; + } else { + item.mealName = "Unknown"; + } + + results.push_back(item); + foodIds.push_back(item.foodId); + } + } else { + qCritical() << "Failed to fetch daily logs:" << query.lastError().text(); + } + + // Hydrate food names from Main DB + if (!foodIds.empty() && mainDb.isOpen()) { + QStringList idStrings; + for (int id : foodIds) idStrings << QString::number(id); + + // Simple name fetch + // Optimization: Could use FoodRepository cache if available, but direct + // query is safe here + QString sql = + QString("SELECT id, long_desc FROM food_des WHERE id IN (%1)").arg(idStrings.join(",")); + QSqlQuery nameQuery(sql, mainDb); + + std::map names; + while (nameQuery.next()) { + names[nameQuery.value(0).toInt()] = nameQuery.value(1).toString(); + } + + for (auto& item : results) { + if (names.count(item.foodId) != 0U) { + item.foodName = names[item.foodId]; + } else { + item.foodName = "Unknown Food"; // Should not happen if DBs consistent + } + } + } + + return results; +} + +void MealRepository::clearDailyLogs(QDate date) { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return; + + qint64 startOfDay = QDateTime(date, QTime(0, 0, 0)).toSecsSinceEpoch(); + qint64 endOfDay = QDateTime(date, QTime(23, 59, 59)).toSecsSinceEpoch(); + + QSqlQuery query(db); + query.prepare("DELETE FROM log_food WHERE date >= ? AND date <= ? AND profile_id = 1"); + query.addBindValue(startOfDay); + query.addBindValue(endOfDay); + + if (!query.exec()) { + qCritical() << "Failed to clear daily logs:" << query.lastError().text(); + } +} + +void MealRepository::removeLogEntry(int logId) { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return; + + QSqlQuery query(db); + query.prepare("DELETE FROM log_food WHERE id = ?"); + query.addBindValue(logId); + if (!query.exec()) { + qCritical() << "Failed to remove log entry:" << query.lastError().text(); + } +} diff --git a/src/db/reciperepository.cpp b/src/db/reciperepository.cpp new file mode 100644 index 0000000..132d134 --- /dev/null +++ b/src/db/reciperepository.cpp @@ -0,0 +1,240 @@ +#include "db/reciperepository.h" + +#include +#include +#include +#include +#include + +#include "db/databasemanager.h" + +RecipeRepository::RecipeRepository() = default; + +int RecipeRepository::createRecipe(const QString& name, const QString& instructions) { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return -1; + + QSqlQuery query(db); + query.prepare("INSERT INTO recipe (name, instructions) VALUES (?, ?)"); + query.addBindValue(name); + query.addBindValue(instructions); + + if (query.exec()) { + return query.lastInsertId().toInt(); + } + qCritical() << "Failed to create recipe:" << query.lastError().text(); + return -1; +} + +bool RecipeRepository::updateRecipe(int id, const QString& name, const QString& instructions) { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return false; + + QSqlQuery query(db); + query.prepare("UPDATE recipe SET name = ?, instructions = ? WHERE id = ?"); + query.addBindValue(name); + query.addBindValue(instructions); + query.addBindValue(id); + + if (query.exec()) { + return true; + } + qCritical() << "Failed to update recipe:" << query.lastError().text(); + return false; +} + +bool RecipeRepository::deleteRecipe(int id) { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return false; + + QSqlQuery query(db); + query.prepare("UPDATE recipe SET is_deleted = 1 WHERE id = ?"); + query.addBindValue(id); + return query.exec(); +} + +std::vector RecipeRepository::getAllRecipes() { + std::vector recipes; + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return recipes; + + QSqlQuery query(db); + // TODO: Join with ingredient amounts * food nutrient values to get calories? + // For now, simple list. + if (query.exec("SELECT id, uuid, name, instructions, created FROM recipe WHERE is_deleted = 0 " + "ORDER BY name ASC")) { + while (query.next()) { + RecipeItem item; + item.id = query.value(0).toInt(); + item.uuid = query.value(1).toString(); + item.name = query.value(2).toString(); + item.instructions = query.value(3).toString(); + item.created = QDateTime::fromSecsSinceEpoch(query.value(4).toLongLong()); + recipes.push_back(item); + } + } else { + qCritical() << "Failed to fetch recipes:" << query.lastError().text(); + } + return recipes; +} + +RecipeItem RecipeRepository::getRecipe(int id) { + RecipeItem item; + item.id = -1; + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return item; + + QSqlQuery query(db); + query.prepare( + "SELECT id, uuid, name, instructions, created FROM recipe WHERE id = ? AND is_deleted = 0"); + query.addBindValue(id); + if (query.exec() && query.next()) { + item.id = query.value(0).toInt(); + item.uuid = query.value(1).toString(); + item.name = query.value(2).toString(); + item.instructions = query.value(3).toString(); + item.created = QDateTime::fromSecsSinceEpoch(query.value(4).toLongLong()); + } + return item; +} + +bool RecipeRepository::addIngredient(int recipeId, int foodId, double amount) { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return false; + + QSqlQuery query(db); + query.prepare("INSERT INTO recipe_ingredient (recipe_id, food_id, amount) VALUES (?, ?, ?)"); + query.addBindValue(recipeId); + query.addBindValue(foodId); + query.addBindValue(amount); + + if (!query.exec()) { + qCritical() << "Failed to add ingredient:" << query.lastError().text(); + return false; + } + return true; +} + +bool RecipeRepository::removeIngredient(int recipeId, int foodId) { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return false; + + QSqlQuery query(db); + query.prepare("DELETE FROM recipe_ingredient WHERE recipe_id = ? AND food_id = ?"); + query.addBindValue(recipeId); + query.addBindValue(foodId); + return query.exec(); +} + +bool RecipeRepository::updateIngredient(int recipeId, int foodId, double amount) { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return false; + + QSqlQuery query(db); + query.prepare("UPDATE recipe_ingredient SET amount = ? WHERE recipe_id = ? AND food_id = ?"); + query.addBindValue(amount); + query.addBindValue(recipeId); + query.addBindValue(foodId); + return query.exec(); +} + +std::vector RecipeRepository::getIngredients(int recipeId) { + std::vector ingredients; + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return ingredients; + + // We need to join with USDA db 'food_des' to get names? + // USDA db is attached as what? 'main' is User db usually? + // Wait, DatabaseManager opens main USDA db as 'db' (default connection) and User DB as + // 'user_db' (named connection). They are SEPARATE connections. Cross-database joins require + // attaching. DatabaseManager doesn't seem to attach them by default. workaround: Get IDs then + // query USDA db for names. + + QSqlQuery query(db); + query.prepare("SELECT food_id, amount FROM recipe_ingredient WHERE recipe_id = ?"); + query.addBindValue(recipeId); + + if (query.exec()) { + while (query.next()) { + RecipeIngredient ing; + ing.foodId = query.value(0).toInt(); + ing.amount = query.value(1).toDouble(); + + // Fetch name from main DB + // This is inefficient (N+1 queries), but simple for now without ATTACH logic. + // Or we could pass a list of IDs to FoodRepository. + + QSqlDatabase usdaDb = DatabaseManager::instance().database(); + if (usdaDb.isOpen()) { + QSqlQuery nameQuery(usdaDb); + nameQuery.prepare("SELECT long_desc FROM food_des WHERE ndb_no = ?"); + nameQuery.addBindValue(ing.foodId); + if (nameQuery.exec() && nameQuery.next()) { + ing.foodName = nameQuery.value(0).toString(); + } else { + ing.foodName = "Unknown Food (" + QString::number(ing.foodId) + ")"; + } + } + ingredients.push_back(ing); + } + } + return ingredients; +} + +#include + +void RecipeRepository::loadCsvRecipes(const QString& directory) { + QDir dir(directory); + if (!dir.exists()) return; + + // Pre-load all recipes into a map for O(1) lookup + std::vector existingRecipes = getAllRecipes(); + std::map recipeMap; + for (const auto& r : existingRecipes) { + recipeMap[r.name] = r.id; + } + + QDirIterator it(directory, QStringList() << "*.csv", QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + processCsvFile(it.next(), recipeMap); + } +} + +void RecipeRepository::processCsvFile(const QString& filePath, std::map& recipeMap) { + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return; + + while (!file.atEnd()) { + QString line = file.readLine().trimmed(); + if (line.isEmpty() || line.startsWith("#")) continue; + + QStringList parts = line.split(','); + if (parts.size() < 4) continue; + + QString recipeName = parts[0].trimmed(); + QString instructions = parts[1].trimmed(); + int foodId = parts[2].toInt(); + double amount = parts[3].toDouble(); + + if (foodId <= 0 || amount <= 0) continue; + + int recipeId = getOrCreateRecipe(recipeName, instructions, recipeMap); + if (recipeId != -1) { + addIngredient(recipeId, foodId, amount); + } + } + file.close(); +} + +int RecipeRepository::getOrCreateRecipe(const QString& name, const QString& instructions, + std::map& recipeMap) { + if (recipeMap.count(name) != 0U) { + return recipeMap[name]; + } + + int newId = createRecipe(name, instructions); + if (newId != -1) { + recipeMap[name] = newId; + } + return newId; +} diff --git a/src/main.cpp b/src/main.cpp index a6857d0..9a87746 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,68 +1,115 @@ -#include "db/databasemanager.h" -#include "mainwindow.h" #include +#include #include #include +#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; +#include "db/databasemanager.h" +#include "mainwindow.h" + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + + // Command line parsing + QCommandLineParser parser; + parser.setApplicationDescription("Nutra - Nutrient Coach"); + parser.addHelpOption(); + parser.addVersionOption(); + + QCommandLineOption debugOption(QStringList() << "d" << "debug", "Enable debug logging"); + parser.addOption(debugOption); + + parser.process(app); + + if (parser.isSet(debugOption)) { + QLoggingCategory::setFilterRules("*.debug=true\n*.info=true"); + qDebug() << "Debug logging enabled."; + } + + QApplication::setOrganizationName("nutra"); + QApplication::setApplicationName("nutra"); + QApplication::setWindowIcon(QIcon(":/resources/nutrition_icon-no_bg.png")); + + // Prevent multiple instances + QString lockPath = QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation); + if (lockPath.isEmpty()) { + lockPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + } + lockPath += "/nutra.lock"; + QLockFile lockFile(lockPath); + if (!lockFile.tryLock(100)) { + QMessageBox::warning(nullptr, "Nutra is already running", + "Another instance of Nutra is already running.\n" + "Please close it before starting a new one."); + return 1; + } + + // 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(); + + if (dbPath.isEmpty()) { + qWarning() << "Database not found in standard locations."; + QMessageBox::StandardButton resBtn = QMessageBox::question( + nullptr, "Database Not Found", + "The Nutrient database (usda.sqlite3) was not found.\nWould you like " + "to browse for it manually?", + QMessageBox::No | QMessageBox::Yes, QMessageBox::Yes); + + if (resBtn == QMessageBox::Yes) { + dbPath = QFileDialog::getOpenFileName(nullptr, "Find usda.sqlite3", + QDir::homePath() + "/.nutra", + "SQLite Databases (*.sqlite3 *.db)"); + } + + if (dbPath.isEmpty()) { + return 1; // User cancelled or still not found + } + } + + if (!DatabaseManager::instance().connect(dbPath)) { + QString errorMsg = QString( + "Failed to connect to database at:\n%1\n\nPlease ensure the " + "database file exists or browse for a valid SQLite file.") + .arg(dbPath); + qCritical() << errorMsg; + QMessageBox::critical(nullptr, "Database Error", errorMsg); + // Let's try to let the user browse one more time before giving up + 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 index 4cf1803..dc73ea4 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1,52 +1,433 @@ #include "mainwindow.h" -#include +#include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include #include -MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { setupUi(); } +#include "db/databasemanager.h" +#include "widgets/preferencesdialog.h" +#include "widgets/rdasettingswidget.h" +#include "widgets/recipewidget.h" + +MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { + for (auto& recentFileAction : recentFileActions) { + recentFileAction = new QAction(this); + recentFileAction->setVisible(false); + connect(recentFileAction, &QAction::triggered, this, &MainWindow::onRecentFileClick); + } + setupUi(); + updateRecentFileActions(); + + // Load CSV Recipes on startup, if they exist + QSettings settings("nutra", "nutra"); + if (!settings.value("recipesLoaded", false).toBool()) { + QString recipesPath = QDir::homePath() + "/.nutra/recipe"; + + if (QDir(recipesPath).exists()) { + RecipeRepository repo; + repo.loadCsvRecipes(recipesPath); + settings.setValue("recipesLoaded", true); + } else { + qWarning() << "Recipe directory does not exist:" << recipesPath; + } + } +} 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); - }); + setWindowTitle("Nutrient Coach"); + setWindowIcon(QIcon(":/resources/nutrition_icon-no_bg.png")); + resize(1000, 700); + + // File Menu + auto* fileMenu = menuBar()->addMenu("&File"); + auto* openDbAction = fileMenu->addAction("&Open Database..."); + recentFilesMenu = fileMenu->addMenu("Recent Databases"); + fileMenu->addSeparator(); + auto* exitAction = fileMenu->addAction("E&xit"); + connect(openDbAction, &QAction::triggered, this, &MainWindow::onOpenDatabase); + connect(exitAction, &QAction::triggered, this, &QWidget::close); + + for (auto& recentFileAction : recentFileActions) recentFilesMenu->addAction(recentFileAction); + + // Tools Menu + auto* toolsMenu = menuBar()->addMenu("&Tools"); + auto* reloadRecipesAction = toolsMenu->addAction("Reload Recipes from &CSV"); + connect(reloadRecipesAction, &QAction::triggered, this, &MainWindow::onReloadRecipes); + + // Edit Menu + QMenu* editMenu = menuBar()->addMenu("Edit"); + + QAction* preferencesAction = editMenu->addAction("Preferences"); + connect(preferencesAction, &QAction::triggered, this, [this]() { + PreferencesDialog dlg(repository, this); + if (dlg.exec() == QDialog::Accepted) { + searchWidget->reloadSettings(); + } + }); + + // Help Menu + auto* helpMenu = menuBar()->addMenu("&Help"); + + auto* licenseAction = helpMenu->addAction("&License"); + connect(licenseAction, &QAction::triggered, this, [this]() { + QMessageBox::information( + this, "License", + "

GNU General Public License v3.0

" + "

This program is free software: you can redistribute it and/or modify " + "it under the terms of the GNU General Public License as published by " + "the Free Software Foundation, either version 3 of the License, or " + "(at your option) any later version.

" + "

This program is distributed in the hope that it will be useful, " + "but WITHOUT ANY WARRANTY; without even the implied warranty of " + "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

" + "

See " + "https://www.gnu.org/licenses/gpl-3.0.html for details.

"); + }); + + auto* aboutAction = helpMenu->addAction("&About"); + connect(aboutAction, &QAction::triggered, this, &MainWindow::onAbout); + + 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 << ")"; + // Determine if we are in analysis mode or just searching + // For now, simpler handling: + detailsWidget->loadFood(foodId, foodName); + tabs->setCurrentWidget(detailsWidget); + + // Persist selection + QSettings settings("nutra", "nutra"); + settings.setValue("lastSelectedFoodId", foodId); + settings.setValue("lastSelectedFoodName", foodName); + }); + + connect(searchWidget, &SearchWidget::addToMealRequested, this, + [=](int foodId, const QString& foodName, double grams) { + mealWidget->addFood(foodId, foodName, grams); + tabs->setCurrentWidget(mealWidget); + }); + + connect(searchWidget, &SearchWidget::searchStatus, this, + [=](const QString& msg) { dbStatusLabel->setText(msg); }); + + // Analysis Tab + detailsWidget = new DetailsWidget(this); + tabs->addTab(detailsWidget, "Analyze"); + + // Meal Tab (Builder) + mealWidget = new MealWidget(this); + tabs->addTab(mealWidget, "Meal Builder"); + + // Recipes Tab + recipeWidget = new RecipeWidget(this); + tabs->addTab(recipeWidget, "Recipes"); + + // Daily Log Tab + dailyLogWidget = new DailyLogWidget(this); + tabs->addTab(dailyLogWidget, "Daily Log"); + + // Connect Analysis -> Meal + connect(detailsWidget, &DetailsWidget::addToMeal, this, + [=](int foodId, const QString& foodName, double grams) { + mealWidget->addFood(foodId, foodName, grams); + }); + + // Connect Meal Builder -> Daily Log + connect(mealWidget, &MealWidget::logUpdated, dailyLogWidget, &DailyLogWidget::refresh); + + // Status Bar + dbStatusLabel = new QLabel(this); + dbStatusLabel->setFrameStyle(QFrame::Panel | QFrame::Sunken); + statusBar()->addPermanentWidget(dbStatusLabel); + updateStatusBar(); + + // Restore last selection if available + QSettings settings("nutra", "nutra"); + if (settings.contains("lastSelectedFoodId") && settings.contains("lastSelectedFoodName")) { + int id = settings.value("lastSelectedFoodId").toInt(); + QString name = settings.value("lastSelectedFoodName").toString(); + // Defer slightly to ensure DB is ready if needed (though it should be open by now) + QTimer::singleShot(200, this, [this, id, name]() { + detailsWidget->loadFood(id, name); + // Optionally switch tab? Default is usually search or dashboard. + // Let's keep the user's focus where it makes sense. + // If they had a selection open, maybe they want to see it. + // tabs->setCurrentWidget(detailsWidget); + }); + } +} + +void MainWindow::updateStatusBar() { + auto& dbMgr = DatabaseManager::instance(); + QStringList parts; + QString tooltip = "Active Databases:\n"; + + if (dbMgr.isOpen()) { + parts << "USDA [Connected]"; + tooltip += QString("- USDA: %1\n").arg(dbMgr.database().databaseName()); + } else { + parts << "USDA [Disconnected]"; + } + + if (dbMgr.userDatabase().isOpen()) { + parts << "NTDB (User) [Connected]"; + tooltip += QString("- NTDB: %1\n").arg(dbMgr.userDatabase().databaseName()); + } else { + parts << "NTDB (User) [Disconnected]"; + } + + dbStatusLabel->setText("DB Status: " + parts.join(" | ")); + dbStatusLabel->setToolTip(tooltip.trimmed()); +} + +void MainWindow::onOpenDatabase() { + QString fileName = + QFileDialog::getOpenFileName(this, "Open USDA Database", QDir::homePath() + "/.nutra", + "SQLite Databases (*.sqlite3 *.db)"); + + if (!fileName.isEmpty()) { + auto& dbMgr = DatabaseManager::instance(); + QString canonicalFileName = QFileInfo(fileName).canonicalFilePath(); + + // Check if it's the already open USDA DB + if (dbMgr.isOpen()) { + QString currentUsda = QFileInfo(dbMgr.database().databaseName()).canonicalFilePath(); + if (currentUsda == canonicalFileName) { + QMessageBox::information(this, "Already Open", + "This USDA database is already loaded."); + return; + } + } + + // Check if it's the active User DB + if (dbMgr.userDatabase().isOpen()) { + QString currentUser = + QFileInfo(dbMgr.userDatabase().databaseName()).canonicalFilePath(); + if (currentUser == canonicalFileName) { + QMessageBox::information( + this, "Already Connected", + "This is your active User Database (NTDB). It is already connected."); + return; + } + } + + if (dbMgr.connect(fileName)) { + qDebug() << "Switched to database:" << fileName; + addToRecentFiles(fileName); + updateStatusBar(); + // In a real app, we'd emit a signal or refresh all widgets + // For now, let's just log and show success + QMessageBox::information(this, "Database Opened", + "Successfully connected to: " + fileName); + } else { + QMessageBox::critical(this, "Database Error", "Failed to connect to the database."); + } + } +} + +void MainWindow::onRecentFileClick() { + auto* action = qobject_cast(sender()); + if (action != nullptr) { + QString fileName = action->data().toString(); + auto& dbMgr = DatabaseManager::instance(); + + QString canonicalFileName = QFileInfo(fileName).canonicalFilePath(); + + // Check USDA + if (dbMgr.isOpen()) { + QString currentUsda = QFileInfo(dbMgr.database().databaseName()).canonicalFilePath(); + if (currentUsda == canonicalFileName) { + QMessageBox::information(this, "Already Open", + "This USDA database is already loaded."); + return; + } + } + + // Check User + if (dbMgr.userDatabase().isOpen()) { + QString currentUser = + QFileInfo(dbMgr.userDatabase().databaseName()).canonicalFilePath(); + if (currentUser == canonicalFileName) { + QMessageBox::information( + this, "Already Connected", + "This is your active User Database (NTDB). It is already connected."); + return; + } + } + + if (dbMgr.connect(fileName)) { + qDebug() << "Switched to database (recent):" << fileName; + addToRecentFiles(fileName); + updateStatusBar(); + QMessageBox::information(this, "Database Opened", + "Successfully connected to: " + fileName); + } else { + QMessageBox::critical(this, "Database Error", "Failed to connect to: " + fileName); + } + } +} + +void MainWindow::updateRecentFileActions() { + QSettings settings("nutra", "nutra"); + + // Check for legacy setting if new one is empty + if (!settings.contains("recentFilesList") && settings.contains("recentFiles")) { + QStringList legacyFiles = settings.value("recentFiles").toStringList(); + QList newFiles; + for (const auto& path : legacyFiles) { + auto info = DatabaseManager::instance().getDatabaseInfo(path); + if (info.isValid) { // Only migrate valid ones + QVariantMap entry; + entry["path"] = path; + entry["type"] = info.type; + entry["version"] = info.version; + newFiles.append(entry); + } + } + settings.setValue("recentFilesList", newFiles); + settings.remove("recentFiles"); // Clean up legacy + } + + QList files = settings.value("recentFilesList").toList(); + + // Sort: User first, then USDA. Within type, preserve order (recency) or sort by name? + // Usually "Recent" implies recency. But user asked for "User on top". + // So we split into two lists (preserving recency within them) and concat. + + QList userDBs; + QList usdaDBs; + + for (const auto& v : files) { + QVariantMap m = v.toMap(); + if (m["type"].toString() == "User") { + userDBs.append(m); + } else { + usdaDBs.append(m); + } + } + + QList sortedFiles = userDBs; + sortedFiles.append(usdaDBs); + + int numToShow = static_cast(qMin(static_cast(sortedFiles.size()), + static_cast(MaxRecentFiles))); + + QFontMetrics fontMetrics(recentFilesMenu->font()); + + for (int i = 0; i < numToShow; ++i) { + QVariantMap m = sortedFiles[i]; + QString path = m["path"].toString(); + QString type = m["type"].toString(); + int version = m["version"].toInt(); + + // Elide path to ~400 pixels (roughly 50-60 chars depending on font) + QString elidedPath = fontMetrics.elidedText(path, Qt::ElideMiddle, 400); + + // Format: "/path/to/file... (User v9)" + QString text = QString("&%1 %2 (%3 v%4)").arg(i + 1).arg(elidedPath).arg(type).arg(version); + + recentFileActions[static_cast(i)]->setText(text); + recentFileActions[static_cast(i)]->setData(path); + recentFileActions[static_cast(i)]->setVisible(true); + } + for (int i = numToShow; i < MaxRecentFiles; ++i) + recentFileActions[static_cast(i)]->setVisible(false); + + recentFilesMenu->setEnabled(numToShow > 0); +} + +void MainWindow::addToRecentFiles(const QString& path) { + if (path.isEmpty()) return; + + auto info = DatabaseManager::instance().getDatabaseInfo(path); + if (!info.isValid) return; + + QSettings settings("nutra", "nutra"); + // Read list of QVariantMaps + QList files = settings.value("recentFilesList").toList(); + + // Remove existing entry for this path + for (int i = 0; i < files.size(); ++i) { + if (files[i].toMap()["path"].toString() == path) { + files.removeAt(i); + break; + } + } + + // Prepare new entry + QVariantMap entry; + entry["path"] = path; + entry["type"] = info.type; + entry["version"] = info.version; + + // Prepend new entry + files.prepend(entry); + + // Limit list size + while (files.size() > MaxRecentFiles) { + files.removeLast(); + } + + settings.setValue("recentFilesList", files); + updateRecentFileActions(); +} + +void MainWindow::onSettings() { + QMessageBox::information(this, "Settings", "Settings dialog coming soon!"); +} + +void MainWindow::onAbout() { + QMessageBox::about( + this, "About Nutrient Coach", + "

Nutrient Coach

" + "

A true nutrition coach, giving insights into what you're getting and what you're " + "lacking.

" + "

Homepage: https://github.com/nutratech/gui

"); +} + +void MainWindow::onReloadRecipes() { + QString recipesPath = QDir::homePath() + "/.nutra/recipe"; + if (!QDir(recipesPath).exists()) { + QMessageBox::warning(this, "Directory Not Found", + "Recipe directory not found at: " + recipesPath); + return; + } + + auto reply = QMessageBox::question( + this, "Reload Recipes", + "This will re-scan the recipe directory and add any new recipes found.\n\n" + "Do you want to continue?", + QMessageBox::Yes | QMessageBox::No); + if (reply == QMessageBox::Yes) { + RecipeRepository repo; + repo.loadCsvRecipes(recipesPath); + QMessageBox::information(this, "Reload Complete", "Recipe directory scan complete."); + if (recipeWidget) { + recipeWidget->loadRecipes(); + } + } } diff --git a/src/utils/pythonservicemanager.cpp b/src/utils/pythonservicemanager.cpp new file mode 100644 index 0000000..1955b5c --- /dev/null +++ b/src/utils/pythonservicemanager.cpp @@ -0,0 +1,191 @@ +#include "utils/pythonservicemanager.h" + +#include +#include +#include +#include +#include +#include + +PythonServiceManager& PythonServiceManager::instance() { + static PythonServiceManager instance; + return instance; +} + +PythonServiceManager::PythonServiceManager(QObject* parent) : QObject(parent) { + m_network = new QNetworkAccessManager(this); + + // Load settings + QSettings settings; + m_enabled = settings.value(SETTING_ENABLED, false).toBool(); + m_port = settings.value(SETTING_PORT, 5001).toInt(); + + findPythonPath(); + + // Auto-start if enabled + if (m_enabled) { + startService(); + } +} + +PythonServiceManager::~PythonServiceManager() { + stopService(); +} + +bool PythonServiceManager::isEnabled() const { + return m_enabled; +} + +void PythonServiceManager::setEnabled(bool enabled) { + if (m_enabled == enabled) return; + + m_enabled = enabled; + QSettings settings; + settings.setValue(SETTING_ENABLED, enabled); + + if (enabled) { + startService(); + } else { + stopService(); + } +} + +bool PythonServiceManager::isRunning() const { + return m_process != nullptr && m_process->state() == QProcess::Running; +} + +void PythonServiceManager::findPythonPath() { + // Try common Python paths + QStringList candidates = { + "python3", + "python", +#ifdef Q_OS_WIN + "py", +#endif + }; + + for (const QString& candidate : candidates) { + QString path = QStandardPaths::findExecutable(candidate); + if (!path.isEmpty()) { + m_pythonPath = path; + qDebug() << "Found Python at:" << m_pythonPath; + return; + } + } + + qWarning() << "Python not found in PATH"; +} + +void PythonServiceManager::startService() { + if (isRunning()) { + qDebug() << "Python service already running"; + return; + } + + if (m_pythonPath.isEmpty()) { + emit parseError("Python not found. Install Python 3 to use NLP features."); + return; + } + + if (m_process != nullptr) { + m_process->deleteLater(); + } + + m_process = new QProcess(this); + + // Find the pylang_serv module + QString modulePath; + + // Check relative to app (development) + QString devPath = QCoreApplication::applicationDirPath() + "/../lib/pylang_serv"; + if (QDir(devPath).exists()) { + modulePath = devPath; + } + + // Check installed location + QString installPath = "/usr/share/nutra/pylang_serv"; + if (modulePath.isEmpty() && QDir(installPath).exists()) { + modulePath = installPath; + } + + // Check user local + QString userPath = QDir::homePath() + "/.local/share/nutra/pylang_serv"; + if (modulePath.isEmpty() && QDir(userPath).exists()) { + modulePath = userPath; + } + + if (modulePath.isEmpty()) { + emit parseError("Python NLP module not found. Ensure pylang_serv is installed."); + return; + } + + connect(m_process, &QProcess::started, this, [this]() { + qDebug() << "Python NLP service started on port" << m_port; + emit serviceStarted(); + }); + + connect(m_process, QOverload::of(&QProcess::finished), this, + [this](int exitCode, QProcess::ExitStatus status) { + qDebug() << "Python service stopped. Exit code:" << exitCode; + emit serviceStopped(); + }); + + connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + qWarning() << "Python service error:" << error; + emit parseError("Failed to start Python service"); + }); + + // Start the server + m_process->setWorkingDirectory(modulePath); + m_process->start(m_pythonPath, {"-m", "pylang_serv.server", "--port", QString::number(m_port)}); +} + +void PythonServiceManager::stopService() { + if (m_process == nullptr) return; + + if (m_process->state() == QProcess::Running) { + m_process->terminate(); + if (!m_process->waitForFinished(3000)) { + m_process->kill(); + } + } + + m_process->deleteLater(); + m_process = nullptr; +} + +void PythonServiceManager::parseIngredient(const QString& text) { + if (!isRunning()) { + emit parseError("NLP service not running. Enable it in Settings."); + return; + } + + QUrl url(QString("http://127.0.0.1:%1/parse").arg(m_port)); + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QJsonObject json; + json["text"] = text; + QByteArray data = QJsonDocument(json).toJson(); + + QNetworkReply* reply = m_network->post(request, data); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + emit parseError(reply->errorString()); + return; + } + + QByteArray responseData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(responseData); + + if (doc.isNull() || !doc.isObject()) { + emit parseError("Invalid response from NLP service"); + return; + } + + emit parseComplete(doc.object()); + }); +} diff --git a/src/utils/string_utils.cpp b/src/utils/string_utils.cpp index a6d1635..4c7bf21 100644 --- a/src/utils/string_utils.cpp +++ b/src/utils/string_utils.cpp @@ -1,112 +1,110 @@ #include "utils/string_utils.h" + #include #include #include -#include // Required for std::max +#include // Required for std::max #include #ifndef QT_VERSION_CHECK -#define QT_VERSION_CHECK(major, minor, patch) \ - ((major << 16) | (minor << 8) | (patch)) +#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]}); - } +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]; + 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,-]+"); +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; + auto behavior = Qt::SkipEmptyParts; #else - auto behavior = QString::SkipEmptyParts; + 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); + 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++; } - totalScore += maxTokenScore; - if (maxTokenScore > 60) - matchedTokens++; - } - if (queryTokens.isEmpty()) { - return 0; - } + if (queryTokens.isEmpty()) { + return 0; + } - int averageScore = static_cast(totalScore / queryTokens.size()); + int averageScore = static_cast(totalScore / queryTokens.size()); - // Penalize if not all tokens matched somewhat well - if (matchedTokens < queryTokens.size()) { - averageScore -= 20; - } + // Penalize if not all tokens matched somewhat well + if (matchedTokens < queryTokens.size()) { + averageScore -= 20; + } - return std::max(0, averageScore); + return std::max(0, averageScore); } -} // namespace Utils +} // namespace Utils diff --git a/src/widgets/dailylogwidget.cpp b/src/widgets/dailylogwidget.cpp new file mode 100644 index 0000000..c061148 --- /dev/null +++ b/src/widgets/dailylogwidget.cpp @@ -0,0 +1,238 @@ +#include "widgets/dailylogwidget.h" + +#include +#include +#include +#include +#include +#include + +DailyLogWidget::DailyLogWidget(QWidget* parent) : QWidget(parent) { + setupUi(); + setToday(); +} + +void DailyLogWidget::setupUi() { + auto* mainLayout = new QVBoxLayout(this); + + auto* splitter = new QSplitter(Qt::Vertical, this); + mainLayout->addWidget(splitter); + + // --- Top: Log Table --- + // --- Top: Log Table --- + auto* topWidget = new QWidget(this); + auto* topLayout = new QVBoxLayout(topWidget); + topLayout->setContentsMargins(0, 0, 0, 0); + + // Date Navigation Header + auto* navLayout = new QHBoxLayout(); + auto* prevBtn = new QPushButton("<", this); + prevBtn->setFixedWidth(30); + + dateLabel = new QLabel(this); + dateLabel->setAlignment(Qt::AlignCenter); + QFont font = dateLabel->font(); + font.setBold(true); + font.setPointSize(12); + dateLabel->setFont(font); + + auto* nextBtn = new QPushButton(">", this); + nextBtn->setFixedWidth(30); + + auto* todayBtn = new QPushButton("Today", this); + + navLayout->addWidget(prevBtn); + navLayout->addStretch(); + navLayout->addWidget(dateLabel); + navLayout->addStretch(); + navLayout->addWidget(nextBtn); + navLayout->addWidget(todayBtn); + + topLayout->addLayout(navLayout); + + connect(prevBtn, &QPushButton::clicked, this, &DailyLogWidget::prevDay); + connect(nextBtn, &QPushButton::clicked, this, &DailyLogWidget::nextDay); + connect(todayBtn, &QPushButton::clicked, this, &DailyLogWidget::setToday); + + topLayout->addWidget(new QLabel("Food Log", this)); + + logTable = new QTableWidget(this); + logTable->setColumnCount(4); + logTable->setHorizontalHeaderLabels({"Meal", "Food", "Amount", "Calories"}); + logTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + topLayout->addWidget(logTable); + + splitter->addWidget(topWidget); + + // --- Bottom: Analysis --- + auto* bottomWidget = new QWidget(this); + auto* bottomLayout = new QVBoxLayout(bottomWidget); + bottomLayout->setContentsMargins(0, 0, 0, 0); + + auto* analysisBox = new QGroupBox("Analysis (Projected)", this); + auto* analysisLayout = new QVBoxLayout(analysisBox); + + // Scale Controls + auto* scaleLayout = new QHBoxLayout(); + scaleLayout->addWidget(new QLabel("Project to Goal:", this)); + + scaleInput = new QSpinBox(this); + scaleInput->setRange(0, 50000); + scaleInput->setValue(2000); // Default Goal + scaleLayout->addWidget(scaleInput); + + scaleLayout->addWidget(new QLabel("kcal", this)); + scaleLayout->addStretch(); + analysisLayout->addLayout(scaleLayout); + + connect(scaleInput, QOverload::of(&QSpinBox::valueChanged), this, + &DailyLogWidget::updateAnalysis); + + // Analysis Table + analysisTable = new QTableWidget(this); + analysisTable->setColumnCount(3); + analysisTable->setHorizontalHeaderLabels({"Nutrient", "Progress", "Detail"}); + analysisTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + analysisTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + analysisTable->setSelectionMode(QAbstractItemView::NoSelection); + analysisLayout->addWidget(analysisTable); + + bottomLayout->addWidget(analysisBox); + splitter->addWidget(bottomWidget); + + // Set initial sizes + splitter->setStretchFactor(0, 3); + splitter->setStretchFactor(1, 2); +} + +void DailyLogWidget::refresh() { + onDateChanged(); +} + +void DailyLogWidget::prevDay() { + currentDate = currentDate.addDays(-1); + onDateChanged(); +} + +void DailyLogWidget::nextDay() { + currentDate = currentDate.addDays(1); + onDateChanged(); +} + +void DailyLogWidget::setToday() { + currentDate = QDate::currentDate(); + onDateChanged(); +} + +void DailyLogWidget::onDateChanged() { + // Update Label + QString text = currentDate.toString("ddd, MMM d, yyyy"); + if (currentDate == QDate::currentDate()) { + text += " (Today)"; + } + dateLabel->setText(text); + + updateTable(); + updateAnalysis(); +} + +void DailyLogWidget::updateAnalysis() { + std::map totals; // id -> amount + auto logs = m_mealRepo.getDailyLogs(currentDate); + + for (const auto& log : logs) { + auto nutrients = m_foodRepo.getFoodNutrients(log.foodId); + double scale = log.grams / 100.0; + for (const auto& nut : nutrients) { + totals[nut.id] += nut.amount * scale; + } + } + + double goalKcal = scaleInput->value(); + double currentKcal = totals[208]; + double multiplier = 1.0; + if (currentKcal > 0 && goalKcal > 0) { + multiplier = goalKcal / currentKcal; + } + + analysisTable->setRowCount(0); + + // Iterate over defined RDAs from repository + auto rdas = m_foodRepo.getNutrientRdas(); + for (const auto& [nutrId, rda] : rdas) { + if (rda <= 0) continue; + + double val = totals[nutrId]; + double projectedVal = val * multiplier; + double pct = (projectedVal / rda) * 100.0; + QString unit = m_foodRepo.getNutrientUnit(nutrId); + QString name = m_foodRepo.getNutrientName(nutrId); + + int row = analysisTable->rowCount(); + analysisTable->insertRow(row); + + // 1. Nutrient Name + analysisTable->setItem(row, 0, new QTableWidgetItem(name)); + + // 2. Progress Bar + auto* bar = new QProgressBar(); + bar->setRange(0, 100); + bar->setValue(std::min(static_cast(pct), 100)); + bar->setTextVisible(true); + bar->setFormat(QString("%1%").arg(pct, 0, 'f', 1)); + + // Coloring logic based on CLI thresholds + QString color = "#3498db"; // Default Blue + if (pct < 50) + color = "#f1c40f"; // Yellow (Under) + else if (pct > 150) + color = "#8e44ad"; // Purple (Over) + else if (pct >= 100) + color = "#2ecc71"; // Green (Good) + + bar->setStyleSheet(QString("QProgressBar::chunk { background-color: %1; }").arg(color)); + analysisTable->setCellWidget(row, 1, bar); + + // 3. Detail Text + QString detail = QString("%1 (%2) / %3 %4") + .arg(val, 0, 'f', 1) + .arg(projectedVal, 0, 'f', 1) + .arg(rda, 0, 'f', 1) + .arg(unit); + analysisTable->setItem(row, 2, new QTableWidgetItem(detail)); + } +} + +void DailyLogWidget::updateTable() { + logTable->setRowCount(0); + + // Get logs for selected date + auto logs = m_mealRepo.getDailyLogs(currentDate); + + for (const auto& log : logs) { + int row = logTable->rowCount(); + logTable->insertRow(row); + + // Meal Name + logTable->setItem(row, 0, new QTableWidgetItem(log.mealName)); + + // Food Name + logTable->setItem(row, 1, new QTableWidgetItem(log.foodName)); + + // Amount + logTable->setItem(row, 2, new QTableWidgetItem(QString::number(log.grams, 'f', 1) + " g")); + + // Calories Calculation + // TODO: optimize by fetching in batch or using repository better? + // For now, simple fetch is fine for valid DBs + auto nutrients = m_foodRepo.getFoodNutrients(log.foodId); + double kcal = 0; + for (const auto& nut : nutrients) { + if (nut.id == 208) { // KCAL + kcal = (nut.amount * log.grams) / 100.0; + break; + } + } + logTable->setItem(row, 3, new QTableWidgetItem(QString::number(kcal, 'f', 0) + " kcal")); + } +} diff --git a/src/widgets/detailswidget.cpp b/src/widgets/detailswidget.cpp index c775d05..8a32407 100644 --- a/src/widgets/detailswidget.cpp +++ b/src/widgets/detailswidget.cpp @@ -1,63 +1,215 @@ #include "widgets/detailswidget.h" + +#include +#include +#include #include #include #include +#include +#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); +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); + // Context Menu + nameLabel->setContextMenuPolicy(Qt::CustomContextMenu); + connect(nameLabel, &QLabel::customContextMenuRequested, this, [this](const QPoint& pos) { + if (currentFoodId == -1) return; + + QMenu menu(this); + QAction* copyAction = menu.addAction("Copy Food ID"); + connect(copyAction, &QAction::triggered, this, + [this]() { QApplication::clipboard()->setText(QString::number(currentFoodId)); }); + menu.exec(nameLabel->mapToGlobal(pos)); + }); + addButton = new QPushButton("Add to Meal", this); + addButton->setEnabled(false); + connect(addButton, &QPushButton::clicked, this, &DetailsWidget::onAddClicked); + + headerLayout->addWidget(nameLabel); + + copyIdBtn = new QToolButton(this); + copyIdBtn->setText("Copy ID"); + copyIdBtn->setVisible(false); + connect(copyIdBtn, &QToolButton::clicked, this, [this]() { + if (currentFoodId != -1) { + QApplication::clipboard()->setText(QString::number(currentFoodId)); + } + }); + headerLayout->addWidget(copyIdBtn); + + headerLayout->addStretch(); + clearButton = new QPushButton("Clear", this); + clearButton->setVisible(false); + connect(clearButton, &QPushButton::clicked, this, &DetailsWidget::clear); + + headerLayout->addWidget(clearButton); + headerLayout->addWidget(addButton); + layout->addLayout(headerLayout); + + // Scaling Controls + auto* scaleLayout = new QHBoxLayout(); + scaleCheckbox = new QCheckBox("Scale to:", this); + scaleSpinBox = new QSpinBox(this); + scaleSpinBox->setRange(500, 10000); + scaleSpinBox->setSingleStep(50); + scaleSpinBox->setSuffix(" kcal"); + + // Load last target + QSettings settings("nutra", "nutra"); + scaleSpinBox->setValue(settings.value("analysisTargetKcal", 2000).toInt()); + scaleCheckbox->setChecked(false); // Default off + + scaleLayout->addStretch(); + + hideEmptyCheckbox = new QCheckBox("Hide Empty", this); + hideEmptyCheckbox->setChecked(false); // Default show all + connect(hideEmptyCheckbox, &QCheckBox::toggled, this, &DetailsWidget::updateTable); + scaleLayout->addWidget(hideEmptyCheckbox); + + scaleLayout->addWidget(scaleCheckbox); + scaleLayout->addWidget(scaleSpinBox); + layout->addLayout(scaleLayout); + + connect(scaleCheckbox, &QCheckBox::toggled, this, &DetailsWidget::updateTable); + connect(scaleSpinBox, QOverload::of(&QSpinBox::valueChanged), this, [this](int val) { + QSettings settings("nutra", "nutra"); + settings.setValue("analysisTargetKcal", val); + if (scaleCheckbox->isChecked()) updateTable(); + }); + + // Nutrients Table + nutrientsTable = new QTableWidget(this); + nutrientsTable->setColumnCount(3); + nutrientsTable->setHorizontalHeaderLabels({"Nutrient", "Progress", "Detail"}); + nutrientsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + nutrientsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + nutrientsTable->setSelectionMode(QAbstractItemView::NoSelection); + 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); + copyIdBtn->setVisible(true); + clearButton->setVisible(true); + updateTable(); } -void DetailsWidget::loadFood(int foodId, const QString &foodName) { - currentFoodId = foodId; - currentFoodName = foodName; - nameLabel->setText(foodName + QString(" (ID: %1)").arg(foodId)); - addButton->setEnabled(true); +void DetailsWidget::clear() { + currentFoodId = -1; + currentFoodName.clear(); + nameLabel->setText("No food selected"); + addButton->setEnabled(false); + copyIdBtn->setVisible(false); + clearButton->setVisible(false); + nutrientsTable->setRowCount(0); +} + +void DetailsWidget::updateTable() { + if (currentFoodId == -1) return; + + nutrientsTable->setRowCount(0); + + std::vector nutrients = repository.getFoodNutrients(currentFoodId); + auto rdas = repository.getNutrientRdas(); + + double multiplier = calculateScaleMultiplier(nutrients); + bool hideEmpty = hideEmptyCheckbox->isChecked(); + + for (const auto& nut : nutrients) { + double multiplierVal = nut.amount * multiplier; + + if (hideEmpty && multiplierVal < 0.01) { + continue; + } + + addNutrientRow(nut, multiplier, rdas); + } +} + +double DetailsWidget::calculateScaleMultiplier(const std::vector& nutrients) { + if (!scaleCheckbox->isChecked()) return 1.0; + + // Find calories (ID 208) + double kcalPer100g = 0; + for (const auto& n : nutrients) { + if (n.id == 208) { + kcalPer100g = n.amount; + break; + } + } + + double target = scaleSpinBox->value(); + if (kcalPer100g > 0 && target > 0) { + return target / kcalPer100g; + } + return 1.0; +} + +void DetailsWidget::addNutrientRow(const Nutrient& nut, double multiplier, + const std::map& rdas) { + int row = nutrientsTable->rowCount(); + nutrientsTable->insertRow(row); + + nutrientsTable->setItem(row, 0, new QTableWidgetItem(nut.description)); + + double rda = 0; + if (rdas.count(nut.id) != 0U) { + rda = rdas.at(nut.id); + } + + double val = nut.amount * multiplier; - nutrientsTable->setRowCount(0); + // Progress Bar + auto* bar = new QProgressBar(); + bar->setRange(0, 100); + int pct = 0; + if (rda > 0) pct = static_cast((val / rda) * 100.0); + bar->setValue(std::min(pct, 100)); + bar->setTextVisible(true); + bar->setFormat(QString("%1%").arg(pct)); - std::vector nutrients = repository.getFoodNutrients(foodId); + // Color logic + QString color = "#bdc3c7"; // Grey if no RDA + if (rda > 0) { + color = "#3498db"; // Blue + if (pct < 50) + color = "#f1c40f"; // Yellow + else if (pct > 150) + color = "#8e44ad"; // Purple + else if (pct >= 100) + color = "#2ecc71"; // Green + } + bar->setStyleSheet(QString("QProgressBar::chunk { background-color: %1; }").arg(color)); + nutrientsTable->setCellWidget(row, 1, bar); - 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)); - } + // Detail + QString detail; + if (rda > 0) { + detail = QString("%1 / %2 %3").arg(val, 0, 'f', 1).arg(rda, 0, 'f', 1).arg(nut.unit); + } else { + detail = QString("%1 %2").arg(val, 0, 'f', 1).arg(nut.unit); + } + nutrientsTable->setItem(row, 2, new QTableWidgetItem(detail)); } void DetailsWidget::onAddClicked() { - if (currentFoodId != -1) { - // Default 100g - emit addToMeal(currentFoodId, currentFoodName, 100.0); - } + if (currentFoodId != -1) { + // Default 100g + emit addToMeal(currentFoodId, currentFoodName, 100.0); + } } diff --git a/src/widgets/mealwidget.cpp b/src/widgets/mealwidget.cpp index 9078ebd..03452bf 100644 --- a/src/widgets/mealwidget.cpp +++ b/src/widgets/mealwidget.cpp @@ -1,98 +1,136 @@ #include "widgets/mealwidget.h" + #include #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); +MealWidget::MealWidget(QWidget* parent) : QWidget(parent) { + auto* layout = new QVBoxLayout(this); + + // Items List + layout->addWidget(new QLabel("Meal Composition (Builder)", this)); + itemsTable = new QTableWidget(this); + itemsTable->setColumnCount(3); + itemsTable->setHorizontalHeaderLabels({"Food", "Grams", "Calories"}); + itemsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + layout->addWidget(itemsTable); + + // Controls + auto* buttonLayout = new QHBoxLayout(); + + auto* addToLogButton = new QPushButton("Add to Log", this); + connect(addToLogButton, &QPushButton::clicked, this, &MealWidget::onAddToLog); + buttonLayout->addWidget(addToLogButton); + + clearButton = new QPushButton("Clear Builder", this); + connect(clearButton, &QPushButton::clicked, this, &MealWidget::clearMeal); + buttonLayout->addWidget(clearButton); + + layout->addLayout(buttonLayout); + + // Totals + layout->addWidget(new QLabel("Predicted Nutrition", this)); + totalsTable = new QTableWidget(this); + totalsTable->setColumnCount(3); + totalsTable->setHorizontalHeaderLabels({"Nutrient", "Total", "Unit"}); + totalsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + layout->addWidget(totalsTable); + + refresh(); } -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; +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); + + refresh(); +} + +void MealWidget::onAddToLog() { + if (mealItems.empty()) return; + + // TODO: Add meal selection dialog? For now default to Breakfast/General (1) + int mealId = 1; + + for (const auto& item : mealItems) { + m_mealRepo.addFoodLog(item.foodId, item.grams, mealId); } - } - itemsTable->setItem(row, 2, - new QTableWidgetItem(QString::number(kcal, 'f', 1))); - updateTotals(); + emit logUpdated(); + + mealItems.clear(); + refresh(); + + QMessageBox::information(this, "Logged", "Meal added to daily log."); +} + +void MealWidget::refresh() { + itemsTable->setRowCount(0); + + for (const auto& item : mealItems) { + int row = itemsTable->rowCount(); + itemsTable->insertRow(row); + itemsTable->setItem(row, 0, new QTableWidgetItem(item.name)); + itemsTable->setItem(row, 1, new QTableWidgetItem(QString::number(item.grams))); + + double kcal = 0; + for (const auto& nut : item.nutrients_100g) { + if (nut.id == 208) { + kcal = (nut.amount * item.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(); + if (mealItems.empty()) return; + + auto reply = QMessageBox::question(this, "Clear Builder", + "Are you sure you want to clear the current meal builder?", + QMessageBox::Yes | QMessageBox::No); + + if (reply == QMessageBox::Yes) { + mealItems.clear(); + refresh(); + } } 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); + 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++; } - } - - 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/preferencesdialog.cpp b/src/widgets/preferencesdialog.cpp new file mode 100644 index 0000000..b971ec9 --- /dev/null +++ b/src/widgets/preferencesdialog.cpp @@ -0,0 +1,209 @@ +#include "widgets/preferencesdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "db/databasemanager.h" +#include "utils/pythonservicemanager.h" +#include "widgets/profilesettingswidget.h" +#include "widgets/rdasettingswidget.h" + +PreferencesDialog::PreferencesDialog(FoodRepository& repository, QWidget* parent) + : QDialog(parent), m_repository(repository) { + setWindowTitle("Preferences"); + setMinimumSize(550, 450); + setupUi(); + loadStatistics(); + loadGeneralSettings(); +} + +void PreferencesDialog::setupUi() { + auto* mainLayout = new QVBoxLayout(this); + + tabWidget = new QTabWidget(this); + + // === General Tab === + auto* generalWidget = new QWidget(); + auto* generalLayout = new QFormLayout(generalWidget); + + debounceSpin = new QSpinBox(this); + debounceSpin->setRange(100, 5000); + debounceSpin->setSingleStep(50); + debounceSpin->setSuffix(" ms"); + generalLayout->addRow("Search Debounce:", debounceSpin); + + nlpCheckBox = new QCheckBox("Enable Natural Language Parsing", this); + nlpCheckBox->setToolTip("Analyzes ingredient text for improved search (requires Python 3)"); + generalLayout->addRow("NLP Service:", nlpCheckBox); + + tabWidget->addTab(generalWidget, "General"); + + // === Profile Tab === + profileWidget = new ProfileSettingsWidget(this); + tabWidget->addTab(profileWidget, "Profile"); + + // === Usage Statistics Tab === + auto* statsWidget = new QWidget(); + auto* statsLayout = new QVBoxLayout(statsWidget); + + // --- Counts Group --- + auto* countsGroup = new QGroupBox("Data Counts"); + auto* countsLayout = new QFormLayout(countsGroup); + + lblFoodLogs = new QLabel("--"); + lblCustomFoods = new QLabel("--"); + lblRdaOverrides = new QLabel("--"); + lblRecipes = new QLabel("--"); + lblSnapshots = new QLabel("--"); + + countsLayout->addRow("Food Log Entries:", lblFoodLogs); + countsLayout->addRow("Custom Foods:", lblCustomFoods); + countsLayout->addRow("RDA Overrides:", lblRdaOverrides); + countsLayout->addRow("Recipes:", lblRecipes); + countsLayout->addRow("Snapshots/Backups:", lblSnapshots); + + // --- Sizes Group --- + auto* sizesGroup = new QGroupBox("Database Sizes"); + auto* sizesLayout = new QFormLayout(sizesGroup); + + lblUsdaSize = new QLabel("--"); + lblUserSize = new QLabel("--"); + lblBackupSize = new QLabel("--"); + + sizesLayout->addRow("USDA Database:", lblUsdaSize); + sizesLayout->addRow("User Database:", lblUserSize); + sizesLayout->addRow("Total Backup Size:", lblBackupSize); + + // --- Disclaimer --- + auto* disclaimerLabel = new QLabel( + "Note: The USDA database contains public domain data and is " + "read-only. Only your personal user data (logs, custom foods, " + "RDAs, recipes) is backed up.

" + "You are encouraged to periodically upload your snapshots to an " + "online drive, archive them in a Git repository, or save them to a " + "backup USB stick or external HDD."); + disclaimerLabel->setWordWrap(true); + disclaimerLabel->setStyleSheet("color: #666; padding: 10px;"); + + statsLayout->addWidget(countsGroup); + statsLayout->addWidget(sizesGroup); + statsLayout->addWidget(disclaimerLabel); + statsLayout->addStretch(); + + tabWidget->addTab(statsWidget, "Usage Statistics"); + + // === RDA Settings Tab === + rdaWidget = new RDASettingsWidget(m_repository, this); + tabWidget->addTab(rdaWidget, "RDA Settings"); + + mainLayout->addWidget(tabWidget); + + // Buttons + auto* buttonBox = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel, this); + mainLayout->addWidget(buttonBox); + + connect(buttonBox, &QDialogButtonBox::accepted, this, &PreferencesDialog::save); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +void PreferencesDialog::loadGeneralSettings() { + QSettings settings("nutra", "nutra"); + debounceSpin->setValue(settings.value("searchDebounce", 600).toInt()); + + bool nlpEnabled = PythonServiceManager::instance().isEnabled(); + nlpCheckBox->setChecked(nlpEnabled); +} + +void PreferencesDialog::save() { + // Save General + QSettings settings("nutra", "nutra"); + settings.setValue("searchDebounce", debounceSpin->value()); + + PythonServiceManager::instance().setEnabled(nlpCheckBox->isChecked()); + + // Save Profile + if (profileWidget != nullptr) profileWidget->save(); + + // RDA saves automatically on edit in its own widget (checking RDASettingsWidget design + // recommended, assuming yes for now or needs explicit save call if it supports it) Actually + // RDASettingsWidget might need a save call. Let's check? Usually dialogs save on accept. But + // for now, let's assume RDASettingsWidget handles its own stuff or doesn't need explicit save + // call from here if it's direct DB. + + accept(); +} + +void PreferencesDialog::loadStatistics() { + QSqlDatabase userDb = DatabaseManager::instance().userDatabase(); + QSqlDatabase usdaDb = DatabaseManager::instance().database(); + + // --- Counts --- + if (userDb.isOpen()) { + QSqlQuery q(userDb); + + q.exec("SELECT COUNT(*) FROM log_food"); + if (q.next()) lblFoodLogs->setText(QString::number(q.value(0).toInt())); + + q.exec("SELECT COUNT(*) FROM custom_food"); + if (q.next()) lblCustomFoods->setText(QString::number(q.value(0).toInt())); + + q.exec("SELECT COUNT(*) FROM rda"); + if (q.next()) lblRdaOverrides->setText(QString::number(q.value(0).toInt())); + } + + // --- Recipe Count (from filesystem) --- + QString recipePath = QDir::homePath() + "/.nutra/recipe"; + QDir recipeDir(recipePath); + int recipeCount = 0; + if (recipeDir.exists()) { + recipeCount = static_cast(recipeDir.entryList(QDir::Files).count()); + } + lblRecipes->setText(QString::number(recipeCount)); + + // --- Snapshot Count and Size --- + QString backupPath = QDir::homePath() + "/.nutra/backups"; + QDir backupDir(backupPath); + int snapshotCount = 0; + qint64 totalBackupSize = 0; + if (backupDir.exists()) { + QFileInfoList files = backupDir.entryInfoList({"*.sql.gz"}, QDir::Files); + snapshotCount = static_cast(files.count()); + for (const auto& fi : files) { + totalBackupSize += fi.size(); + } + } + lblSnapshots->setText(QString::number(snapshotCount)); + lblBackupSize->setText(formatBytes(totalBackupSize)); + + // --- Database Sizes --- + if (usdaDb.isOpen()) { + QFileInfo usdaInfo(usdaDb.databaseName()); + lblUsdaSize->setText(formatBytes(usdaInfo.size())); + } + + if (userDb.isOpen()) { + QFileInfo userInfo(userDb.databaseName()); + lblUserSize->setText(formatBytes(userInfo.size())); + } +} + +QString PreferencesDialog::formatBytes(qint64 bytes) const { + constexpr qint64 KB = 1024LL; + constexpr qint64 MB = 1024LL * 1024LL; + constexpr qint64 GB = 1024LL * 1024LL * 1024LL; + if (bytes < KB) return QString("%1 B").arg(bytes); + if (bytes < MB) return QString("%1 KB").arg(static_cast(bytes) / KB, 0, 'f', 1); + if (bytes < GB) return QString("%1 MB").arg(static_cast(bytes) / MB, 0, 'f', 2); + return QString("%1 GB").arg(static_cast(bytes) / GB, 0, 'f', 2); +} diff --git a/src/widgets/profilesettingswidget.cpp b/src/widgets/profilesettingswidget.cpp new file mode 100644 index 0000000..a070f4f --- /dev/null +++ b/src/widgets/profilesettingswidget.cpp @@ -0,0 +1,187 @@ +#include "widgets/profilesettingswidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "db/databasemanager.h" + +ProfileSettingsWidget::ProfileSettingsWidget(QWidget* parent) : QWidget(parent) { + setupUi(); + ensureSchema(); + loadProfile(); +} + +void ProfileSettingsWidget::setupUi() { + auto* layout = new QVBoxLayout(this); + + auto* formLayout = new QFormLayout(); + layout->addLayout(formLayout); + + // Name + nameEdit = new QLineEdit(this); + formLayout->addRow("Name:", nameEdit); + + // DOB + dobEdit = new QDateEdit(this); + dobEdit->setCalendarPopup(true); + dobEdit->setDisplayFormat("yyyy-MM-dd"); + formLayout->addRow("Birth Date:", dobEdit); + + // Sex + sexCombo = new QComboBox(this); + sexCombo->addItems({"Male", "Female"}); + formLayout->addRow("Sex:", sexCombo); + + // Height + heightSpin = new QDoubleSpinBox(this); + heightSpin->setRange(0, 300); // cm + heightSpin->setSuffix(" cm"); + formLayout->addRow("Height:", heightSpin); + + // Weight + weightSpin = new QDoubleSpinBox(this); + weightSpin->setRange(0, 500); // kg + weightSpin->setSuffix(" kg"); + formLayout->addRow("Weight:", weightSpin); + + // Activity Level + activitySlider = new QSlider(Qt::Horizontal, this); + activitySlider->setRange(1, 5); + activitySlider->setValue(2); // Default to Lightly Active + activitySlider->setTickPosition(QSlider::TicksBelow); + activitySlider->setTickInterval(1); + + activityLabel = new QLabel("2 (Lightly Active)", this); + + auto* activityLayout = new QHBoxLayout(); + activityLayout->addWidget(activitySlider); + activityLayout->addWidget(activityLabel); + + formLayout->addRow("Activity Level:", activityLayout); + + connect(activitySlider, &QSlider::valueChanged, this, [=](int val) { + QString text; + switch (val) { + case 1: + text = "1 (Sedentary)"; + break; + case 2: + text = "2 (Lightly Active)"; + break; + case 3: + text = "3 (Moderately Active)"; + break; + case 4: + text = "4 (Very Active)"; + break; + case 5: + text = "5 (Extra Active)"; + break; + default: + text = QString::number(val); + break; + } + activityLabel->setText(text); + }); + + layout->addStretch(); +} + +void ProfileSettingsWidget::ensureSchema() { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return; + + // Check for height column + // SQLite doesn't support IF NOT EXISTS in ADD COLUMN well in older versions, + // but duplicate adding errors out harmlessly usually, or we can check PRAGMA table_info. + // We'll check PRAGMA. + + bool hasHeight = false; + bool hasWeight = false; + + QSqlQuery q("PRAGMA table_info(profile)", db); + while (q.next()) { + QString col = q.value(1).toString(); + if (col == "height") hasHeight = true; + if (col == "weight") hasWeight = true; + } + + QSqlQuery alter(db); + if (!hasHeight) { + if (!alter.exec("ALTER TABLE profile ADD COLUMN height REAL")) { + qWarning() << "Failed to add height column:" << alter.lastError().text(); + } + } + if (!hasWeight) { + if (!alter.exec("ALTER TABLE profile ADD COLUMN weight REAL")) { + qWarning() << "Failed to add weight column:" << alter.lastError().text(); + } + } +} + +void ProfileSettingsWidget::loadProfile() { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return; + + QSqlQuery q("SELECT name, dob, gender, weight, height, act_lvl FROM profile WHERE id=1", db); + if (q.next()) { + nameEdit->setText(q.value(0).toString()); + dobEdit->setDate(q.value(1).toDate()); + + QString sex = q.value(2).toString(); + sexCombo->setCurrentText(sex.isEmpty() ? "Male" : sex); + + weightSpin->setValue(q.value(3).toDouble()); + heightSpin->setValue(q.value(4).toDouble()); + + int act = q.value(5).toInt(); + act = std::max(act, 1); + act = std::min(act, 5); + activitySlider->setValue(act); + } else { + // Default insert if missing? + // Or assume ID 1 exists (created by init.sql?). + // If not exists, maybe form is empty. + } +} + +void ProfileSettingsWidget::save() { + QSqlDatabase db = DatabaseManager::instance().userDatabase(); + if (!db.isOpen()) return; + + // Check if ID 1 exists + QSqlQuery check("SELECT 1 FROM profile WHERE id=1", db); + bool exists = check.next(); + + QSqlQuery q(db); + if (exists) { + q.prepare( + "UPDATE profile SET name=?, dob=?, gender=?, weight=?, height=?, act_lvl=? WHERE id=1"); + } else { + q.prepare( + "INSERT INTO profile (name, dob, gender, weight, height, act_lvl, id) VALUES (?, ?, ?, " + "?, ?, ?, 1)"); + } + + q.addBindValue(nameEdit->text()); + q.addBindValue(dobEdit->date()); + q.addBindValue(sexCombo->currentText()); + q.addBindValue(weightSpin->value()); + q.addBindValue(heightSpin->value()); + q.addBindValue(activitySlider->value()); + + if (!q.exec()) { + qCritical() << "Failed to save profile:" << q.lastError().text(); + } +} diff --git a/src/widgets/rdasettingswidget.cpp b/src/widgets/rdasettingswidget.cpp new file mode 100644 index 0000000..251ebac --- /dev/null +++ b/src/widgets/rdasettingswidget.cpp @@ -0,0 +1,81 @@ +#include "widgets/rdasettingswidget.h" + +#include +#include +#include +#include + +#include "db/databasemanager.h" + +RDASettingsWidget::RDASettingsWidget(FoodRepository& repository, QWidget* parent) + : QDialog(parent), m_repository(repository) { + setWindowTitle("RDA Settings"); + resize(600, 400); + + auto* layout = new QVBoxLayout(this); + layout->addWidget( + new QLabel("Customize your Recommended Daily Allowances " + "(RDA). Changes are saved automatically.")); + + m_table = new QTableWidget(this); + m_table->setColumnCount(4); + m_table->setHorizontalHeaderLabels({"ID", "Nutrient", "RDA", "Unit"}); + m_table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + + loadData(); + + connect(m_table, &QTableWidget::cellChanged, this, &RDASettingsWidget::onCellChanged); + + layout->addWidget(m_table); +} + +void RDASettingsWidget::loadData() { + m_loading = true; + m_table->setRowCount(0); + + QSqlDatabase db = DatabaseManager::instance().database(); + if (!db.isOpen()) return; + + // Get metadata from USDA + QSqlQuery query("SELECT id, nutr_desc, unit FROM nutrients_overview ORDER BY nutr_desc", db); + auto currentRdas = m_repository.getNutrientRdas(); + + int row = 0; + while (query.next()) { + int id = query.value(0).toInt(); + QString name = query.value(1).toString(); + QString unit = query.value(2).toString(); + double rda = currentRdas.count(id) != 0U ? currentRdas[id] : 0.0; + + m_table->insertRow(row); + + auto* idItem = new QTableWidgetItem(QString::number(id)); + idItem->setFlags(idItem->flags() & ~Qt::ItemIsEditable); + m_table->setItem(row, 0, idItem); + + auto* nameItem = new QTableWidgetItem(name); + nameItem->setFlags(nameItem->flags() & ~Qt::ItemIsEditable); + m_table->setItem(row, 1, nameItem); + + auto* rdaItem = new QTableWidgetItem(QString::number(rda)); + m_table->setItem(row, 2, rdaItem); + + auto* unitItem = new QTableWidgetItem(unit); + unitItem->setFlags(unitItem->flags() & ~Qt::ItemIsEditable); + m_table->setItem(row, 3, unitItem); + + row++; + } + + m_loading = false; +} + +void RDASettingsWidget::onCellChanged(int row, int column) { + if (m_loading || column != 2) return; + + int id = m_table->item(row, 0)->text().toInt(); + double value = m_table->item(row, 2)->text().toDouble(); + + m_repository.updateRda(id, value); +} diff --git a/src/widgets/recipewidget.cpp b/src/widgets/recipewidget.cpp new file mode 100644 index 0000000..033f737 --- /dev/null +++ b/src/widgets/recipewidget.cpp @@ -0,0 +1,293 @@ +#include "widgets/recipewidget.h" + +#include +#include +#include +#include +#include +#include +#include + +// Simple dialog to pick a food (MVP specific for ingredients) +// Ideally we reuse SearchWidget, but wrapping it might be cleaner later. +// For now, let's use a simple input dialog for ID, or we can make a tiny search dialog. +#include + +#include "widgets/searchwidget.h" + +class IngredientSearchDialog : public QDialog { +public: + IngredientSearchDialog(QWidget* parent) : QDialog(parent) { + setWindowTitle("Add Ingredient"); + resize(600, 400); + auto* layout = new QVBoxLayout(this); + searchWidget = new SearchWidget(this); + layout->addWidget(searchWidget); + + connect(searchWidget, &SearchWidget::foodSelected, this, + [this](int id, const QString& name) { + selectedFoodId = id; + selectedFoodName = name; + accept(); + }); + } + + int selectedFoodId = -1; + QString selectedFoodName; + SearchWidget* searchWidget; +}; + +RecipeWidget::RecipeWidget(QWidget* parent) : QWidget(parent) { + setupUi(); + loadRecipes(); +} + +void RecipeWidget::setupUi() { + auto* mainLayout = new QHBoxLayout(this); + + auto* splitter = new QSplitter(Qt::Horizontal, this); + mainLayout->addWidget(splitter); + + // Left Pane: Recipe List + auto* leftWidget = new QWidget(); + auto* leftLayout = new QVBoxLayout(leftWidget); + leftLayout->setContentsMargins(0, 0, 0, 0); + + leftLayout->addWidget(new QLabel("Recipes", this)); + recipeList = new QListWidget(this); + leftLayout->addWidget(recipeList); + + newButton = new QPushButton("New Recipe", this); + leftLayout->addWidget(newButton); + + leftWidget->setLayout(leftLayout); + splitter->addWidget(leftWidget); + + // Right Pane: Details + auto* rightWidget = new QWidget(); + auto* rightLayout = new QVBoxLayout(rightWidget); + rightLayout->setContentsMargins(0, 0, 0, 0); + + // Name + auto* nameLayout = new QHBoxLayout(); + nameLayout->addWidget(new QLabel("Name:", this)); + nameEdit = new QLineEdit(this); + nameLayout->addWidget(nameEdit); + rightLayout->addLayout(nameLayout); + + // Ingredients + rightLayout->addWidget(new QLabel("Ingredients:", this)); + ingredientsTable = new QTableWidget(this); + ingredientsTable->setColumnCount(3); + ingredientsTable->setHorizontalHeaderLabels({"ID", "Food", "Amount (g)"}); + ingredientsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + ingredientsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + rightLayout->addWidget(ingredientsTable); + + auto* ingButtonsLayout = new QHBoxLayout(); + addIngredientButton = new QPushButton("Add Ingredient", this); + removeIngredientButton = new QPushButton("Remove Selected", this); + ingButtonsLayout->addWidget(addIngredientButton); + ingButtonsLayout->addWidget(removeIngredientButton); + ingButtonsLayout->addStretch(); + rightLayout->addLayout(ingButtonsLayout); + + // Instructions + rightLayout->addWidget(new QLabel("Instructions:", this)); + instructionsEdit = new QTextEdit(this); + rightLayout->addWidget(instructionsEdit); + + // Action Buttons + auto* actionLayout = new QHBoxLayout(); + saveButton = new QPushButton("Save", this); + deleteButton = new QPushButton("Delete", this); + // deleteButton->setStyleSheet("background-color: #e74c3c; color: white;"); // User prefers + // styled? + + actionLayout->addStretch(); + actionLayout->addWidget(deleteButton); + actionLayout->addWidget(saveButton); + rightLayout->addLayout(actionLayout); + + rightWidget->setLayout(rightLayout); + splitter->addWidget(rightWidget); + + splitter->setStretchFactor(1, 2); // Give details more space + + // Connections + connect(newButton, &QPushButton::clicked, this, &RecipeWidget::onNewRecipe); + connect(recipeList, &QListWidget::itemSelectionChanged, this, + &RecipeWidget::onRecipeListSelectionChanged); + connect(saveButton, &QPushButton::clicked, this, &RecipeWidget::onSaveRecipe); + connect(deleteButton, &QPushButton::clicked, this, &RecipeWidget::onDeleteRecipe); + connect(addIngredientButton, &QPushButton::clicked, this, &RecipeWidget::onAddIngredient); + connect(removeIngredientButton, &QPushButton::clicked, this, &RecipeWidget::onRemoveIngredient); + + clearDetails(); +} + +void RecipeWidget::loadRecipes() { + recipeList->clear(); + auto recipes = repository.getAllRecipes(); + for (const auto& r : recipes) { + auto* item = new QListWidgetItem(r.name, recipeList); + item->setData(Qt::UserRole, r.id); + } +} + +void RecipeWidget::onRecipeListSelectionChanged() { + auto items = recipeList->selectedItems(); + if (items.isEmpty()) { + clearDetails(); + return; + } + int id = items.first()->data(Qt::UserRole).toInt(); + loadRecipeDetails(id); +} + +void RecipeWidget::loadRecipeDetails(int recipeId) { + currentRecipeId = recipeId; + RecipeItem item = repository.getRecipe(recipeId); + if (item.id == -1) return; // Error + + nameEdit->setText(item.name); + instructionsEdit->setText(item.instructions); + + // Load ingredients + ingredientsTable->setRowCount(0); + auto ingredients = repository.getIngredients(recipeId); + ingredientsTable->setRowCount(static_cast(ingredients.size())); + for (int i = 0; i < ingredients.size(); ++i) { + const auto& ing = ingredients[i]; + ingredientsTable->setItem(i, 0, new QTableWidgetItem(QString::number(ing.foodId))); + ingredientsTable->setItem(i, 1, new QTableWidgetItem(ing.foodName)); + ingredientsTable->setItem(i, 2, new QTableWidgetItem(QString::number(ing.amount))); + } + + saveButton->setEnabled(true); + deleteButton->setEnabled(true); +} + +void RecipeWidget::clearDetails() { + currentRecipeId = -1; + nameEdit->clear(); + instructionsEdit->clear(); + ingredientsTable->setRowCount(0); + saveButton->setEnabled(false); + deleteButton->setEnabled(false); +} + +void RecipeWidget::onNewRecipe() { + recipeList->clearSelection(); + clearDetails(); + nameEdit->setFocus(); + saveButton->setEnabled(true); // Allow saving a new one +} + +void RecipeWidget::onSaveRecipe() { + QString name = nameEdit->text().trimmed(); + if (name.isEmpty()) { + QMessageBox::warning(this, "Validation Error", "Recipe name cannot be empty."); + return; + } + QString instructions = instructionsEdit->toPlainText(); + + if (currentRecipeId == -1) { + // Create + int newId = repository.createRecipe(name, instructions); + if (newId != -1) { + currentRecipeId = newId; + // Add ingredients from table if any (though usually table is empty on new) + // But if user added ingredients before saving, we should handle that. + // Currently, adding ingredient requires a recipe ID? + // If strict: enforce save before adding ingredients. + // Or: keep ingredients in memory until save. + // For MVP: Simplest is: Create Recipe -> Then Add Ingredients. + // So if new, save creates empty recipe, then reloads it, allowing adds. + loadRecipes(); + // Select the new item + for (int i = 0; i < recipeList->count(); ++i) { + if (recipeList->item(i)->data(Qt::UserRole).toInt() == newId) { + recipeList->setCurrentRow(i); + break; + } + } + } + } else { + // Update + repository.updateRecipe(currentRecipeId, name, instructions); + + // Update ingredients? + // Ingredients are updated immediately in onAdd/RemoveIngredient for now? + // Or we should do batch save. + // Current implementation of onAddIngredient writes to DB immediately. + // So here just update name/instructions. + + // Refresh list name if changed + auto items = recipeList->findItems(name, Qt::MatchExactly); // This might find others? + // Just reload list to be safe or update current item + if (!recipeList->selectedItems().isEmpty()) { + recipeList->selectedItems().first()->setText(name); + } + } +} + +void RecipeWidget::onDeleteRecipe() { + if (currentRecipeId == -1) return; + + auto reply = QMessageBox::question( + this, "Confirm Delete", + "Are you sure you want to delete this recipe?\n\n" + "Note: The recipe will be marked as deleted and can be recovered if needed.", + QMessageBox::Yes | QMessageBox::No); + if (reply == QMessageBox::Yes) { + if (repository.deleteRecipe(currentRecipeId)) { + loadRecipes(); + clearDetails(); + QMessageBox::information( + this, "Recipe Deleted", + "Recipe marked as deleted. It can be recovered from the database if needed."); + } + } +} + +void RecipeWidget::onAddIngredient() { + if (currentRecipeId == -1) { + QMessageBox::information(this, "Save Required", + "Please save the recipe before adding ingredients."); + return; + } + + IngredientSearchDialog dlg(this); + if (dlg.exec() == QDialog::Accepted) { + int foodId = dlg.selectedFoodId; + QString foodName = dlg.selectedFoodName; + + // Ask for amount + bool ok; + double amount = QInputDialog::getDouble( + this, "Ingredient Amount", QString("Enter amount (grams) for %1:").arg(foodName), 100.0, + 0.1, 10000.0, 1, &ok); + + if (ok) { + if (repository.addIngredient(currentRecipeId, foodId, amount)) { + loadRecipeDetails(currentRecipeId); // Refresh table + } + } + } +} + +void RecipeWidget::onRemoveIngredient() { + if (currentRecipeId == -1) return; + + int row = ingredientsTable->currentRow(); + if (row < 0) return; + + auto* item = ingredientsTable->item(row, 0); + if (item == nullptr) return; + int foodId = item->text().toInt(); + + if (repository.removeIngredient(currentRecipeId, foodId)) { + ingredientsTable->removeRow(row); + } +} diff --git a/src/widgets/searchwidget.cpp b/src/widgets/searchwidget.cpp index a035191..53a6ea3 100644 --- a/src/widgets/searchwidget.cpp +++ b/src/widgets/searchwidget.cpp @@ -1,85 +1,329 @@ #include "widgets/searchwidget.h" + +#include +#include +#include +#include +#include #include #include +#include +#include #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); +#include "widgets/weightinputdialog.h" + +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 (or type to see history)..."); + + searchTimer = new QTimer(this); + searchTimer->setSingleShot(true); + + reloadSettings(); + + // History Completer + historyModel = new QStringListModel(this); + historyCompleter = new QCompleter(historyModel, this); + historyCompleter->setCaseSensitivity(Qt::CaseInsensitive); + historyCompleter->setCompletionMode(QCompleter::PopupCompletion); + searchInput->setCompleter(historyCompleter); + + QAbstractItemView* popup = historyCompleter->popup(); + popup->setContextMenuPolicy(Qt::CustomContextMenu); + popup->installEventFilter(this); + + connect(popup, &QAbstractItemView::customContextMenuRequested, this, + &SearchWidget::onHistoryContextMenu); + + connect(historyCompleter, QOverload::of(&QCompleter::activated), this, + &SearchWidget::onCompleterActivated); + + connect(searchInput, &QLineEdit::textChanged, this, [=]() { searchTimer->start(); }); + connect(searchTimer, &QTimer::timeout, this, &SearchWidget::performSearch); + connect(searchInput, &QLineEdit::returnPressed, this, &SearchWidget::performSearch); + + searchLayout->addWidget(searchInput); + + auto* searchButton = new QPushButton("Search", this); + connect(searchButton, &QPushButton::clicked, this, &SearchWidget::performSearch); + searchLayout->addWidget(searchButton); + + layout->addLayout(searchLayout); + + // Results table + resultsTable = new QTableWidget(this); + resultsTable->setColumnCount(6); + resultsTable->setHorizontalHeaderLabels( + {"ID", "Description", "Group", "Nutr", "Amino", "Flav"}); + + resultsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + resultsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + resultsTable->setSelectionMode(QAbstractItemView::SingleSelection); + resultsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + resultsTable->setContextMenuPolicy(Qt::CustomContextMenu); + + connect(resultsTable, &QTableWidget::cellDoubleClicked, this, + &SearchWidget::onRowDoubleClicked); + connect(resultsTable, &QTableWidget::customContextMenuRequested, this, + &SearchWidget::onCustomContextMenu); + + layout->addWidget(resultsTable); + + loadHistory(); } 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))); - } + QString query = searchInput->text().trimmed(); + if (query.length() < 2) return; + + // Save query to history + addToHistory(0, query); + + // Organization and application name - saves to ~/.config/nutra/nutra.conf + QSettings settings("nutra", "nutra"); + + // Save persistence + settings.setValue("lastSearchQuery", query); + + QElapsedTimer timer; + timer.start(); + + resultsTable->setRowCount(0); + + std::vector results = repository.searchFoods(query); + int elapsed = static_cast(timer.elapsed()); + + 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)); // Fixed: Description Column + + static const std::map groupAbbreviations = { + {1100, "Vegetables"}, // Vegetables and Vegetable Products + {600, "Soups/Sauces"}, // Soups, Sauces, and Gravies + {1700, "Lamb/Veal/Game"}, // Lamb, Veal, and Game Products + {500, "Poultry"}, // Poultry Products + {700, "Sausages/Meats"}, // Sausages and Luncheon Meats + {800, "Cereals"}, // Breakfast Cereals + {900, "Fruits"}, // Fruits and Fruit Juices + {1200, "Nuts/Seeds"}, // Nut and Seed Products + {1400, "Beverages"}, // Beverages + {400, "Fats/Oils"}, // Fats and Oils + {1900, "Sweets"}, // Sweets + {1800, "Baked Prod."}, // Baked Products + {2100, "Fast Food"}, // Fast Foods + {2200, "Meals/Entrees"}, // Meals, Entrees, and Side Dishes + {2500, "Snacks"}, // Snacks + {3600, "Restaurant"}, // Restaurant Foods + {100, "Dairy/Egg"}, // Dairy and Egg Products + {1300, "Beef"}, // Beef Products + {1000, "Pork"}, // Pork Products + {2000, "Grains/Pasta"}, // Cereal Grains and Pasta + {1600, "Legumes"}, // Legumes and Legume Products + {1500, "Fish/Shellfish"}, // Finfish and Shellfish Products + {300, "Baby Food"}, // Baby Foods + {200, "Spices"}, // Spices and Herbs + {3500, "Native Foods"} // American Indian/Alaska Native Foods + }; + + QString group = item.foodGroupName; + auto it = groupAbbreviations.find(item.foodGroupId); + if (it != groupAbbreviations.end()) { + group = it->second; + } else if (group.length() > 20) { + group = group.left(17) + "..."; + } + resultsTable->setItem(i, 2, new QTableWidgetItem(group)); + 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))); + } + + emit searchStatus( + QString("Search: matched %1 foods in %2 ms").arg(results.size()).arg(elapsed)); } void SearchWidget::onRowDoubleClicked(int row, int column) { - Q_UNUSED(column); - QTableWidgetItem *idItem = resultsTable->item(row, 0); - QTableWidgetItem *descItem = resultsTable->item(row, 1); + Q_UNUSED(column); + QTableWidgetItem* idItem = resultsTable->item(row, 0); + QTableWidgetItem* descItem = resultsTable->item(row, 1); + + if (idItem != nullptr && descItem != nullptr) { + int id = idItem->text().toInt(); + QString name = descItem->text(); + addToHistory(id, name); + emit foodSelected(id, name); + } +} + +void SearchWidget::onCustomContextMenu(const QPoint& pos) { + QTableWidgetItem* item = resultsTable->itemAt(pos); + if (item == nullptr) return; + + int row = item->row(); + QTableWidgetItem* idItem = resultsTable->item(row, 0); + QTableWidgetItem* descItem = resultsTable->item(row, 1); + + if (idItem == nullptr || descItem == nullptr) return; + + int foodId = idItem->text().toInt(); + QString foodName = descItem->text(); + + QMenu menu(this); + QAction* analyzeAction = menu.addAction("Analyze"); + QAction* addToMealAction = menu.addAction("Add to Meal"); + + QAction* selectedAction = menu.exec(resultsTable->viewport()->mapToGlobal(pos)); + + if (selectedAction != nullptr) { + addToHistory(foodId, foodName); + } + + if (selectedAction == analyzeAction) { + emit foodSelected(foodId, foodName); + } else if (selectedAction == addToMealAction) { + std::vector servings = repository.getFoodServings(foodId); + WeightInputDialog dlg(foodName, servings, this); + if (dlg.exec() == QDialog::Accepted) { + emit addToMealRequested(foodId, foodName, dlg.getGrams()); + } + } +} + +void SearchWidget::addToHistory(int foodId, const QString& foodName) { + // Remove if exists to move to top + for (int i = 0; i < recentHistory.size(); ++i) { + bool sameId = (foodId != 0) && (recentHistory[i].id == foodId); + bool sameName = (recentHistory[i].name.compare(foodName, Qt::CaseInsensitive) == 0); + + if (sameId || sameName) { + recentHistory.removeAt(i); + break; + } + } + + HistoryItem item{foodId, foodName, QDateTime::currentDateTime()}; + recentHistory.prepend(item); + + // Limit to 50 + while (recentHistory.size() > 50) { + recentHistory.removeLast(); + } + + // Save to settings + QSettings settings("NutraTech", "Nutra"); + QList list; + for (const auto& h : recentHistory) { + QVariantMap m; + m["id"] = h.id; + m["name"] = h.name; + m["timestamp"] = h.timestamp; + list.append(m); + } + settings.setValue("recentFoods", list); + + updateCompleterModel(); +} + +void SearchWidget::loadHistory() { + QSettings settings("nutra", "nutra"); + QList list = settings.value("recentFoods").toList(); + recentHistory.clear(); + for (const auto& v : list) { + QVariantMap m = v.toMap(); + HistoryItem item; + item.id = m["id"].toInt(); + item.name = m["name"].toString(); + item.timestamp = m["timestamp"].toDateTime(); + recentHistory.append(item); + } + updateCompleterModel(); +} + +void SearchWidget::updateCompleterModel() { + QStringList suggestions; + for (const auto& item : recentHistory) { + suggestions << item.name; + } + historyModel->setStringList(suggestions); +} + +void SearchWidget::onCompleterActivated(const QString& text) { + searchInput->blockSignals(true); + searchInput->setText(text); + searchInput->blockSignals(false); + performSearch(); +} + +void SearchWidget::reloadSettings() { + QSettings settings("nutra", "nutra"); + int debounce = settings.value("searchDebounce", 600).toInt(); + debounce = std::max(debounce, 250); + searchTimer->setInterval(debounce); + + // Restore last search if empty + if (searchInput->text().isEmpty() && settings.contains("lastSearchQuery")) { + QString lastQuery = settings.value("lastSearchQuery").toString(); + searchInput->setText(lastQuery); + // Defer search slightly to allow UI unchecked init + QTimer::singleShot(100, this, &SearchWidget::performSearch); + } +} + +bool SearchWidget::eventFilter(QObject* obj, QEvent* event) { + if (obj == historyCompleter->popup() && event->type() == QEvent::KeyPress) { + auto* keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Delete) { + QModelIndex index = historyCompleter->popup()->currentIndex(); + if (index.isValid()) { + removeFromHistory(index.row()); + return true; + } + } + } + return QWidget::eventFilter(obj, event); +} + +void SearchWidget::onHistoryContextMenu(const QPoint& pos) { + QAbstractItemView* popup = historyCompleter->popup(); + QModelIndex index = popup->indexAt(pos); + if (!index.isValid()) return; + + QMenu menu(this); + QAction* deleteAction = menu.addAction("Remove from history"); + QAction* selectedAction = menu.exec(popup->viewport()->mapToGlobal(pos)); + + if (selectedAction == deleteAction) { + removeFromHistory(index.row()); + } +} + +void SearchWidget::removeFromHistory(int index) { + if (index < 0 || index >= recentHistory.size()) return; + + recentHistory.removeAt(index); + + // Save to settings + QSettings settings("NutraTech", "Nutra"); + QList list; + for (const auto& h : recentHistory) { + QVariantMap m; + m["id"] = h.id; + m["name"] = h.name; + m["timestamp"] = h.timestamp; + list.append(m); + } + settings.setValue("recentFoods", list); - if (idItem != nullptr && descItem != nullptr) { - emit foodSelected(idItem->text().toInt(), descItem->text()); - } + updateCompleterModel(); } diff --git a/src/widgets/weightinputdialog.cpp b/src/widgets/weightinputdialog.cpp new file mode 100644 index 0000000..d3e9127 --- /dev/null +++ b/src/widgets/weightinputdialog.cpp @@ -0,0 +1,62 @@ +#include "widgets/weightinputdialog.h" + +#include +#include +#include + +WeightInputDialog::WeightInputDialog(const QString& foodName, + const std::vector& servings, QWidget* parent) + : QDialog(parent), m_servings(servings) { + setWindowTitle("Add to Meal - " + foodName); + auto* layout = new QVBoxLayout(this); + + layout->addWidget(new QLabel("How much " + foodName + " are you adding?", this)); + + auto* inputLayout = new QHBoxLayout(); + amountSpinBox = new QDoubleSpinBox(this); + amountSpinBox->setRange(0.1, 10000.0); + amountSpinBox->setValue(1.0); + amountSpinBox->setDecimals(2); + + unitComboBox = new QComboBox(this); + unitComboBox->addItem("Grams (g)", 1.0); + unitComboBox->addItem("Ounces (oz)", GRAMS_PER_OZ); + unitComboBox->addItem("Pounds (lb)", GRAMS_PER_LB); + + for (const auto& sw : servings) { + unitComboBox->addItem(sw.description, sw.grams); + } + + // Default to Grams and set value to 100 if Grams is selected + unitComboBox->setCurrentIndex(0); + amountSpinBox->setValue(100.0); + + // Update value when unit changes? No, let's keep it simple. + // Usually 100g is a good default, but 1 serving might be better if available. + if (!servings.empty()) { + unitComboBox->setCurrentIndex(3); // First serving + amountSpinBox->setValue(1.0); + } + + inputLayout->addWidget(amountSpinBox); + inputLayout->addWidget(unitComboBox); + layout->addLayout(inputLayout); + + auto* buttonLayout = new QHBoxLayout(); + auto* okButton = new QPushButton("Add to Meal", this); + auto* cancelButton = new QPushButton("Cancel", this); + + connect(okButton, &QPushButton::clicked, this, &QDialog::accept); + connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject); + + buttonLayout->addStretch(); + buttonLayout->addWidget(cancelButton); + buttonLayout->addWidget(okButton); + layout->addLayout(buttonLayout); +} + +double WeightInputDialog::getGrams() const { + double amount = amountSpinBox->value(); + double multiplier = unitComboBox->currentData().toDouble(); + return amount * multiplier; +} diff --git a/tests/test_calculations.cpp b/tests/test_calculations.cpp new file mode 100644 index 0000000..583d5df --- /dev/null +++ b/tests/test_calculations.cpp @@ -0,0 +1,14 @@ +#include "test_calculations.h" + +void TestCalculations::testBMR() { + // TDD: Fail mainly because not implemented + QEXPECT_FAIL("", "BMR calculation not yet implemented", Continue); + QVERIFY(false); +} + +void TestCalculations::testBodyFat() { + QEXPECT_FAIL("", "Body Fat calculation not yet implemented", Continue); + QVERIFY(false); +} + +QTEST_MAIN(TestCalculations) diff --git a/tests/test_calculations.h b/tests/test_calculations.h new file mode 100644 index 0000000..79edf79 --- /dev/null +++ b/tests/test_calculations.h @@ -0,0 +1,15 @@ +#ifndef TEST_CALCULATIONS_H +#define TEST_CALCULATIONS_H + +#include +#include + +class TestCalculations : public QObject { + Q_OBJECT + +private slots: + void testBMR(); + void testBodyFat(); +}; + +#endif // TEST_CALCULATIONS_H diff --git a/tests/test_databasemanager.cpp b/tests/test_databasemanager.cpp new file mode 100644 index 0000000..a57712e --- /dev/null +++ b/tests/test_databasemanager.cpp @@ -0,0 +1,57 @@ +#include "test_databasemanager.h" + +#include +#include +#include +#include + +#include "db/databasemanager.h" + +void TestDatabaseManager::testUserDatabaseInit() { + // Use a temporary database path + QString dbPath = QDir::tempPath() + "/nutra_test_db.sqlite3"; + if (QFileInfo::exists(dbPath)) { + QFile::remove(dbPath); + } + + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "test_connection"); + db.setDatabaseName(dbPath); + QVERIFY(db.open()); + + // Initialize schema manually (simulating initUserDatabase behavior) + QSqlQuery q(db); + q.exec("PRAGMA application_id = 1314145346"); // 'NTDB' + q.exec("PRAGMA user_version = 9"); + q.exec("CREATE TABLE log_food (id int)"); + + db.close(); + db = QSqlDatabase(); // Clear the object so it doesn't hold reference + + auto info = DatabaseManager::instance().getDatabaseInfo(dbPath); + QCOMPARE(info.type, QString("User")); + QVERIFY(info.isValid); + QCOMPARE(info.version, 9); + + QSqlDatabase::removeDatabase("test_connection"); + QFile::remove(dbPath); +} + +void TestDatabaseManager::testInvalidDatabase() { + QString dbPath = QDir::tempPath() + "/nutra_invalid.sqlite3"; + if (QFileInfo::exists(dbPath)) QFile::remove(dbPath); + + QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "invalid_conn"); + db.setDatabaseName(dbPath); + QVERIFY(db.open()); + // Empty DB + db.close(); + db = QSqlDatabase(); // Clear the object so it doesn't hold reference + + auto info = DatabaseManager::instance().getDatabaseInfo(dbPath); + QVERIFY(info.isValid == false); + + QSqlDatabase::removeDatabase("invalid_conn"); + QFile::remove(dbPath); +} + +QTEST_MAIN(TestDatabaseManager) diff --git a/tests/test_databasemanager.h b/tests/test_databasemanager.h new file mode 100644 index 0000000..3c52af9 --- /dev/null +++ b/tests/test_databasemanager.h @@ -0,0 +1,15 @@ +#ifndef TEST_DATABASEMANAGER_H +#define TEST_DATABASEMANAGER_H + +#include +#include + +class TestDatabaseManager : public QObject { + Q_OBJECT + +private slots: + void testUserDatabaseInit(); + void testInvalidDatabase(); +}; + +#endif // TEST_DATABASEMANAGER_H diff --git a/tests/test_foodrepository.cpp b/tests/test_foodrepository.cpp index 60a11c2..c45d3ae 100644 --- a/tests/test_foodrepository.cpp +++ b/tests/test_foodrepository.cpp @@ -1,57 +1,56 @@ -#include "db/databasemanager.h" -#include "db/foodrepository.h" #include #include #include +#include "db/databasemanager.h" +#include "db/foodrepository.h" + class TestFoodRepository : public QObject { - Q_OBJECT + 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."); + 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'"); } - 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; - } + 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"); } - 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) diff --git a/tests/test_reciperepository.cpp b/tests/test_reciperepository.cpp new file mode 100644 index 0000000..569955c --- /dev/null +++ b/tests/test_reciperepository.cpp @@ -0,0 +1,61 @@ +#include +#include +#include + +#include "db/databasemanager.h" +#include "db/reciperepository.h" + +class TestRecipeRepository : public QObject { + Q_OBJECT + +private slots: + void initTestCase() { + // Setup temporary DB and directory + QStandardPaths::setTestModeEnabled(true); + + // Ensure we start with a fresh database + // DatabaseManager uses ~/.nutra/nt.sqlite3 so we must clean it up + // ONLY do this in CI to avoid wiping local dev data! + if (!qEnvironmentVariable("CI").isEmpty()) { + QString dbPath = QDir::homePath() + "/.nutra/nt.sqlite3"; + if (QFileInfo::exists(dbPath)) { + QFile::remove(dbPath); + } + } + } + + void testLoadCsv() { + RecipeRepository repo; + + // Create dummy CSV + QString recipeDir = QDir::tempPath() + "/nutra_test_recipes"; + QDir().mkpath(recipeDir); + + QFile file(recipeDir + "/test_recipe.csv"); + if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream out(&file); + out << "Unit Test Recipe,Mix it up,1234,200\n"; + file.close(); + } + + repo.loadCsvRecipes(recipeDir); + + // Verify + auto recipes = repo.getAllRecipes(); + bool found = false; + for (const auto& r : recipes) { + if (r.name == "Unit Test Recipe") { + found = true; + break; + } + } + QVERIFY(found); + } + + void cleanupTestCase() { + QDir(QDir::tempPath() + "/nutra_test_recipes").removeRecursively(); + } +}; + +QTEST_MAIN(TestRecipeRepository) +#include "test_reciperepository.moc" diff --git a/usdasqlite b/usdasqlite deleted file mode 160000 index 4644288..0000000 --- a/usdasqlite +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4644288e1cfc0bac44dd3a0bfdaafa5bd72cf819