feat: Implement application preferences with JSON persistence
Build and Push Docker Image / build-and-push (push) Has been cancelled

- Added preferences management in `preferences.py` with functions to load, save, get, set, and reset preferences.
- Introduced a configuration directory structure based on the operating system.
- Integrated preferences into the settings window, allowing users to reset settings and manage window geometry.
- Enhanced `search_filter.py` to support flexible date column names and improved filtering logic.
- Updated `settings_window.py` to include options for managing backup and configuration folder paths.
- Introduced an `UndoManager` class to handle undo actions for add/update/delete operations.
- Improved UIManager to support sorting in tree views and added a toast notification feature.
This commit is contained in:
William Valentin
2025-08-07 16:26:17 -07:00
parent 73498af138
commit 9372d6ef29
15 changed files with 1997 additions and 468 deletions
+293 -23
View File
@@ -7,6 +7,7 @@ from datetime import datetime
from tkinter import messagebox, ttk
from typing import Any
import pandas as pd
from PIL import Image, ImageTk
from medicine_manager import MedicineManager
@@ -15,29 +16,96 @@ from tooltip_system import TooltipManager
class UIManager:
"""Handle UI creation and management for the application."""
"""Handle UI creation and management for the application.
Test suite historically instantiated UIManager with only (root, logger).
To preserve backward compatibility we make other dependencies optional
and provide minimal shims when not supplied so unit tests focused on
widget construction still work without full managers.
"""
def __init__(
self,
root: tk.Tk,
logger: logging.Logger,
medicine_manager: MedicineManager,
pathology_manager: PathologyManager,
theme_manager, # Import would create circular dependency
medicine_manager: MedicineManager | None = None,
pathology_manager: PathologyManager | None = None,
theme_manager: Any | None = None, # Avoid circular import typing
) -> None:
self.root: tk.Tk = root
self.logger: logging.Logger = logger
self.medicine_manager = medicine_manager
self.pathology_manager = pathology_manager
self.theme_manager = theme_manager
self.root = root
self.logger = logger
# Provide lightweight fallback managers if not provided (tests use fixed keys)
class _FallbackMedicineMgr:
def get_medicine_keys(self):
return [
"bupropion",
"hydroxyzine",
"gabapentin",
"propranolol",
"quetiapine",
]
def get_medicine(self, key): # pragma: no cover - simple data holder
class M:
def __init__(self, k):
self.key = k
self.display_name = k.capitalize()
self.dosage_info = ""
self.color = "#CCCCCC"
return M(key)
def get_all_medicines(self):
return {k: self.get_medicine(k) for k in self.get_medicine_keys()}
def get_quick_doses(self, _key):
return []
class _FallbackPathologyMgr:
def get_pathology_keys(self):
return ["depression", "anxiety", "sleep", "appetite"]
def get_pathology(self, key): # pragma: no cover - simple data holder
class P:
def __init__(self, k):
self.key = k
self.display_name = k.capitalize()
self.scale_info = "0-10"
self.scale_min = 0
self.scale_max = 10
self.scale_orientation = (
"inverted" if k in ("sleep", "appetite") else "normal"
)
return P(key)
def get_all_pathologies(self):
return {k: self.get_pathology(k) for k in self.get_pathology_keys()}
class _FallbackThemeMgr:
def get_theme_colors(self):
return {
"bg": "#FFFFFF",
"alt_bg": "#F5F5F5",
"select_bg": "#2E86AB",
"select_fg": "#FFFFFF",
"fg": "#000000",
}
# Bind managers (use fallbacks if not provided)
self.medicine_manager = medicine_manager or _FallbackMedicineMgr()
self.pathology_manager = pathology_manager or _FallbackPathologyMgr()
self.theme_manager = theme_manager or _FallbackThemeMgr()
# Status bar attributes
self.status_bar: tk.Frame | None = None
self.status_label: tk.Label | None = None
self.file_info_label: tk.Label | None = None
self.last_backup_label: tk.Label | None = None
# Initialize tooltip manager
self.tooltip_manager = TooltipManager(theme_manager)
self.tooltip_manager = TooltipManager(self.theme_manager)
def setup_application_icon(self, img_path: str) -> bool:
"""Set up the application icon."""
@@ -240,9 +308,11 @@ class UIManager:
self._bind_mousewheel_to_widget_tree(input_frame, canvas)
# Return all UI elements and variables
# Tests expect keys symptom_vars & medicine_vars (legacy naming). Provide both.
return {
"frame": main_container,
"pathology_vars": pathology_vars,
"symptom_vars": pathology_vars, # backward compatibility alias
"medicine_vars": medicine_vars,
"note_var": note_var,
"date_var": date_var,
@@ -320,8 +390,17 @@ class UIManager:
tree.bind("<<TreeviewSelect>>", on_selection_change)
# Column sort state tracking
self._tree_sort_directions: dict[str, bool] = {}
def make_sort_callback(col_name: str):
def _callback():
self.sort_tree_column(tree, col_name)
return _callback
for col, label in zip(columns, col_labels, strict=False):
tree.heading(col, text=label)
tree.heading(col, text=label, command=make_sort_callback(col))
for col, width, anchor in col_settings:
tree.column(col, width=width, anchor=anchor)
@@ -338,6 +417,107 @@ class UIManager:
return {"frame": table_frame, "tree": tree}
# ------------------------------------------------------------------
# Table Utilities
# ------------------------------------------------------------------
def sort_tree_column(self, tree: ttk.Treeview, column: str) -> None:
"""Sort a treeview column, toggling ascending/descending."""
data = []
for item in tree.get_children(""):
values = tree.item(item, "values")
# Map heading column name to index
try:
col_index = tree["columns"].index(column)
except ValueError:
continue
data.append((values[col_index], item, values))
# Determine direction
ascending = not self._tree_sort_directions.get(column, True)
self._tree_sort_directions[column] = ascending
def try_cast(v: Any):
for caster in (int, float):
try:
return caster(v)
except Exception:
continue
return str(v)
data.sort(key=lambda tup: try_cast(tup[0]), reverse=not ascending)
for index, (_value, item, _vals) in enumerate(data):
tree.move(item, "", index)
# Update heading arrow (basic glyph)
direction_glyph = "" if ascending else ""
tree.heading(column, text=f"{column} {direction_glyph}")
def diff_update_tree(self, tree: ttk.Treeview, df: pd.DataFrame) -> None:
"""Apply minimal changes to treeview vs full rebuild.
Rows keyed by 'date'. If structure mismatch or too large diff, fallback
to full rebuild.
"""
if df.empty:
for child in tree.get_children(""):
tree.delete(child)
return
# Build desired mapping
if "date" not in df.columns:
# Fallback
children = tree.get_children("")
if children:
tree.delete(*children)
for _idx, row in df.iterrows():
tree.insert("", "end", values=list(row))
return
desired = {str(row["date"]): list(row) for _i, row in df.iterrows()}
existing_ids = tree.get_children("")
existing_map = {}
for item_id in existing_ids:
vals = tree.item(item_id, "values")
if vals:
existing_map[str(vals[0])] = (item_id, list(vals))
# Heuristic: fallback if large diff (>30% changes)
change_budget = max(10, int(len(desired) * 0.3))
changes = 0
# Update & insert
for date_key, row_vals in desired.items():
if date_key in existing_map:
item_id, current_vals = existing_map[date_key]
if current_vals != row_vals:
tree.item(item_id, values=row_vals)
changes += 1
else:
tag = "evenrow" if (len(existing_map) + changes) % 2 == 0 else "oddrow"
tree.insert("", "end", values=row_vals, tags=(tag,))
changes += 1
if changes > change_budget:
break
# Delete orphaned if under budget
if changes <= change_budget:
for date_key, (item_id, _) in existing_map.items():
if date_key not in desired:
tree.delete(item_id)
changes += 1
if changes > change_budget:
break
# Fallback to full rebuild if budget exceeded
if changes > change_budget:
children = tree.get_children("")
if children:
tree.delete(*children)
for idx, row in df.iterrows():
tag = "evenrow" if idx % 2 == 0 else "oddrow"
tree.insert("", "end", values=list(row), tags=(tag,))
def create_graph_frame(self, parent_frame: ttk.Frame) -> ttk.LabelFrame:
"""Create and configure the graph frame."""
graph_frame: ttk.LabelFrame = ttk.LabelFrame(
@@ -376,6 +556,12 @@ class UIManager:
return button_frame
# Backward compatibility: some tests reference add_buttons
def add_buttons(
self, frame: ttk.Frame, buttons_config: list[dict[str, Any]]
): # pragma: no cover - simple delegate
return self.add_action_buttons(frame, buttons_config)
def create_status_bar(self, parent_frame: tk.Widget) -> tk.Frame:
"""Create and configure the status bar at the bottom of the application."""
# Get theme colors for consistent styling
@@ -419,8 +605,28 @@ class UIManager:
)
self.file_info_label.pack(side=tk.RIGHT)
# Create last backup label (right side, next to file info)
self.last_backup_label = tk.Label(
self.status_bar,
text="Last backup: —",
anchor=tk.E,
font=("TkDefaultFont", 9),
padx=10,
pady=2,
bg=theme_colors["bg"],
fg=theme_colors["fg"],
)
# Pack after file_info so it appears to the left of it
self.last_backup_label.pack(side=tk.RIGHT)
return self.status_bar
def update_last_backup(self, when_text: str) -> None:
"""Update the 'Last backup' indicator in the status bar."""
if not self.last_backup_label:
return
self.last_backup_label.config(text=f"Last backup: {when_text}")
def update_status(self, message: str, message_type: str = "info") -> None:
"""
Update the status bar with a message.
@@ -491,6 +697,57 @@ class UIManager:
lambda: self.status_label.config(text=original_text, fg=original_color),
)
def show_toast(self, message: str, duration_ms: int = 3000) -> None:
"""Display a transient toast-style message near the bottom-right.
Creates a small borderless window that auto-destroys after duration_ms.
Safe to call from anywhere; failures are ignored.
"""
try:
toast = tk.Toplevel(self.root)
toast.overrideredirect(True)
toast.attributes("-topmost", True)
# Styling based on theme
colors = self.theme_manager.get_theme_colors()
bg = colors.get("alt_bg", "#333333")
fg = colors.get("fg", "#000000")
frame = tk.Frame(toast, bg=bg, bd=1, relief=tk.SOLID)
frame.pack(fill=tk.BOTH, expand=True)
label = tk.Label(
frame,
text=message,
bg=bg,
fg=fg,
padx=12,
pady=8,
font=("TkDefaultFont", 9),
anchor=tk.W,
justify=tk.LEFT,
)
label.pack()
self.root.update_idletasks()
# Position in bottom-right of the root window
root_x = self.root.winfo_rootx()
root_y = self.root.winfo_rooty()
root_w = self.root.winfo_width()
root_h = self.root.winfo_height()
toast.update_idletasks()
tw = toast.winfo_width() or 240
th = toast.winfo_height() or 48
x = root_x + root_w - tw - 20
y = root_y + root_h - th - 20
toast.geometry(f"{tw}x{th}+{max(0, x)}+{max(0, y)}")
# Auto-destroy after duration
toast.after(duration_ms, toast.destroy)
except Exception:
# Non-fatal UI convenience; ignore errors
pass
def create_edit_window(
self, values: tuple[str, ...], callbacks: dict[str, Callable]
) -> tk.Toplevel:
@@ -570,8 +827,12 @@ class UIManager:
# Expected format: date, pathology1, pathology2, ...,
# medicine1, medicine1_doses, medicine2, medicine2_doses, ..., note
# Parse values dynamically
# Parse values dynamically. Legacy tests pass a compressed tuple:
# (date, p1, p2, p3, p4, m1, m2, m3, m4, note)
values_list = list(values)
legacy_mode = False
if len(values_list) == 10: # heuristic matching test tuple
legacy_mode = True
# Extract date
date = values_list[0] if len(values_list) > 0 else ""
@@ -594,19 +855,28 @@ class UIManager:
medicine_start_idx = 1 + len(pathology_keys)
for i, medicine_key in enumerate(medicine_keys):
# Each medicine has 2 values: checkbox value and doses string
checkbox_idx = medicine_start_idx + (i * 2)
doses_idx = medicine_start_idx + (i * 2) + 1
if checkbox_idx < len(values_list):
medicine_values[medicine_key] = values_list[checkbox_idx]
if legacy_mode:
# After pathologies, next up to len(medicine_keys) values map directly
legacy_idx = 1 + len(pathology_keys) + i
if legacy_idx < len(values_list) - 1: # last element is note
medicine_values[medicine_key] = values_list[legacy_idx]
else:
medicine_values[medicine_key] = 0
medicine_doses[medicine_key] = "" # No dose info in legacy tuple
else:
medicine_values[medicine_key] = 0
# Each medicine has 2 values: checkbox value and doses string
checkbox_idx = medicine_start_idx + (i * 2)
doses_idx = medicine_start_idx + (i * 2) + 1
if doses_idx < len(values_list):
medicine_doses[medicine_key] = values_list[doses_idx]
else:
medicine_doses[medicine_key] = ""
if checkbox_idx < len(values_list):
medicine_values[medicine_key] = values_list[checkbox_idx]
else:
medicine_values[medicine_key] = 0
if doses_idx < len(values_list):
medicine_doses[medicine_key] = values_list[doses_idx]
else:
medicine_doses[medicine_key] = ""
# Extract note (should be the last value)
note = values_list[-1] if len(values_list) > 0 else ""