Initial commit
This commit is contained in:
186
ui/dashboard.py
Normal file
186
ui/dashboard.py
Normal file
@@ -0,0 +1,186 @@
|
||||
import customtkinter as ctk
|
||||
from database import Database
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
import datetime
|
||||
import numpy as np
|
||||
from utils import parse_float, get_bmi_status, get_bmi_icon
|
||||
|
||||
class DashboardFrame(ctk.CTkFrame):
|
||||
def __init__(self, master, db: Database):
|
||||
super().__init__(master)
|
||||
self.db = db
|
||||
self.profile = self.db.get_user_profile()
|
||||
self.setup_ui()
|
||||
|
||||
def setup_ui(self):
|
||||
self.clear_ui()
|
||||
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_rowconfigure(2, weight=1)
|
||||
|
||||
# Header
|
||||
self.header = ctk.CTkLabel(self, text="Status & Prognosis", font=ctk.CTkFont(size=24, weight="bold"))
|
||||
self.header.grid(row=0, column=0, pady=20, sticky="n")
|
||||
|
||||
# Check if profile exists
|
||||
if not self.profile:
|
||||
self.show_setup_form()
|
||||
else:
|
||||
self.show_dashboard()
|
||||
|
||||
def clear_ui(self):
|
||||
for widget in self.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
def show_setup_form(self):
|
||||
frame = ctk.CTkFrame(self)
|
||||
frame.grid(row=1, column=0, pady=20)
|
||||
|
||||
ctk.CTkLabel(frame, text="Welcome! Let's set up your profile.", font=ctk.CTkFont(size=16)).pack(pady=10)
|
||||
|
||||
ctk.CTkLabel(frame, text="Height (cm):").pack(pady=5)
|
||||
self.height_entry = ctk.CTkEntry(frame, placeholder_text="e.g. 180,0")
|
||||
self.height_entry.pack(pady=5)
|
||||
|
||||
ctk.CTkLabel(frame, text="Goal Weight (kg):").pack(pady=5)
|
||||
self.goal_entry = ctk.CTkEntry(frame, placeholder_text="e.g. 75,0")
|
||||
self.goal_entry.pack(pady=5)
|
||||
|
||||
ctk.CTkButton(frame, text="Save Profile", command=self.save_profile).pack(pady=20)
|
||||
|
||||
def save_profile(self):
|
||||
h = parse_float(self.height_entry.get())
|
||||
g = parse_float(self.goal_entry.get())
|
||||
|
||||
if h is not None and g is not None:
|
||||
self.db.save_user_profile(h, g)
|
||||
self.profile = self.db.get_user_profile()
|
||||
self.setup_ui()
|
||||
else:
|
||||
ctk.CTkLabel(self, text="Invalid input. Use numbers (e.g. 180.5 or 180,5).", text_color="red").grid(row=2, column=0)
|
||||
|
||||
def show_dashboard(self):
|
||||
# 1. Fetch Data
|
||||
logs = self.db.get_log_history(90) # Last 90 days for trends
|
||||
logs.reverse() # Oldest first
|
||||
|
||||
latest_log = logs[-1] if logs else None
|
||||
current_weight = latest_log['weight'] if latest_log and latest_log['weight'] else None
|
||||
|
||||
# --- Top Cards: BMI & Goal ---
|
||||
cards_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||
cards_frame.grid(row=1, column=0, sticky="ew", padx=20)
|
||||
cards_frame.grid_columnconfigure((0, 1, 2), weight=1)
|
||||
|
||||
# BMI Card
|
||||
bmi_frame = ctk.CTkFrame(cards_frame)
|
||||
bmi_frame.grid(row=0, column=0, padx=10, sticky="ew")
|
||||
|
||||
bmi_val_str = "N/A"
|
||||
bmi_status_text = ""
|
||||
bmi_color = "white"
|
||||
bmi_icon_img = None
|
||||
|
||||
if current_weight:
|
||||
height_m = self.profile['height_cm'] / 100
|
||||
bmi = current_weight / (height_m ** 2)
|
||||
bmi_val_str = f"{bmi:.1f}"
|
||||
|
||||
bmi_status_text, _, bmi_color = get_bmi_status(bmi)
|
||||
bmi_icon_img = get_bmi_icon(bmi, size=(80, 80))
|
||||
|
||||
# Layout BMI Card
|
||||
# Left: Icon, Right: Text
|
||||
bmi_inner = ctk.CTkFrame(bmi_frame, fg_color="transparent")
|
||||
bmi_inner.pack(pady=10, padx=10)
|
||||
|
||||
if bmi_icon_img:
|
||||
icon_label = ctk.CTkLabel(bmi_inner, text="", image=bmi_icon_img)
|
||||
icon_label.pack(side="left", padx=(0, 15))
|
||||
|
||||
text_container = ctk.CTkFrame(bmi_inner, fg_color="transparent")
|
||||
text_container.pack(side="left")
|
||||
|
||||
ctk.CTkLabel(text_container, text="BMI", font=ctk.CTkFont(size=14)).pack(anchor="w")
|
||||
ctk.CTkLabel(text_container, text=bmi_val_str, font=ctk.CTkFont(size=30, weight="bold"), text_color=bmi_color).pack(anchor="w")
|
||||
ctk.CTkLabel(text_container, text=bmi_status_text, font=ctk.CTkFont(size=12)).pack(anchor="w")
|
||||
|
||||
# Weight Card
|
||||
weight_frame = ctk.CTkFrame(cards_frame)
|
||||
weight_frame.grid(row=0, column=1, padx=10, sticky="ew")
|
||||
|
||||
w_text = f"{current_weight} kg" if current_weight else "-- kg"
|
||||
g_text = f"Goal: {self.profile['goal_weight_kg']} kg"
|
||||
|
||||
ctk.CTkLabel(weight_frame, text="Current Weight", font=ctk.CTkFont(size=14)).pack(pady=(10,0))
|
||||
ctk.CTkLabel(weight_frame, text=w_text, font=ctk.CTkFont(size=30, weight="bold")).pack()
|
||||
ctk.CTkLabel(weight_frame, text=g_text, font=ctk.CTkFont(size=12, slant="italic")).pack(pady=(0,10))
|
||||
|
||||
# Date Estimate Card
|
||||
date_frame = ctk.CTkFrame(cards_frame)
|
||||
date_frame.grid(row=0, column=2, padx=10, sticky="ew")
|
||||
|
||||
estimated_date_str = "-1 (Insuff. Data)"
|
||||
|
||||
# Calculate Projection
|
||||
valid_logs = [l for l in logs if l['weight'] is not None]
|
||||
if len(valid_logs) >= 3:
|
||||
dates_num = [(datetime.datetime.strptime(l['date'], "%Y-%m-%d") - datetime.datetime.strptime(valid_logs[0]['date'], "%Y-%m-%d")).days for l in valid_logs]
|
||||
weights = [l['weight'] for l in valid_logs]
|
||||
|
||||
# Linear Regression
|
||||
slope, intercept = np.polyfit(dates_num, weights, 1)
|
||||
|
||||
# Time to goal
|
||||
goal = self.profile['goal_weight_kg']
|
||||
if slope < 0 and current_weight > goal: # Losing weight towards goal
|
||||
days_to_goal = (goal - intercept) / slope
|
||||
target_date = datetime.datetime.strptime(valid_logs[0]['date'], "%Y-%m-%d") + datetime.timedelta(days=days_to_goal)
|
||||
estimated_date_str = target_date.strftime("%b %d, %Y")
|
||||
elif slope > 0 and current_weight < goal: # Gaining weight towards goal
|
||||
days_to_goal = (goal - intercept) / slope
|
||||
target_date = datetime.datetime.strptime(valid_logs[0]['date'], "%Y-%m-%d") + datetime.timedelta(days=days_to_goal)
|
||||
estimated_date_str = target_date.strftime("%b %d, %Y")
|
||||
else:
|
||||
estimated_date_str = "Trend mismatch" # Not moving towards goal
|
||||
|
||||
ctk.CTkLabel(date_frame, text="Est. Goal Date", font=ctk.CTkFont(size=14)).pack(pady=(10,0))
|
||||
ctk.CTkLabel(date_frame, text=estimated_date_str, font=ctk.CTkFont(size=20, weight="bold")).pack(pady=5)
|
||||
ctk.CTkLabel(date_frame, text="Based on recent trend", font=ctk.CTkFont(size=10)).pack(pady=(0,10))
|
||||
|
||||
# --- Graph ---
|
||||
self.graph_frame = ctk.CTkFrame(self)
|
||||
self.graph_frame.grid(row=2, column=0, sticky="nsew", padx=20, pady=20)
|
||||
|
||||
self.plot_prognosis(valid_logs if 'valid_logs' in locals() else [])
|
||||
|
||||
def plot_prognosis(self, logs):
|
||||
if not logs: return
|
||||
|
||||
dates = [datetime.datetime.strptime(l['date'], "%Y-%m-%d") for l in logs]
|
||||
weights = [l['weight'] for l in logs]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(6, 4), dpi=100)
|
||||
fig.patch.set_facecolor('#2b2b2b')
|
||||
ax.set_facecolor('#2b2b2b')
|
||||
|
||||
# Plot History
|
||||
ax.plot(dates, weights, color='tab:blue', marker='o', label='History')
|
||||
|
||||
# Plot Goal Line
|
||||
ax.axhline(y=self.profile['goal_weight_kg'], color='tab:green', linestyle=':', label='Goal')
|
||||
|
||||
ax.set_ylabel('Weight (kg)', color='white')
|
||||
ax.tick_params(axis='x', rotation=45, colors='white')
|
||||
ax.tick_params(axis='y', colors='white')
|
||||
ax.spines['bottom'].set_color('white')
|
||||
ax.spines['top'].set_color('none')
|
||||
ax.spines['left'].set_color('white')
|
||||
ax.spines['right'].set_color('none')
|
||||
|
||||
fig.tight_layout()
|
||||
|
||||
canvas = FigureCanvasTkAgg(fig, master=self.graph_frame)
|
||||
canvas.draw()
|
||||
canvas.get_tk_widget().pack(fill="both", expand=True)
|
||||
Reference in New Issue
Block a user