From 2142db7093445a22daa162b4ada5a0105ea1fa23 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 23 Jul 2025 16:10:22 -0700 Subject: [PATCH] Refactor MedTrackerApp and UI components for improved structure and readability - Simplified initialization logic in init.py - Consolidated testing_mode assignment - Removed unnecessary else statements - Created UIManager class to handle UI-related tasks - Modularized input frame creation, table frame creation, and graph frame creation - Enhanced edit window creation with better organization and error handling - Updated data management methods to improve clarity and maintainability - Improved logging for better debugging and tracking of application flow --- .gitea/workflows/build.yaml | 51 +++ .gitignore | 1 + Dockerfile | 71 +++- Makefile | 2 +- Pipfile | 2 +- Pipfile.lock | 44 +-- deploy/thechart.desktop | 2 +- docker-build.sh | 20 + docker-compose.yaml | 6 +- run-container.sh | 59 +++ src/data_manager.py | 120 ++++++ src/graph_manager.py | 81 ++++ src/init.py | 11 +- src/main.py | 709 +++++++++--------------------------- src/ui_manager.py | 462 +++++++++++++++++++++++ 15 files changed, 1063 insertions(+), 578 deletions(-) create mode 100644 .gitea/workflows/build.yaml create mode 100755 docker-build.sh create mode 100755 run-container.sh create mode 100644 src/data_manager.py create mode 100644 src/graph_manager.py create mode 100644 src/ui_manager.py diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..81e7477 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,51 @@ +name: Build and Push Docker Image +run-name: ${{ gitea.actor }} is building and pushing TheChart Docker image 🚀 +on: + push: + branches: + - main + - master + tags: + - "v*" + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: gitea-http.taildb3494.ts.net + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: gitea-http.taildb3494.ts.net/will/thechart + tags: | + type=semver,pattern={{version}} + type=ref,event=branch + type=ref,event=pr + type=sha,format=short + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64/v8 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=gitea-http.taildb3494.ts.net/will/thechart:buildcache + cache-to: type=registry,ref=gitea-http.taildb3494.ts.net/will/thechart:buildcache,mode=max diff --git a/.gitignore b/.gitignore index aa6c27a..01d2985 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ __pycache__/ *.spec *.log logs/ +.vscode/ diff --git a/Dockerfile b/Dockerfile index f2eac90..4ed0ee9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,13 @@ -# Use a Python Alpine base image FROM python:3.13-alpine -# Set the working directory in the container +ARG TARGET=thechart +ARG ICON=chart-671.png +ARG UID=1000 +ARG GUID=1000 + +USER root WORKDIR /app -# Install necessary system dependencies RUN apk add --no-cache \ build-base \ libffi-dev \ @@ -15,17 +18,63 @@ RUN apk add --no-cache \ xorg-server \ xauth \ xvfb \ - && pip install --upgrade pip + font-terminus font-inconsolata font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra \ + && pip install --upgrade pip && pip install pyinstaller python-dotenv -# Copy the requirements file and install dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy the application code -COPY src/ . +# RUN export uid=$UID gid=$GUID +RUN mkdir -p /home/docker_user +RUN echo "docker_user:x:${UID}:${GUID}:docker_user,,,:/home/docker_user:/bin/bash" >> /etc/passwd +RUN echo "docker_user:x:${UID}:" >> /etc/group +RUN mkdir -p /etc/sudoers.d && echo "docker_user ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/docker_user +RUN chmod 0440 /etc/sudoers.d/docker_user +RUN chown -R ${UID}:${GUID} /home/docker_user -# Expose the port your application listens on (if applicable) -# EXPOSE 8000 +COPY --chown=${UID}:${GUID} --chmod=765 ./src/ ./src/ +COPY --chown=${UID}:${GUID} --chmod=765 ./.env . +COPY --chown=${UID}:${GUID} --chmod=765 ./thechart_data.csv . +COPY --chown=${UID}:${GUID} --chmod=765 ./${ICON} . -# Define the command to run your application -CMD ["python", "main.py"] +ARG HOME=/home/docker_user +ENV HOME=/home/docker_user + +# Make sure the icon file and data file are in the right location +RUN cp -f ./${ICON} ./src/ && cp -f ./thechart_data.csv ./.env ./src/ + +# Debug the environment +RUN echo "Icon file is: ${ICON}" +RUN ls -la ./src/ + +# Use a shell command with variable expansion to make sure variables are properly substituted +RUN sh -c "pyinstaller --name ${TARGET} --optimize 2 --onefile --windowed --hidden-import='PIL._tkinter_finder' --icon=./src/chart-671.png --add-data='.env:.' --add-data='./src/chart-671.png:.' --add-data='./src/thechart_data.csv:.' --distpath ${HOME}/${TARGET} ./src/main.py" + +RUN chown -R ${UID}:${GUID} /home/docker_user/ +RUN chmod -R 777 /home/docker_user/${TARGET} + +# Set environment variables for X11 forwarding +ENV DISPLAY=:0 +ENV XAUTHORITY=/tmp/.docker.xauth +ENV QT_X11_NO_MITSHM=1 + +# Create a startup script to handle X11 setup and application launch +# Create a proper entrypoint script with correct line endings +RUN echo '#!/bin/sh' > /home/docker_user/entrypoint.sh && \ + echo 'set -e' >> /home/docker_user/entrypoint.sh && \ + echo 'echo "Starting entrypoint script..."' >> /home/docker_user/entrypoint.sh && \ + echo 'echo "DISPLAY=$DISPLAY"' >> /home/docker_user/entrypoint.sh && \ + echo 'echo "XAUTHORITY=$XAUTHORITY"' >> /home/docker_user/entrypoint.sh && \ + echo 'echo "HOME=$HOME"' >> /home/docker_user/entrypoint.sh && \ + echo 'mkdir -p $(dirname $XAUTHORITY)' >> /home/docker_user/entrypoint.sh && \ + echo 'touch $XAUTHORITY || echo "Warning: Could not create $XAUTHORITY file"' >> /home/docker_user/entrypoint.sh && \ + echo 'xauth nlist $DISPLAY | sed -e "s/^..../ffff/" | xauth -f $XAUTHORITY nmerge - || echo "Warning: X auth setup failed"' >> /home/docker_user/entrypoint.sh && \ + echo "echo \"Starting application: \$HOME/${TARGET}/${TARGET}\"" >> /home/docker_user/entrypoint.sh && \ + echo "exec \$HOME/${TARGET}/${TARGET}" >> /home/docker_user/entrypoint.sh && \ + chmod +x /home/docker_user/entrypoint.sh + +USER docker_user + +# Set the entrypoint to our startup script through shell +ENTRYPOINT ["/bin/sh"] +CMD ["-c", "/home/docker_user/entrypoint.sh"] diff --git a/Makefile b/Makefile index b6c95b0..e68cab5 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ setup-env: build: docker buildx build --platform linux/amd64,linux/arm64 -t ${IMAGE} --push . install: - pyinstaller --name ${TARGET} --onefile --windowed --hidden-import='PIL._tkinter_finder' --icon='${ICON}' --add-data=".env:." src/main.py + pyinstaller --name ${TARGET} --optimize 2 --onefile --windowed --hidden-import='PIL._tkinter_finder' --icon='${ICON}' --add-data=".env:." --add-data='./src/chart-671.png:.' --add-data='./src/thechart_data.csv:.' src/main.py cp -f ./dist/${TARGET} ${ROOT}/Applications/ cp -f ./deploy/${TARGET}.desktop ${ROOT}/.local/share/applications/ desktop-file-validate ${ROOT}/.local/share/applications/${TARGET}.desktop diff --git a/Pipfile b/Pipfile index 92c853f..6e4ff53 100644 --- a/Pipfile +++ b/Pipfile @@ -19,11 +19,11 @@ pytz = "==2025.2" six = "==1.17.0" tzdata = "==2025.2" colorlog = "*" +dotenv = "*" [dev-packages] pre-commit = "*" pyinstaller = "*" -dotenv = "*" [requires] python_version = "3.13" diff --git a/Pipfile.lock b/Pipfile.lock index f82bd35..d476ecd 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "61b1a4d194b2257d4bef97d275fe5d947559a2346020473a0c45cb49940608ab" + "sha256": "3f2c36deb68c43ab2082c8b3f7f37b33345ad3693c498101d5b1d1edf6ff3223" }, "pipfile-spec": 6, "requires": { @@ -98,6 +98,13 @@ "markers": "python_version >= '3.8'", "version": "==0.12.1" }, + "dotenv": { + "hashes": [ + "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9" + ], + "index": "pypi", + "version": "==0.9.9" + }, "fonttools": { "hashes": [ "sha256:0162a6a37b0ca70d8505311d541e291cd6cab54d1a986ae3d2686c56c0581e8f", @@ -522,6 +529,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.9.0.post0" }, + "python-dotenv": { + "hashes": [ + "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", + "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab" + ], + "markers": "python_version >= '3.9'", + "version": "==1.1.1" + }, "pytz": { "hashes": [ "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", @@ -572,13 +587,6 @@ ], "version": "==0.4.0" }, - "dotenv": { - "hashes": [ - "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9" - ], - "index": "pypi", - "version": "==0.9.9" - }, "filelock": { "hashes": [ "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", @@ -650,19 +658,11 @@ }, "pyinstaller-hooks-contrib": { "hashes": [ - "sha256:06779d024f7d60dd75b05520923bba16b17df5f64073434b23e570ffb71094dc", - "sha256:223ae773733fb7a0ee9cb5e817480998a90a6c7a9c3d2b7b580d2dfa2b325751" + "sha256:b0c19dbe9a8428665d2c5c4538eaa16b683579aabd7f2ecd31f41b116c4e4e57", + "sha256:ba4fecc94eb761de2015cd8b1e674354e8a1f13ba65047602912f34c20fb510b" ], "markers": "python_version >= '3.8'", - "version": "==2025.6" - }, - "python-dotenv": { - "hashes": [ - "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", - "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab" - ], - "markers": "python_version >= '3.9'", - "version": "==1.1.1" + "version": "==2025.7" }, "pyyaml": { "hashes": [ @@ -733,11 +733,11 @@ }, "virtualenv": { "hashes": [ - "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", - "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af" + "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", + "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0" ], "markers": "python_version >= '3.8'", - "version": "==20.31.2" + "version": "==20.32.0" } } } diff --git a/deploy/thechart.desktop b/deploy/thechart.desktop index c2ebd6f..843fa58 100644 --- a/deploy/thechart.desktop +++ b/deploy/thechart.desktop @@ -5,4 +5,4 @@ Name=Thechart Exec=sh -c "/home/will/Applications/thechart /home/will/Documents/thechart_data.csv" Icon=/home/will/Code/thechart/chart-671.png Categories=Utility; -StartupWMClass=tk # Crucial for Dock icon persistence +StartupWMClass=Tk # Crucial for Dock icon persistence diff --git a/docker-build.sh b/docker-build.sh new file mode 100755 index 0000000..f8da577 --- /dev/null +++ b/docker-build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/bash + +CONTAINER_ENGINE="docker" # podman | docker +VERSION="v1.0.0" +REGISTRY="gitea-http.taildb3494.ts.net/will/thechart" + +if [ "$CONTAINER_ENGINE" == "podman" ]; +then + buildah build \ + -t $REGISTRY:$VERSION \ + --platform linux/amd64,linux/arm64/v8 \ + --no-cache . +else + DOCKER_BUILDKIT=1 \ + docker buildx build \ + --platform linux/amd64,linux/arm64/v8 \ + -t $REGISTRY:$VERSION \ + --no-cache \ + --push . +fi diff --git a/docker-compose.yaml b/docker-compose.yaml index fb822f0..bd816a9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,6 +7,8 @@ services: working_dir: '/app' tty: true volumes: - - ${SRC_PATH}:/app + - /tmp/.X11-unix:/tmp/.X11-unix + - ${XAUTHORITY:-~/.Xauthority}:/tmp/.docker.xauth:rw environment: - - DISPLAY=${DISPLAY_IP}:0.0 + - DISPLAY=${DISPLAY:-:0} + - XAUTHORITY=/tmp/.docker.xauth diff --git a/run-container.sh b/run-container.sh new file mode 100755 index 0000000..a0ab073 --- /dev/null +++ b/run-container.sh @@ -0,0 +1,59 @@ +#!/usr/bin/bash + +# Check for .env file and create if it doesn't exist +if [ ! -f .env ]; then + echo "Creating .env file..." + touch .env +fi + +# Allow local X server connections +xhost +local: + +# Set environment variables +export DISPLAY=":0" # Default to local display +# Try to get IP address if hostname -I is available +if command -v hostname >/dev/null 2>&1; then + if hostname --help 2>&1 | grep -q -- "-I"; then + IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "") + if [ -n "$IP" ]; then + export DISPLAY="$IP:0" + fi + fi +fi + +export SRC_PATH=$(pwd) +export IMAGE="thechart:latest" +export XAUTHORITY=$HOME/.Xauthority + +echo "Building and running the container..." +echo "Using DISPLAY=$DISPLAY" +echo "Using SRC_PATH=$SRC_PATH" +echo "Using XAUTHORITY=$XAUTHORITY" + +# Check if debug mode is requested +if [ "$1" = "debug" ]; then + echo "Running in debug mode - will open shell instead of running app" + docker-compose build + docker run --rm -it \ + -v /tmp/.X11-unix:/tmp/.X11-unix \ + -v ${XAUTHORITY:-$HOME/.Xauthority}:/tmp/.docker.xauth:rw \ + -e DISPLAY=${DISPLAY:-:0} \ + -e XAUTHORITY=/tmp/.docker.xauth \ + ${IMAGE} /bin/sh +else + # First run with debug to see the container's internal state + echo "First entering container shell for debugging..." + docker run --rm -it \ + -v /tmp/.X11-unix:/tmp/.X11-unix \ + -v ${XAUTHORITY:-$HOME/.Xauthority}:/tmp/.docker.xauth:rw \ + -e DISPLAY=${DISPLAY:-:0} \ + -e XAUTHORITY=/tmp/.docker.xauth \ + ${IMAGE} /bin/sh + + # Then continue with normal operation if needed + echo "Now running the container with docker-compose..." + docker-compose up --build +fi + +# Disallow local X server connections when done +xhost -local: diff --git a/src/data_manager.py b/src/data_manager.py new file mode 100644 index 0000000..0facaeb --- /dev/null +++ b/src/data_manager.py @@ -0,0 +1,120 @@ +import os +import csv +import logging +from typing import List, Union + +import pandas as pd + + +class DataManager: + """Handle all data operations for the application.""" + + def __init__(self, filename: str, logger: logging.Logger) -> None: + self.filename: str = filename + self.logger: logging.Logger = logger + self.initialize_csv() + + def initialize_csv(self) -> None: + """Create CSV file with headers if it doesn't exist.""" + if not os.path.exists(self.filename): + with open(self.filename, mode="w", newline="") as file: + writer = csv.writer(file) + writer.writerow( + [ + "date", + "depression", + "anxiety", + "sleep", + "appetite", + "bupropion", + "hydroxyzine", + "gabapentin", + "propranolol", + "note", + ] + ) + + def load_data(self) -> pd.DataFrame: + """Load data from CSV file.""" + if ( + not os.path.exists(self.filename) + or os.path.getsize(self.filename) == 0 + ): + self.logger.warning( + "CSV file is empty or doesn't exist. No data to load." + ) + return pd.DataFrame() + + try: + df: pd.DataFrame = pd.read_csv( + self.filename, + dtype={ + "depression": int, + "anxiety": int, + "sleep": int, + "appetite": int, + "bupropion": int, + "hydroxyzine": int, + "gabapentin": int, + "propranolol": int, + "note": str, + "date": str, + }, + ).fillna("") + return df.sort_values(by="date").reset_index(drop=True) + except pd.errors.EmptyDataError: + self.logger.warning("CSV file is empty. No data to load.") + return pd.DataFrame() + except Exception as e: + self.logger.error(f"Error loading data: {str(e)}") + return pd.DataFrame() + + def add_entry(self, entry_data: List[Union[str, int]]) -> bool: + """Add a new entry to the CSV file.""" + try: + with open(self.filename, mode="a", newline="") as file: + writer = csv.writer(file) + writer.writerow(entry_data) + return True + except Exception as e: + self.logger.error(f"Error adding entry: {str(e)}") + return False + + def update_entry(self, date: str, values: List[Union[str, int]]) -> bool: + """Update an existing entry identified by date.""" + try: + df: pd.DataFrame = self.load_data() + # Find the row to update using date as a unique identifier + df.loc[ + df["date"] == date, + [ + "date", + "depression", + "anxiety", + "sleep", + "appetite", + "bupropion", + "hydroxyzine", + "gabapentin", + "propranolol", + "note", + ], + ] = values + df.to_csv(self.filename, index=False) + return True + except Exception as e: + self.logger.error(f"Error updating entry: {str(e)}") + return False + + def delete_entry(self, date: str) -> bool: + """Delete an entry identified by date.""" + try: + df: pd.DataFrame = self.load_data() + # Remove the row with the matching date + df = df[df["date"] != date] + # Write the updated dataframe back to the CSV + df.to_csv(self.filename, index=False) + return True + except Exception as e: + self.logger.error(f"Error deleting entry: {str(e)}") + return False diff --git a/src/graph_manager.py b/src/graph_manager.py new file mode 100644 index 0000000..a222abd --- /dev/null +++ b/src/graph_manager.py @@ -0,0 +1,81 @@ +import pandas as pd +import matplotlib.pyplot as plt +import matplotlib.figure +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.axes import Axes +from tkinter import ttk + + +class GraphManager: + """Handle all graph-related operations for the application.""" + + def __init__(self, parent_frame: ttk.LabelFrame) -> None: + self.parent_frame: ttk.LabelFrame = parent_frame + + # Configure graph frame to expand + self.parent_frame.grid_rowconfigure(0, weight=1) + self.parent_frame.grid_columnconfigure(0, weight=1) + + # Initialize matplotlib figure and canvas + self.fig: matplotlib.figure.Figure + self.ax: Axes + self.fig, self.ax = plt.subplots() + self.canvas: FigureCanvasTkAgg = FigureCanvasTkAgg( + figure=self.fig, master=self.parent_frame + ) + self.canvas.get_tk_widget().pack(fill="both", expand=True) + + def update_graph(self, df: pd.DataFrame) -> None: + """Update the graph with new data.""" + self.ax.clear() + if not df.empty: + # Convert dates and sort + df = df.copy() # Create a copy to avoid modifying the original + df["date"] = pd.to_datetime(df["date"]) + df = df.sort_values(by="date") + df.set_index(keys="date", inplace=True) + + # Plot data series + self._plot_series( + df, "depression", "Depression (0:good, 10:bad)", "o", "-" + ) + self._plot_series( + df, "anxiety", "Anxiety (0:good, 10:bad)", "o", "-" + ) + self._plot_series( + df, "sleep", "Sleep (0:bad, 10:good)", "o", "dashed" + ) + self._plot_series( + df, "appetite", "Appetite (0:bad, 10:good)", "o", "dashed" + ) + + # Configure graph appearance + self.ax.legend() + self.ax.set_title("Medication Effects Over Time") + self.ax.set_xlabel("Date") + self.ax.set_ylabel("Rating (0-10)") + self.fig.autofmt_xdate() + + # Redraw the canvas + self.canvas.draw() + + def _plot_series( + self, + df: pd.DataFrame, + column: str, + label: str, + marker: str, + linestyle: str, + ) -> None: + """Helper method to plot a data series.""" + self.ax.plot( + df.index, + df[column], + marker=marker, + linestyle=linestyle, + label=label, + ) + + def close(self) -> None: + """Clean up resources.""" + plt.close(self.fig) diff --git a/src/init.py b/src/init.py index 68a786d..ebb41ac 100644 --- a/src/init.py +++ b/src/init.py @@ -4,8 +4,6 @@ from constants import LOG_PATH, LOG_CLEAR, LOG_LEVEL if not os.path.exists(LOG_PATH): os.mkdir(LOG_PATH) -else: - pass log_files = ( f"{LOG_PATH}/app.log", @@ -13,10 +11,7 @@ log_files = ( f"{LOG_PATH}/app.error.log", ) -if LOG_LEVEL == "DEBUG": - testing_mode = True -else: - testing_mode = False +testing_mode = LOG_LEVEL == "DEBUG" logger = init_logger(__name__, testing_mode=testing_mode) @@ -26,10 +21,6 @@ if LOG_CLEAR == "True": if os.path.exists(log_file): with open(log_file, "r+") as t: t.truncate(0) - else: - pass except Exception as e: logger.error(e) raise -else: - pass diff --git a/src/main.py b/src/main.py index 35f578d..8fccb35 100644 --- a/src/main.py +++ b/src/main.py @@ -1,399 +1,134 @@ -import csv -import logging import os -import tkinter as tk -from tkinter import messagebox, ttk - -import matplotlib.pyplot as plt -import pandas as pd -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg - import sys -from PIL import Image, ImageTk +import tkinter as tk +from tkinter import messagebox +from typing import Dict, List, Tuple, Any, Callable, Union + +import pandas as pd from init import logger from constants import LOG_LEVEL +from data_manager import DataManager +from graph_manager import GraphManager +from ui_manager import UIManager class MedTrackerApp: - def __init__(self, root): - - self.root = root + def __init__(self, root: tk.Tk) -> None: + self.root: tk.Tk = root self.root.resizable(True, True) self.root.title("Thechart - medication tracker") self.root.protocol("WM_DELETE_WINDOW", self.on_closing) - # self.root.iconbitmap("app_icon.ico") - # screen_width = self.root.winfo_screenwidth() - # screen_height = self.root.winfo_screenheight() - # self.root.geometry(f"{screen_width}x{screen_height}") - # self.root.configure(background='gold') - # self.root.lift() - # self.root.attributes("-topmost", True) - # self.root.geometry("800x600") + # Set up data file + self.filename: str = "thechart_data.csv" if len(sys.argv) > 1: - script_name = sys.argv[0] - first_argument = sys.argv[1] - + first_argument: str = sys.argv[1] if LOG_LEVEL == "DEBUG": - logger.debug(f"Script name: {script_name}") + logger.debug(f"Script name: {sys.argv[0]}") logger.debug(f"First argument: {first_argument}") - if os.path.exists(first_argument): self.filename = first_argument logger.info(f"Using data file: {first_argument}") else: logger.warning( - f"Data file {first_argument} does not exist." - f" Using default file: {self.filename}" + f"Data file {first_argument} does not exist. Using default file: {self.filename}" ) - self.make_icon( - img="/home/will/Code/thechart/chart-671.png", - logger=logger, + # Initialize managers + self.ui_manager: UIManager = UIManager(root, logger) + self.data_manager: DataManager = DataManager(self.filename, logger) + + # Set up application icon + icon_path: str = "chart-671.png" + if not os.path.exists(icon_path) and os.path.exists("./chart-671.png"): + icon_path = "./chart-671.png" + self.ui_manager.setup_icon(img_path=icon_path) + + # Set up the main application UI + self._setup_main_ui() + + def _setup_main_ui(self) -> None: + """Set up the main UI components.""" + import tkinter.ttk as ttk + + # --- Main Frame --- + main_frame: ttk.Frame = ttk.Frame(self.root, padding="10") + main_frame.grid(row=0, column=0, sticky="nsew") + + # Configure root window grid + self.root.grid_rowconfigure(0, weight=1) + self.root.grid_columnconfigure(0, weight=1) + + # Configure main frame grid for scaling + for i in range(2): + main_frame.grid_rowconfigure(i, weight=1 if i == 1 else 0) + main_frame.grid_columnconfigure(i, weight=3 if i == 1 else 1) + logger.debug("Main frame and root grid configured for scaling.") + + # --- Create Graph Frame --- + graph_frame: ttk.Frame = self.ui_manager.create_graph_frame(main_frame) + self.graph_manager: GraphManager = GraphManager(graph_frame) + + # --- Create Input Frame --- + input_ui: Dict[str, Any] = self.ui_manager.create_input_frame( + main_frame + ) + self.input_frame: ttk.Frame = input_ui["frame"] + self.symptom_vars: Dict[str, tk.IntVar] = input_ui["symptom_vars"] + self.medicine_vars: Dict[str, List[Union[tk.IntVar, ttk.Spinbox]]] = ( + input_ui["medicine_vars"] + ) + self.note_var: tk.StringVar = input_ui["note_var"] + self.date_var: tk.StringVar = input_ui["date_var"] + + # Add buttons to input frame + self.ui_manager.add_buttons( + self.input_frame, + [ + { + "text": "Add Entry", + "command": self.add_entry, + "fill": "both", + "expand": True, + }, + {"text": "Quit", "command": self.on_closing}, + ], ) - self.filename = "thechart_data.csv" - self.initialize_csv() - - main_frame = ttk.Frame(self.root, padding="10") - main_frame.grid(row=0, column=0) - - # --- Input Frame --- - input_frame = ttk.LabelFrame(main_frame, text="New Entry") - input_frame.grid(row=1, column=0, padx=10, pady=10, sticky="nsew") - - ttk.Label(input_frame, text="Depression (0-10):").grid( - row=0, column=0, sticky="w", padx=5, pady=2 + # --- Create Table Frame --- + table_ui: Dict[str, Any] = self.ui_manager.create_table_frame( + main_frame ) - self.depression_var = tk.IntVar() - ttk.Scale( - input_frame, - from_=0, - to=10, - orient=tk.HORIZONTAL, - variable=self.depression_var, - ).grid(row=0, column=1, sticky="ew") - - ttk.Label(input_frame, text="Anxiety (0-10):").grid( - row=1, column=0, sticky="w", padx=5, pady=2 - ) - self.anxiety_var = tk.IntVar() - ttk.Scale( - input_frame, - from_=0, - to=10, - orient=tk.HORIZONTAL, - variable=self.anxiety_var, - ).grid(row=1, column=1, sticky="ew") - - ttk.Label(input_frame, text="Sleep Quality (0-10):").grid( - row=2, column=0, sticky="w", padx=5, pady=2 - ) - self.sleep_var = tk.IntVar() - ttk.Scale( - input_frame, - from_=0, - to=10, - orient=tk.HORIZONTAL, - variable=self.sleep_var, - ).grid(row=2, column=1, sticky="ew") - - ttk.Label(input_frame, text="Appetite (0-10):").grid( - row=3, column=0, sticky="w", padx=5, pady=2 - ) - self.appetite_var = tk.IntVar() - ttk.Scale( - input_frame, - from_=0, - to=10, - orient=tk.HORIZONTAL, - variable=self.appetite_var, - ).grid(row=3, column=1, sticky="ew") - - ttk.Label(input_frame, text="Treatment:").grid( - row=4, column=0, sticky="w", padx=5, pady=2 - ) - - medicine_frame = ttk.LabelFrame(input_frame, text="Medicine") - medicine_frame.grid(row=4, column=1, padx=0, pady=10, sticky="nsew") - - self.bupropion_var = tk.IntVar(value=0) - self.hydroxyzine_var = tk.IntVar(value=0) - self.gabapentin_var = tk.IntVar(value=0) - self.propranolol_var = tk.IntVar(value=0) - - ttk.Checkbutton( - medicine_frame, - text="Bupropion 150mg", - variable=self.bupropion_var, - name="bupropion_check", - command=lambda: self.toggle_checkbox(obj_name="bupropion_check"), - ).grid(row=0, column=0, sticky="w", padx=5, pady=2) - - ttk.Checkbutton( - medicine_frame, - text="Hydroxyzine 25mg", - variable=self.hydroxyzine_var, - name="hydroxyzine_check", - command=lambda: self.toggle_checkbox(obj_name="hydroxyzine_check"), - ).grid(row=1, column=0, sticky="w", padx=5, pady=2) - - ttk.Checkbutton( - medicine_frame, - text="Gabapentin 100mg", - variable=self.gabapentin_var, - name="gabapentin_check", - command=lambda: self.toggle_checkbox(obj_name="gabapentin_check"), - ).grid(row=2, column=0, sticky="w", padx=5, pady=2) - - ttk.Checkbutton( - medicine_frame, - text="Propranolol 10mg", - name="propranolol_check", - variable=self.propranolol_var, - command=lambda: self.toggle_checkbox(obj_name="propranolol_check"), - ).grid(row=3, column=0, sticky="w", padx=5, pady=2) - - ttk.Label(input_frame, text="Note:").grid( - row=5, column=0, sticky="w", padx=5, pady=2 - ) - self.note_var = tk.StringVar() - ttk.Entry(input_frame, textvariable=self.note_var).grid( - row=5, column=1, sticky="ew", padx=5, pady=2 - ) - - ttk.Label(input_frame, text="Date (mm/dd/yyyy):").grid( - row=6, column=0, sticky="w", padx=5, pady=2 - ) - self.date_var = tk.StringVar() - ttk.Entry( - input_frame, textvariable=self.date_var, justify="center" - ).grid(row=6, column=1, sticky="ew", padx=5, pady=2) - - button_frame = ttk.Frame(input_frame) - button_frame.grid(row=7, column=0, columnspan=2, pady=10) - - ttk.Button( - button_frame, text="Add Entry", command=self.add_entry - ).pack(side="left", padx=5, fill="both", expand=True) - ttk.Button(button_frame, text="Quit", command=self.on_closing).pack( - side="left", padx=5 - ) - - # --- Table Frame --- - table_frame = ttk.LabelFrame( - main_frame, text="Log (Double-click to edit)" - ) - table_frame.grid(row=1, column=1, padx=10, pady=10, sticky="nsew") - - self.tree = ttk.Treeview( - table_frame, - columns=( - "Date", - "Depression", - "Anxiety", - "Sleep", - "Appetite", - "Bupropion", - "Hydroxyzine", - "Gabapentin", - "Propranolol", - "Note", - ), - show="headings", - ) - self.tree.heading("Date", text="Date") - self.tree.heading("Depression", text="Depression") - self.tree.heading("Anxiety", text="Anxiety") - self.tree.heading("Sleep", text="Sleep") - self.tree.heading("Appetite", text="Appetite") - self.tree.heading("Bupropion", text="Bupropion 150mg") - self.tree.heading("Hydroxyzine", text="Hydroxyzine 25mg") - self.tree.heading("Gabapentin", text="Gabapentin 100mg") - self.tree.heading("Propranolol", text="Propranolol 10mg") - self.tree.heading("Note", text="Note") - - self.tree.column("Date", width=80, anchor="center") - self.tree.column("Depression", width=80, anchor="center") - self.tree.column("Anxiety", width=80, anchor="center") - self.tree.column("Sleep", width=80, anchor="center") - self.tree.column("Appetite", width=80, anchor="center") - self.tree.column("Bupropion", width=120, anchor="center") - self.tree.column("Hydroxyzine", width=120, anchor="center") - self.tree.column("Gabapentin", width=120, anchor="center") - self.tree.column("Propranolol", width=120, anchor="center") - self.tree.column("Note", width=300, anchor="w") - - # --- Bind double-click event --- + self.tree: ttk.Treeview = table_ui["tree"] self.tree.bind("", self.on_double_click) - self.tree.pack(side="left", fill="both", expand=True) - - scrollbar = ttk.Scrollbar( - table_frame, orient="vertical", command=self.tree.yview - ) - self.tree.configure(yscrollcommand=scrollbar.set) - scrollbar.pack(side="right", fill="y") - - # --- Graph Frame --- - graph_frame = ttk.LabelFrame(main_frame, text="Evolution") - graph_frame.grid( - row=0, column=0, columnspan=2, padx=10, pady=10, sticky="nsew" - ) - - self.fig, self.ax = plt.subplots() - self.canvas = FigureCanvasTkAgg(figure=self.fig, master=graph_frame) - self.canvas.get_tk_widget().pack(fill="both", expand=True) + # Load data self.load_data() - def toggle_checkbox(obj_name: str) -> None: - if ttk.Checkbutton.nametowidget(name=obj_name).get(): - ttk.Checkbutton.nametowidget(name=obj_name).set(False) - else: - ttk.Checkbutton.nametowidget(name=obj_name).set(True) - - def on_double_click(self, event: any) -> None: + def on_double_click(self, event: tk.Event) -> None: """Handle double-click event to edit an entry.""" + logger.debug("Double-click event triggered on treeview.") if len(self.tree.get_children()) > 0: item_id = self.tree.selection()[0] item_values = self.tree.item(item_id, "values") - self.create_edit_window(item_id, item_values) + logger.debug(f"Editing item_id={item_id}, values={item_values}") + self._create_edit_window(item_id, item_values) - def create_edit_window(self, item_id: str, values: tuple) -> None: + def _create_edit_window( + self, item_id: str, values: Tuple[str, ...] + ) -> None: """Create a new Toplevel window for editing an entry.""" - edit_win = tk.Toplevel(master=self.root) - edit_win.title("Edit Entry") + # Define callbacks for edit window buttons + callbacks: Dict[str, Callable] = { + "save": self._save_edit, + "delete": lambda win: self._delete_entry(win, item_id), + } - # Unpack values - date, dep, anx, slp, app, bup, hydro, gaba, prop, note = values + # Create edit window using UI manager + _: tk.Toplevel = self.ui_manager.create_edit_window(values, callbacks) - # Create variables for the widgets - date_var = tk.StringVar(value=str(date)) - dep_var = tk.IntVar(value=int(dep)) - anx_var = tk.IntVar(value=int(anx)) - slp_var = tk.IntVar(value=int(slp)) - app_var = tk.IntVar(value=int(app)) - bup_var = tk.IntVar(value=int(bup)) - hydro_var = tk.IntVar(value=int(hydro)) - gaba_var = tk.IntVar(value=int(gaba)) - prop_var = tk.IntVar(value=int(prop)) - note_var = tk.StringVar(value=str(note)) - - # Create form widgets - ttk.Label(edit_win, text="Depression:").grid( - row=1, column=0, sticky="w", padx=5, pady=2 - ) - ttk.Scale( - edit_win, from_=0, to=10, variable=dep_var, orient=tk.HORIZONTAL - ).grid(row=1, column=1, sticky="ew") - - ttk.Label(edit_win, text="Anxiety:").grid( - row=2, column=0, sticky="w", padx=5, pady=2 - ) - ttk.Scale( - edit_win, from_=0, to=10, variable=anx_var, orient=tk.HORIZONTAL - ).grid(row=2, column=1, sticky="ew") - - ttk.Label(edit_win, text="Sleep:").grid( - row=3, column=0, sticky="w", padx=5, pady=2 - ) - ttk.Scale( - edit_win, from_=0, to=10, variable=slp_var, orient=tk.HORIZONTAL - ).grid(row=3, column=1, sticky="ew") - - ttk.Label(edit_win, text="Appetite:").grid( - row=4, column=0, sticky="w", padx=5, pady=2 - ) - ttk.Scale( - edit_win, from_=0, to=10, variable=app_var, orient=tk.HORIZONTAL - ).grid(row=4, column=1, sticky="ew") - - ttk.Label(edit_win, text="Treatment:").grid( - row=5, column=0, sticky="w", padx=5, pady=2 - ) - - medicine_frame = ttk.LabelFrame(edit_win, text="Medicine") - medicine_frame.grid(row=5, column=1, padx=0, pady=10, sticky="nsew") - - ttk.Checkbutton( - medicine_frame, - text="Bupropion 150mg", - name="bupropion_check", - variable=bup_var, - command=lambda: self.toggle_checkbox(obj_name="bupropion_check"), - ).grid(row=0, column=0, sticky="w", padx=5, pady=2) - - ttk.Checkbutton( - medicine_frame, - text="Hydroxyzine 25mg", - name="hydroxyzine_check", - variable=hydro_var, - command=lambda: self.toggle_checkbox(obj_name="hydroxyzine_check"), - ).grid(row=1, column=0, sticky="w", padx=5, pady=2) - - ttk.Checkbutton( - medicine_frame, - text="Gabapentin 100mg", - name="gabapentin_check", - variable=gaba_var, - command=lambda: self.toggle_checkbox(obj_name="gabapentin_check"), - ).grid(row=2, column=0, sticky="w", padx=5, pady=2) - - ttk.Checkbutton( - medicine_frame, - text="Propranolol 10mg", - name="propranolol_check", - variable=prop_var, - command=lambda: self.toggle_checkbox(obj_name="propranolol_check"), - ).grid(row=3, column=0, sticky="w", padx=5, pady=2) - - ttk.Label(edit_win, text="Note:").grid( - row=6, column=0, sticky="w", padx=5, pady=2 - ) - ttk.Entry(edit_win, textvariable=note_var).grid( - row=6, column=1, sticky="ew" - ) - - ttk.Label(edit_win, text="Date:").grid( - row=7, column=0, sticky="w", padx=5, pady=2 - ) - ttk.Entry(edit_win, textvariable=date_var).grid( - row=7, column=1, sticky="ew" - ) - - # Save and Cancel buttons - save_btn = ttk.Button( - edit_win, - text="Save", - command=lambda: self.save_edit( - edit_win, - date_var.get(), - dep_var.get(), - anx_var.get(), - slp_var.get(), - app_var.get(), - bup_var.get(), - hydro_var.get(), - gaba_var.get(), - prop_var.get(), - note_var.get(), - ), - ) - save_btn.grid(row=8, column=0, padx=5, pady=10) - - cancel_btn = ttk.Button( - edit_win, text="Cancel", command=edit_win.destroy - ) - cancel_btn.grid(row=8, column=1, padx=5, pady=10) - delete_btn = ttk.Button( - edit_win, - text="Delete", - command=lambda: self.delete_entry(edit_win, item_id), - ) - delete_btn.grid(row=8, column=2, padx=5, pady=10) - - def save_edit( + def _save_edit( self, edit_win: tk.Toplevel, date: str, @@ -407,207 +142,121 @@ class MedTrackerApp: prop: int, note: str, ) -> None: - """ - Save the edited data to the CSV file. - """ - df = pd.read_csv(self.filename) - # Find the row to update using the date as a unique identifier - df.loc[ - df["date"] == date, - [ - "date", - "depression", - "anxiety", - "sleep", - "appetite", - "bupropion", - "hydroxyzine", - "gabapentin", - "propranolol", - "note", - ], - ] = [date, dep, anx, slp, app, bup, hydro, gaba, prop, note] - # Write the updated dataframe back to the CSV - df.to_csv(self.filename, index=False) + """Save the edited data to the CSV file.""" + values: List[Union[str, int]] = [ + date, + dep, + anx, + slp, + app, + bup, + hydro, + gaba, + prop, + note, + ] - edit_win.destroy() - messagebox.showinfo( - "Success", "Entry updated successfully!", parent=self.root - ) - self.clear_entries() - self.load_data() + if self.data_manager.update_entry(date, values): + edit_win.destroy() + messagebox.showinfo( + "Success", "Entry updated successfully!", parent=self.root + ) + self._clear_entries() + self.load_data() + else: + messagebox.showerror( + "Error", "Failed to save changes", parent=edit_win + ) def on_closing(self) -> None: if messagebox.askokcancel( "Quit", "Do you want to quit the application?", parent=self.root ): - plt.close(self.fig) + self.graph_manager.close() self.root.destroy() - def initialize_csv(self) -> None: - if not os.path.exists(self.filename): - with open(self.filename, mode="w", newline="") as file: - writer = csv.writer(file) - writer.writerow( - [ - "date", - "depression", - "anxiety", - "sleep", - "appetite", - "bupropion", - "hydroxyzine", - "gabapentin", - "propranolol", - "note", - ] - ) - def add_entry(self) -> None: - with open(self.filename, mode="a", newline="") as file: - writer = csv.writer(file) - writer.writerow( - [ - self.date_var.get(), - self.depression_var.get(), - self.anxiety_var.get(), - self.sleep_var.get(), - self.appetite_var.get(), - self.bupropion_var.get(), - self.hydroxyzine_var.get(), - self.gabapentin_var.get(), - self.propranolol_var.get(), - self.note_var.get(), - ] + """Add a new entry to the CSV file.""" + entry: List[Union[str, int]] = [ + self.date_var.get(), + self.symptom_vars["depression"].get(), + self.symptom_vars["anxiety"].get(), + self.symptom_vars["sleep"].get(), + self.symptom_vars["appetite"].get(), + self.medicine_vars["bupropion"][0].get(), + self.medicine_vars["hydroxyzine"][0].get(), + self.medicine_vars["gabapentin"][0].get(), + self.medicine_vars["propranolol"][0].get(), + self.note_var.get(), + ] + logger.debug(f"Adding entry: {entry}") + + if self.data_manager.add_entry(entry): + messagebox.showinfo( + "Success", "Entry added successfully!", parent=self.root + ) + self._clear_entries() + self.load_data() + else: + messagebox.showerror( + "Error", "Failed to add entry", parent=self.root ) - messagebox.showinfo( - "Success", "Entry added successfully!", parent=self.root - ) - self.clear_entries() - self.load_data() - - def delete_entry(self, edit_win: tk.Toplevel, item_id: str) -> None: - """ - Delete the selected entry from the CSV file. - """ + def _delete_entry(self, edit_win: tk.Toplevel, item_id: str) -> None: + """Delete the selected entry from the CSV file.""" + logger.debug(f"Delete requested for item_id={item_id}") if messagebox.askyesno( "Delete Entry", "Are you sure you want to delete this entry?", parent=edit_win, ): - df = pd.read_csv(self.filename) # Get the date of the entry to delete - date = self.tree.item(item_id, "values")[0] - # Remove the row with the matching date - df = df[df["date"] != date] - # Write the updated dataframe back to the CSV - df.to_csv(self.filename, index=False) + date: str = self.tree.item(item_id, "values")[0] + logger.debug(f"Deleting entry with date={date}") - edit_win.destroy() - messagebox.showinfo( - "Success", "Entry deleted successfully!", parent=edit_win - ) - self.load_data() + if self.data_manager.delete_entry(date): + edit_win.destroy() + messagebox.showinfo( + "Success", "Entry deleted successfully!", parent=edit_win + ) + self.load_data() + else: + messagebox.showerror( + "Error", "Failed to delete entry", parent=edit_win + ) - def clear_entries(self) -> None: + def _clear_entries(self) -> None: + """Clear all input fields.""" + logger.debug("Clearing input fields.") self.date_var.set("") - self.depression_var.set(0) - self.anxiety_var.set(0) - self.sleep_var.set(0) - self.appetite_var.set(0) - self.bupropion_var.set(False) - self.hydroxyzine_var.set(False) - self.gabapentin_var.set(False) - self.propranolol_var.set(False) + for key in self.symptom_vars: + self.symptom_vars[key].set(0) + for key in self.medicine_vars: + self.medicine_vars[key][0].set(0) self.note_var.set("") def load_data(self) -> None: + """Load data from the CSV file into the table and graph.""" + logger.debug("Loading data from CSV.") + + # Clear existing data in the treeview for i in self.tree.get_children(): self.tree.delete(i) - if ( - os.path.exists(self.filename) - and os.path.getsize(self.filename) > 0 - ): - try: - df = pd.read_csv( - self.filename, - dtype={ - "depression": int, - "anxiety": int, - "sleep": int, - "appetite": int, - "bupropion": int, - "hydroxyzine": int, - "gabapentin": int, - "propranolol": int, - "note": str, - "date": str, - }, - ).fillna("") - df = df.sort_values(by="date").reset_index(drop=True) - for index, row in df.iterrows(): - self.tree.insert(parent="", index="end", values=list(row)) - self.update_graph(df) - except pd.errors.EmptyDataError: - self.update_graph(pd.DataFrame()) + # Load data from the CSV file + df: pd.DataFrame = self.data_manager.load_data() - def update_graph(self, df: pd.DataFrame) -> None: - self.ax.clear() + # Update the treeview with the data if not df.empty: - df["date"] = pd.to_datetime(df["date"]) - df = df.sort_values(by="date") - df.set_index(keys="date", inplace=True) - self.ax.plot( - df.index, - df["depression"], - marker="o", - linestyle="-", - label="Depression (0:good, 10:bad)", - ) - self.ax.plot( - df.index, - df["anxiety"], - marker="o", - linestyle="-", - label="Anxiety (0:good, 10:bad)", - ) - self.ax.plot( - df.index, - df["sleep"], - marker="o", - linestyle="dashed", - label="Sleep (0:bad, 10:good)", - ) - self.ax.plot( - df.index, - df["appetite"], - marker="o", - linestyle="dashed", - label="Appetite (0:bad, 10:good)", - ) - self.ax.legend() - self.ax.set_title("Medication Effects Over Time") - self.ax.set_xlabel("Date") - self.ax.set_ylabel("Rating (0-10)") - self.fig.autofmt_xdate() - self.canvas.draw() + for index, row in df.iterrows(): + self.tree.insert(parent="", index="end", values=list(row)) + logger.debug(f"Loaded {len(df)} entries into treeview.") - def make_icon(self, img: str, logger: logging.Logger) -> None: - try: - icon_image = Image.open(img) - icon_image = icon_image.resize( - size=(32, 32), resample=Image.Resampling.NEAREST - ) - icon_photo = ImageTk.PhotoImage(image=icon_image) - self.root.iconphoto(True, icon_photo) - self.root.wm_iconphoto(True, icon_photo) - except FileNotFoundError: - logger.warning("Icon file not found.") + # Update the graph + self.graph_manager.update_graph(df) if __name__ == "__main__": - root = tk.Tk() - app = MedTrackerApp(root) + root: tk.Tk = tk.Tk() + app: MedTrackerApp = MedTrackerApp(root) root.mainloop() diff --git a/src/ui_manager.py b/src/ui_manager.py new file mode 100644 index 0000000..b19a54b --- /dev/null +++ b/src/ui_manager.py @@ -0,0 +1,462 @@ +import os +import logging +import sys +import tkinter as tk +from tkinter import ttk +from typing import Dict, List, Tuple, Any, Callable, Union +from PIL import Image, ImageTk + + +class UIManager: + """Handle UI creation and management for the application.""" + + def __init__(self, root: tk.Tk, logger: logging.Logger) -> None: + self.root: tk.Tk = root + self.logger: logging.Logger = logger + + def setup_icon(self, img_path: str) -> bool: + """Set up the application icon.""" + try: + self.logger.info(f"Trying to load icon from: {img_path}") + # Try to find the icon in various locations + if not os.path.exists(img_path): + # Check if we're in PyInstaller bundle + if hasattr(sys, "_MEIPASS"): + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path: str = sys._MEIPASS + potential_paths: List[str] = [ + os.path.join(base_path, os.path.basename(img_path)), + os.path.join(base_path, "chart-671.png"), + ] + for path in potential_paths: + if os.path.exists(path): + self.logger.info( + f"Found icon in PyInstaller bundle: {path}" + ) + img_path = path + break + + icon_image: Image.Image = Image.open(img_path) + icon_image = icon_image.resize( + size=(32, 32), resample=Image.Resampling.NEAREST + ) + icon_photo: ImageTk.PhotoImage = ImageTk.PhotoImage( + image=icon_image + ) + self.root.iconphoto(True, icon_photo) + self.root.wm_iconphoto(True, icon_photo) + return True + except FileNotFoundError: + self.logger.warning(f"Icon file not found at {img_path}") + return False + except Exception as e: + self.logger.error(f"Error setting icon: {str(e)}") + return False + + def create_input_frame(self, parent_frame: ttk.Frame) -> Dict[str, Any]: + """Create and configure the input frame with all widgets.""" + input_frame: ttk.LabelFrame = ttk.LabelFrame( + parent_frame, text="New Entry" + ) + input_frame.grid(row=1, column=0, padx=10, pady=10, sticky="nsew") + input_frame.grid_columnconfigure(1, weight=1) + + # Create variables for symptoms + symptom_vars: Dict[str, tk.IntVar] = { + "depression": tk.IntVar(value=0), + "anxiety": tk.IntVar(value=0), + "sleep": tk.IntVar(value=0), + "appetite": tk.IntVar(value=0), + } + + # Create scales for symptoms + symptom_labels: List[Tuple[str, str]] = [ + ("Depression (0-10):", "depression"), + ("Anxiety (0-10):", "anxiety"), + ("Sleep Quality (0-10):", "sleep"), + ("Appetite (0-10):", "appetite"), + ] + + for idx, (label, var_name) in enumerate(symptom_labels): + ttk.Label(input_frame, text=label).grid( + row=idx, column=0, sticky="w", padx=5, pady=2 + ) + ttk.Scale( + input_frame, + from_=0, + to=10, + orient=tk.HORIZONTAL, + variable=symptom_vars[var_name], + ).grid(row=idx, column=1, sticky="ew") + + # Medicine checkboxes + ttk.Label(input_frame, text="Treatment:").grid( + row=4, column=0, sticky="w", padx=5, pady=2 + ) + medicine_frame = ttk.LabelFrame(input_frame, text="Medicine") + medicine_frame.grid(row=4, column=1, padx=0, pady=10, sticky="nsew") + + medicine_vars: Dict[str, Tuple[tk.IntVar, str]] = { + "bupropion": (tk.IntVar(value=0), "Bupropion 150mg"), + "hydroxyzine": (tk.IntVar(value=0), "Hydroxyzine 25mg"), + "gabapentin": (tk.IntVar(value=0), "Gabapentin 100mg"), + "propranolol": (tk.IntVar(value=0), "Propranolol 10mg"), + } + + for idx, (name, (var, text)) in enumerate(medicine_vars.items()): + ttk.Checkbutton(medicine_frame, text=text, variable=var).grid( + row=idx, column=0, sticky="w", padx=5, pady=2 + ) + + # Note and Date fields + note_var: tk.StringVar = tk.StringVar() + date_var: tk.StringVar = tk.StringVar() + + ttk.Label(input_frame, text="Note:").grid( + row=5, column=0, sticky="w", padx=5, pady=2 + ) + ttk.Entry(input_frame, textvariable=note_var).grid( + row=5, column=1, sticky="ew", padx=5, pady=2 + ) + + ttk.Label(input_frame, text="Date (mm/dd/yyyy):").grid( + row=6, column=0, sticky="w", padx=5, pady=2 + ) + ttk.Entry(input_frame, textvariable=date_var, justify="center").grid( + row=6, column=1, sticky="ew", padx=5, pady=2 + ) + + # Return all UI elements and variables + return { + "frame": input_frame, + "symptom_vars": symptom_vars, + "medicine_vars": medicine_vars, + "note_var": note_var, + "date_var": date_var, + } + + def create_table_frame(self, parent_frame: ttk.Frame) -> Dict[str, Any]: + """Create and configure the table frame with a treeview.""" + table_frame: ttk.LabelFrame = ttk.LabelFrame( + parent_frame, text="Log (Double-click to edit)" + ) + table_frame.grid(row=1, column=1, padx=10, pady=10, sticky="nsew") + + # Configure table frame to expand + table_frame.grid_rowconfigure(0, weight=1) + table_frame.grid_columnconfigure(0, weight=1) + + columns: List[str] = [ + "Date", + "Depression", + "Anxiety", + "Sleep", + "Appetite", + "Bupropion", + "Hydroxyzine", + "Gabapentin", + "Propranolol", + "Note", + ] + + tree: ttk.Treeview = ttk.Treeview( + table_frame, columns=columns, show="headings" + ) + + col_labels: List[str] = [ + "Date", + "Depression", + "Anxiety", + "Sleep", + "Appetite", + "Bupropion 150mg", + "Hydroxyzine 25mg", + "Gabapentin 100mg", + "Propranolol 10mg", + "Note", + ] + + for col, label in zip(columns, col_labels): + tree.heading(col, text=label) + + col_settings: List[Tuple[str, int, str]] = [ + ("Date", 80, "center"), + ("Depression", 80, "center"), + ("Anxiety", 80, "center"), + ("Sleep", 80, "center"), + ("Appetite", 80, "center"), + ("Bupropion", 120, "center"), + ("Hydroxyzine", 120, "center"), + ("Gabapentin", 120, "center"), + ("Propranolol", 120, "center"), + ("Note", 300, "w"), + ] + + for col, width, anchor in col_settings: + tree.column(col, width=width, anchor=anchor) + + tree.pack(side="left", fill="both", expand=True) + + # Add scrollbar + scrollbar = ttk.Scrollbar( + table_frame, orient="vertical", command=tree.yview + ) + tree.configure(yscrollcommand=scrollbar.set) + scrollbar.pack(side="right", fill="y") + + return {"frame": table_frame, "tree": tree} + + def create_graph_frame(self, parent_frame: ttk.Frame) -> ttk.LabelFrame: + """Create and configure the graph frame.""" + graph_frame: ttk.LabelFrame = ttk.LabelFrame( + parent_frame, text="Evolution" + ) + graph_frame.grid( + row=0, column=0, columnspan=2, padx=10, pady=10, sticky="nsew" + ) + return graph_frame + + def add_buttons( + self, frame: ttk.Frame, buttons_config: List[Dict[str, Any]] + ) -> ttk.Frame: + """Add buttons to a frame based on configuration.""" + button_frame: ttk.Frame = ttk.Frame(frame) + button_frame.grid(row=7, column=0, columnspan=2, pady=10) + + for btn_config in buttons_config: + ttk.Button( + button_frame, + text=btn_config["text"], + command=btn_config["command"], + ).pack( + side="left", + padx=5, + fill=btn_config.get("fill", None), + expand=btn_config.get("expand", False), + ) + + return button_frame + + def create_edit_window( + self, values: Tuple[str, ...], callbacks: Dict[str, Callable] + ) -> tk.Toplevel: + """Create a new window for editing an entry.""" + edit_win: tk.Toplevel = tk.Toplevel(master=self.root) + edit_win.title("Edit Entry") + edit_win.transient(self.root) # Make window modal + edit_win.minsize(400, 300) + + # Configure grid columns to expand properly + edit_win.grid_columnconfigure(1, weight=1) + + # Unpack values + date, dep, anx, slp, app, bup, hydro, gaba, prop, note = values + + # Create variables and fields + vars_dict = self._create_edit_fields( + edit_win, date, dep, anx, slp, app + ) + + # Medicine checkboxes + current_row = 6 # After the 5 fields (date, dep, anx, slp, app) + med_vars = self._create_medicine_checkboxes( + edit_win, current_row, bup, hydro, gaba, prop + ) + vars_dict.update(med_vars) + + # Note field + current_row += 1 + vars_dict["note"] = tk.StringVar(value=str(note)) + ttk.Label(edit_win, text="Note:").grid( + row=current_row, column=0, sticky="w", padx=5, pady=2 + ) + ttk.Entry(edit_win, textvariable=vars_dict["note"]).grid( + row=current_row, column=1, sticky="ew", padx=5, pady=2 + ) + + # Buttons + current_row += 1 + self._add_edit_window_buttons( + edit_win, current_row, vars_dict, callbacks + ) + + # Make window modal + edit_win.update_idletasks() + edit_win.focus_set() + edit_win.grab_set() + + return edit_win + + def _create_edit_fields( + self, + parent: tk.Toplevel, + date: str, + dep: int, + anx: int, + slp: int, + app: int, + ) -> Dict[str, Union[tk.StringVar, tk.IntVar]]: + """Create fields for editing entry values.""" + vars_dict: Dict[str, Union[tk.StringVar, tk.IntVar]] = {} + + # Ensure values are converted to appropriate types + try: + app = int(app) if app != "" else 0 + except (ValueError, TypeError): + self.logger.warning( + f"Invalid appetite value: {app}, defaulting to 0" + ) + app = 0 + + value_map = { + "date": date, + "depression": dep, + "anxiety": anx, + "sleep": slp, + "appetite": app, + } + + fields = [ + ("Date", tk.StringVar, "date"), + ("Depression (0-10)", tk.IntVar, "depression"), + ("Anxiety (0-10)", tk.IntVar, "anxiety"), + ("Sleep (0-10)", tk.IntVar, "sleep"), + ("Appetite (0-10)", tk.IntVar, "appetite"), + ] + + for idx, (label, var_type, key) in enumerate(fields): + try: + value = value_map[key] + if var_type == tk.IntVar: + try: + value = int(float(value)) + except (ValueError, TypeError): + value = 0 + self.logger.warning( + f"Failed to convert {key} value: {value}, defaulting to 0" + ) + else: + value = str(value) + except (ValueError, TypeError, KeyError): + value = 0 if var_type == tk.IntVar else "" + self.logger.warning( + f"Missing or invalid value for {key}, defaulting to {value}" + ) + + vars_dict[key] = var_type(value=value) + ttk.Label(parent, text=f"{label}:").grid( + row=idx + 1, column=0, sticky="w", padx=5, pady=2 + ) + + if var_type == tk.IntVar: + self._create_scale_with_label( + parent, idx + 1, vars_dict[key], value + ) + else: + ttk.Entry(parent, textvariable=vars_dict[key]).grid( + row=idx + 1, column=1, sticky="ew" + ) + + return vars_dict + + def _create_scale_with_label( + self, parent: tk.Toplevel, row: int, var: tk.IntVar, value: int + ) -> None: + """Create a scale with a value label.""" + scale_frame: ttk.Frame = ttk.Frame(parent) + scale_frame.grid(row=row, column=1, sticky="ew", padx=5, pady=2) + scale_frame.grid_columnconfigure(0, weight=1) + + scale = ttk.Scale( + scale_frame, from_=0, to=10, variable=var, orient=tk.HORIZONTAL + ) + scale.grid(row=0, column=0, sticky="ew", padx=5) + + # Add a value label to show the current value + value_label = ttk.Label(scale_frame, width=3) + value_label.grid(row=0, column=1, padx=(5, 0)) + + # Update label when scale value changes + def update_label(event=None): + value_label.configure(text=str(var.get())) + + scale.bind("", update_label) + scale.bind("", update_label) + update_label() # Set initial value + scale.set(value) # Explicitly set scale value + + def _create_medicine_checkboxes( + self, + parent: tk.Toplevel, + row: int, + bup: int, + hydro: int, + gaba: int, + prop: int, + ) -> Dict[str, tk.IntVar]: + """Create medicine checkboxes in the edit window.""" + ttk.Label(parent, text="Treatment:").grid( + row=row, column=0, sticky="w", padx=5, pady=2 + ) + medicine_frame: ttk.LabelFrame = ttk.LabelFrame( + parent, text="Medicine" + ) + medicine_frame.grid(row=row, column=1, padx=0, pady=10, sticky="nsew") + + medicine_vars: Dict[str, Tuple[int, str]] = { + "bupropion": (bup, "Bupropion 150mg"), + "hydroxyzine": (hydro, "Hydroxyzine 25mg"), + "gabapentin": (gaba, "Gabapentin 100mg"), + "propranolol": (prop, "Propranolol 10mg"), + } + + vars_dict: Dict[str, tk.IntVar] = {} + for idx, (key, (value, label)) in enumerate(medicine_vars.items()): + vars_dict[key] = tk.IntVar(value=int(value)) + ttk.Checkbutton( + medicine_frame, text=label, variable=vars_dict[key] + ).grid(row=idx, column=0, sticky="w", padx=5, pady=2) + + return vars_dict + + def _add_edit_window_buttons( + self, + parent: tk.Toplevel, + row: int, + vars_dict: Dict[str, Any], + callbacks: Dict[str, Callable], + ) -> None: + """Add buttons to the edit window.""" + button_frame: ttk.Frame = ttk.Frame(parent) + button_frame.grid(row=row, column=0, columnspan=2, pady=10) + + # Save button + ttk.Button( + button_frame, + text="Save", + command=lambda: callbacks["save"]( + parent, + vars_dict["date"].get(), + vars_dict["depression"].get(), + vars_dict["anxiety"].get(), + vars_dict["sleep"].get(), + vars_dict["appetite"].get(), + vars_dict["bupropion"].get(), + vars_dict["hydroxyzine"].get(), + vars_dict["gabapentin"].get(), + vars_dict["propranolol"].get(), + vars_dict["note"].get(), + ), + ).pack(side="left", padx=5) + + # Cancel button + ttk.Button(button_frame, text="Cancel", command=parent.destroy).pack( + side="left", padx=5 + ) + + # Delete button + ttk.Button( + button_frame, + text="Delete", + command=lambda: callbacks["delete"](parent), + ).pack(side="left", padx=5)