import logging import os import sys import tkinter as tk from collections.abc import Callable from datetime import datetime from tkinter import messagebox, ttk from typing import Any 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 # Check if we're in PyInstaller bundle if not os.path.exists(img_path) and 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.""" # Create main container for the scrollable input frame main_container = ttk.LabelFrame(parent_frame, text="New Entry") main_container.grid(row=1, column=0, padx=10, pady=10, sticky="nsew") main_container.grid_rowconfigure(0, weight=1) main_container.grid_columnconfigure(0, weight=1) # Create canvas and scrollbar for scrolling canvas = tk.Canvas(main_container, highlightthickness=0) scrollbar = ttk.Scrollbar( main_container, orient="vertical", command=canvas.yview ) canvas.configure(yscrollcommand=scrollbar.set) # Create the actual input frame inside the canvas input_frame = ttk.Frame(canvas) input_frame.grid_columnconfigure(1, weight=1) # Configure canvas scrolling def configure_scroll_region(event=None): canvas.configure(scrollregion=canvas.bbox("all")) def on_mousewheel(event): canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") input_frame.bind("", configure_scroll_region) canvas.bind("", on_mousewheel) # Windows/Linux canvas.bind("", lambda e: canvas.yview_scroll(-1, "units")) # Linux canvas.bind("", lambda e: canvas.yview_scroll(1, "units")) # Linux # Place canvas and scrollbar in the container canvas.grid(row=0, column=0, sticky="nsew") scrollbar.grid(row=0, column=1, sticky="ns") # Create window in canvas for the input frame canvas_window = canvas.create_window((0, 0), window=input_frame, anchor="nw") # Configure canvas window width to fill available space def configure_canvas_width(event=None): canvas_width = canvas.winfo_width() canvas.itemconfig(canvas_window, width=canvas_width) canvas.bind("", configure_canvas_width) # 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 tracking section (simplified) 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_frame.grid_columnconfigure(0, weight=1) # Store medicine variables (checkboxes only) medicine_vars: dict[str, tuple[tk.IntVar, str]] = { "bupropion": (tk.IntVar(value=0), "Bupropion 150/300 mg"), "hydroxyzine": (tk.IntVar(value=0), "Hydroxyzine 25mg"), "gabapentin": (tk.IntVar(value=0), "Gabapentin 100mg"), "propranolol": (tk.IntVar(value=0), "Propranolol 10mg"), } for idx, (_med_name, (var, text)) in enumerate(medicine_vars.items()): # Just checkbox for medicine taken 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 ) # Set default date to today date_var.set(datetime.now().strftime("%m/%d/%Y")) # Return all UI elements and variables return { "frame": main_container, "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 150/300 mg", "Hydroxyzine 25mg", "Gabapentin 100mg", "Propranolol 10mg", "Note", ] for col, label in zip(columns, col_labels, strict=False): 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 - handle both old and new CSV formats if len(values) == 10: # Old format: date, dep, anx, slp, app, bup, hydro, gaba, prop, note date, dep, anx, slp, app, bup, hydro, gaba, prop, note = values bup_doses, hydro_doses, gaba_doses, prop_doses = "", "", "", "" elif len(values) == 14: # New format with dose tracking ( date, dep, anx, slp, app, bup, bup_doses, hydro, hydro_doses, gaba, gaba_doses, prop, prop_doses, note, ) = values else: # Fallback for unexpected format self.logger.warning(f"Unexpected number of values in edit: {len(values)}") # Pad with default values values_list = list(values) + [""] * (14 - len(values)) ( date, dep, anx, slp, app, bup, bup_doses, hydro, hydro_doses, gaba, gaba_doses, prop, prop_doses, note, ) = values_list[:14] # 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) # Dose information display (editable) current_row += 1 dose_vars = self._add_dose_display_to_edit( edit_win, current_row, { "bupropion": bup_doses, "hydroxyzine": hydro_doses, "gabapentin": gaba_doses, "propranolol": prop_doses, }, ) vars_dict.update(dose_vars) # Note field current_row += 2 # Account for dose display 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, tk.StringVar | tk.IntVar]: """Create fields for editing entry values.""" vars_dict: dict[str, 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 150/300 mg"), "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 - create a custom callback to handle dose data def save_with_doses(): # Extract dose data from the text widgets dose_data = {} for medicine in ["bupropion", "hydroxyzine", "gabapentin", "propranolol"]: dose_text_key = f"{medicine}_doses_text" if dose_text_key in vars_dict and isinstance( vars_dict[dose_text_key], tk.Text ): raw_text = vars_dict[dose_text_key].get(1.0, tk.END).strip() dose_data[medicine] = self._parse_dose_text( raw_text, vars_dict["date"].get() ) else: dose_data[medicine] = "" 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(), dose_data, ) ttk.Button( button_frame, text="Save", command=save_with_doses, ).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) def _add_dose_display_to_edit( self, parent: tk.Toplevel, row: int, dose_data: dict[str, str] ) -> dict[str, tk.Text]: """Add comprehensive dose tracking to edit window with punch buttons.""" ttk.Label(parent, text="Dose Tracking:").grid( row=row, column=0, sticky="w", padx=5, pady=2 ) dose_frame = ttk.LabelFrame(parent, text="Medicine Doses") dose_frame.grid(row=row, column=1, padx=5, pady=2, sticky="ew") dose_frame.grid_columnconfigure(2, weight=1) dose_vars = {} for idx, (medicine, doses_str) in enumerate(dose_data.items()): # Medicine label med_label = ttk.Label(dose_frame, text=f"{medicine.title()}:") med_label.grid(row=idx, column=0, sticky="w", padx=5, pady=2) # Dose entry field for new doses dose_entry_var = tk.StringVar() dose_entry = ttk.Entry(dose_frame, textvariable=dose_entry_var, width=12) dose_entry.grid(row=idx, column=1, sticky="w", padx=5, pady=2) # Store entry variable in dose_vars for access from punch button dose_vars[f"{medicine}_entry_var"] = dose_entry_var # Display area for existing doses (editable) dose_text = tk.Text(dose_frame, height=3, width=40, wrap=tk.WORD) dose_text.grid(row=idx, column=2, sticky="ew", padx=5, pady=2) # Store text widget in dose_vars dose_vars[f"{medicine}_doses_text"] = dose_text # Punch button to record dose immediately punch_button = ttk.Button( dose_frame, text=f"Take {medicine.title()}", width=15, command=lambda med=medicine: self._punch_dose_in_edit(med, dose_vars), ) punch_button.grid(row=idx, column=3, sticky="w", padx=5, pady=2) # Parse and format doses for editing if doses_str: formatted_doses = [] for dose_entry_str in doses_str.split("|"): if ":" in dose_entry_str: timestamp, dose = dose_entry_str.split(":", 1) # Format timestamp for display try: dt = datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S") time_str = dt.strftime("%H:%M") formatted_doses.append(f"{time_str}: {dose}") except ValueError: formatted_doses.append(dose_entry_str) if formatted_doses: dose_text.insert(1.0, "\n".join(formatted_doses)) else: dose_text.insert(1.0, "No doses recorded") else: dose_text.insert(1.0, "No doses recorded") # Add help text below the dose display help_label = ttk.Label( dose_frame, text="Format: HH:MM: dose", font=("TkDefaultFont", 8), foreground="gray", ) help_label.grid(row=idx, column=4, sticky="w", padx=5, pady=2) return dose_vars def _punch_dose_in_edit(self, medicine_name: str, dose_vars: dict) -> None: """Handle punch dose button in edit window.""" dose_entry_var = dose_vars.get(f"{medicine_name}_entry_var") dose_text_widget = dose_vars.get(f"{medicine_name}_doses_text") if not dose_entry_var or not dose_text_widget: return dose = dose_entry_var.get().strip() if not dose: messagebox.showerror( "Error", f"Please enter a dose amount for {medicine_name}", ) return # Get current time now = datetime.now() time_str = now.strftime("%H:%M") # Get current content current_content = dose_text_widget.get(1.0, tk.END).strip() # Add new dose entry new_dose_line = f"{time_str}: {dose}" if current_content == "No doses recorded" or not current_content: dose_text_widget.delete(1.0, tk.END) dose_text_widget.insert(1.0, new_dose_line) else: dose_text_widget.insert(tk.END, f"\n{new_dose_line}") # Clear the entry field dose_entry_var.set("") # Show success message messagebox.showinfo( "Success", f"{medicine_name.title()} dose recorded: {dose} at {time_str}", ) def _parse_dose_text(self, text: str, date: str) -> str: """Parse dose text from edit window back to CSV format.""" if not text or text == "No doses recorded": return "" lines = text.strip().split("\n") dose_entries = [] for line in lines: line = line.strip() if ":" in line and line != "No doses recorded": try: # Try to parse HH:MM: dose format # Split on ': ' (colon followed by space) to separate time from dose if ": " in line: time_part, dose_part = line.split(": ", 1) else: # Fallback: split on first colon after HH:MM pattern colon_indices = [ i for i, char in enumerate(line) if char == ":" ] if len(colon_indices) >= 2: # Take everything up to the second colon as time second_colon_idx = colon_indices[1] time_part = line[:second_colon_idx] dose_part = line[second_colon_idx + 1 :].strip() else: continue dose_part = dose_part.strip() # Create timestamp for today from datetime import datetime time_str = time_part.strip() # Parse just the time (HH:MM format) time_obj = datetime.strptime(time_str, "%H:%M") # Create full timestamp with today's date today = datetime.strptime(date, "%m/%d/%Y") full_timestamp = today.replace( hour=time_obj.hour, minute=time_obj.minute, second=0 ) timestamp_str = full_timestamp.strftime("%Y-%m-%d %H:%M:%S") dose_entries.append(f"{timestamp_str}:{dose_part}") except ValueError: # If parsing fails, skip this line continue return "|".join(dose_entries)