From 4ae02411aa4ffc4ddf9d20f08f9e0beebfb8efd4 Mon Sep 17 00:00:00 2001 From: ramforth Date: Sat, 10 Jan 2026 16:46:49 +0100 Subject: [PATCH] Initial commit --- .gitignore | 7 + DEVELOPMENT_PLAN.md | 49 ++++ README.md | 22 ++ assets/bmi_icons/bmi_normal.svg | 10 + assets/bmi_icons/bmi_overweight.svg | 10 + assets/bmi_icons/bmi_severely_obese.svg | 10 + assets/bmi_icons/bmi_severely_underweight.svg | 10 + assets/bmi_icons/bmi_underweight.svg | 10 + .../png_cache/bmi_severely_obese_80x80.png | Bin 0 -> 1349 bytes database.py | 223 ++++++++++++++++++ main.py | 15 ++ requirements.txt | 4 + ui/app.py | 72 ++++++ ui/daily_log.py | 122 ++++++++++ ui/dashboard.py | 186 +++++++++++++++ ui/stats.py | 99 ++++++++ ui/workout.py | 145 ++++++++++++ utils.py | 72 ++++++ 18 files changed, 1066 insertions(+) create mode 100644 .gitignore create mode 100644 DEVELOPMENT_PLAN.md create mode 100644 README.md create mode 100644 assets/bmi_icons/bmi_normal.svg create mode 100644 assets/bmi_icons/bmi_overweight.svg create mode 100644 assets/bmi_icons/bmi_severely_obese.svg create mode 100644 assets/bmi_icons/bmi_severely_underweight.svg create mode 100644 assets/bmi_icons/bmi_underweight.svg create mode 100644 assets/bmi_icons/png_cache/bmi_severely_obese_80x80.png create mode 100644 database.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 ui/app.py create mode 100644 ui/daily_log.py create mode 100644 ui/dashboard.py create mode 100644 ui/stats.py create mode 100644 ui/workout.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa17c47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +venv/ +__pycache__/ +*.pyc +*.db +.DS_Store +.idea/ +.vscode/ diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..756a542 --- /dev/null +++ b/DEVELOPMENT_PLAN.md @@ -0,0 +1,49 @@ +# Development Plan: Exercise Diary + +## Overview +A desktop GUI application for Linux (CachyOS) to track workout sessions, exercises, and body metrics (weight, etc.). + +## Technology Stack +* **Language:** Python 3 +* **GUI:** `customtkinter` (Modern, clean, supports dark mode - similar to your Video Converter project). +* **Database:** `SQLite` (Lightweight, local file storage, no server setup required). +* **Plotting:** `matplotlib` (For visualizing progress/weight trends). + +## Core Features (MVP) +1. **Daily Health Log:** + * **Sleep:** Duration (hours) and Quality (1-10 scale). + * **Body:** Weight and Waist Circumference. + * **Wellness:** Mood/Energy levels. +2. **Calisthenics Tracker:** + * Focus on bodyweight exercises (e.g., Pushups, Squats, Planks). + * Track Reps, Time (for statics like Planks), and Difficulty/Variation (e.g., "Knee Pushups" vs "Standard"). + * RPE (Rate of Perceived Exertion) to track effort as strength increases. +3. **Motivation & Gamification:** + * **Streaks:** Visual indicator of consistency. + * **Progress Graphs:** Weight vs. Waist, Sleep Quality vs. Mood. + * **"Level 0 to Hero":** Simple milestones to acknowledge starting from a sedentary lifestyle. +4. **Exercise Library:** + * Pre-loaded with basic calisthenics moves. + * Ability to add custom exercises. + +## Proposed File Structure +``` +ExerciseDiary/ +├── main.py # Entry point +├── database.py # SQLite handling (Daily logs + Workouts) +├── models.py # Data classes +├── assets/ # Icons or motivational images +├── ui/ +│ ├── app.py # Main GUI window +│ ├── daily_log.py # Quick entry for Sleep/Weight/Waist +│ ├── workout.py # Calisthenics logger +│ └── stats.py # Motivation & Graphs +├── requirements.txt # Dependencies +└── README.md +``` + +## Next Steps +1. Initialize the project environment (virtualenv). +2. Install `customtkinter` and `matplotlib`. +3. Set up the database schema. +4. Build the basic GUI shell. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd21a76 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Exercise Diary: Level Sub-0 + +A simple, motivating training diary focused on Calisthenics and basic health metrics. + +## Features +- **Daily Log:** Track Weight, Waist, Sleep (duration & quality), and Mood. +- **Workout Logger:** specifically for Calisthenics (reps, static holds, variations). +- **Progress Tracking:** Visual graphs for weight and waist trends. +- **Motivation:** Streak tracking to keep you consistent. + +## How to Run +1. Ensure you have Python 3.10+ installed. +2. The dependencies are already installed in the `venv` folder if you used the setup script. +3. Run the application: + ```bash + ./venv/bin/python main.py + ``` + +## Tech Stack +- **GUI:** CustomTkinter +- **Database:** SQLite +- **Plotting:** Matplotlib diff --git a/assets/bmi_icons/bmi_normal.svg b/assets/bmi_icons/bmi_normal.svg new file mode 100644 index 0000000..5031686 --- /dev/null +++ b/assets/bmi_icons/bmi_normal.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/bmi_icons/bmi_overweight.svg b/assets/bmi_icons/bmi_overweight.svg new file mode 100644 index 0000000..cbe5f15 --- /dev/null +++ b/assets/bmi_icons/bmi_overweight.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/bmi_icons/bmi_severely_obese.svg b/assets/bmi_icons/bmi_severely_obese.svg new file mode 100644 index 0000000..e461dab --- /dev/null +++ b/assets/bmi_icons/bmi_severely_obese.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/bmi_icons/bmi_severely_underweight.svg b/assets/bmi_icons/bmi_severely_underweight.svg new file mode 100644 index 0000000..8a3e1c0 --- /dev/null +++ b/assets/bmi_icons/bmi_severely_underweight.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/bmi_icons/bmi_underweight.svg b/assets/bmi_icons/bmi_underweight.svg new file mode 100644 index 0000000..52eccaa --- /dev/null +++ b/assets/bmi_icons/bmi_underweight.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/assets/bmi_icons/png_cache/bmi_severely_obese_80x80.png b/assets/bmi_icons/png_cache/bmi_severely_obese_80x80.png new file mode 100644 index 0000000000000000000000000000000000000000..afe9f245229027cb7847149f2cb0299ece3ddf03 GIT binary patch literal 1349 zcmV-L1-kl)P)xCUheHv!c`0p57!aVGeU?)X08EHg1OBo5%mG+ zl2Y!A$Kz+Oe37OeF!TI$I{glSRsh16Q1wSL^PX5N_5u-Q{uekYuLHn3zaun+W6@}| zSxL;cv~)VXJCK(b>r<)J+v)+SbDj{QJLtA~Bc+tL3N>H^@*H7>LZPQs7rw_?`2c2a zQZ2ALP~n)RApoV&2)fV*(bWO`@)I!d*Tj>RH-HnXz)daSDm69Lh6trnDTWS=Wndj> z2X!6>*q;6vA;23pLO$6Fgyum9^bk0ff%W>UptGlQI-Kk3Azyn#3yE5+IK+WHIm=Vf znQmAg?1c^pEzWtJNAfTC<>#ROKY+O|P^bapTIal(ux*ekJJ$u08f~4HJ?*^gtPzO8 z$*GD1Xvlfl*&~q1O=0+r{{pYCbX=5Ol$|vK?Zhg_Wx0MeFxLfgQ%iP*kvBrXZoIb; zA>|i_-vMw{O?G*fce&-jzAg0QvZ{&;dQx!iw>+?D9;lFw`XZ znwtsDnlM|C%ysos|)+ZhpTJAciaWLWiwD3F{BH# z4e{9_h(iZ~(SM)?C8e+*cog=1_X8v}7I^6r#Jjs7PM&~{@ai>?+g3E_5_0F8z`U?0^?BBwt@hp8%TrMAIf(W)&+79;ApQ6~=y(@QL44f_>i)r- zx;z;)KH3k{5FhSyb}8ZNdfZk(=g(y?44DS?{SNf@Xdv*Uo+EB9h*RIWufvOzvZ>Jd zGa8%&hX!CQ$)pA?@Ztzeh0=d(&;rNDVJsfRuEmbD$pivj5(sojAkZa&K$ippeHTau ze+M4K?!JbyG8juN=D$E*R1Q<&MR5%TE?W*$;bk{z(1q8mg{koEYyB5!CmKBI3dM%w z<={lU1}!ig20!uyOoO*P<;j&fPv#)@`VGK}T9^XgQVaWTZ+@ie%_L3;jK91CT)7y! z!xfc~FK%~sSL`%+Hw!L{LT=v$EYbMavrDQWcf18e-Ov9DcW=zUqpeyj zH8CK~fH?M5&R+<74CaCmLLBuw3R5AXPL&3^b}|jJ<##}aAuY>F2$dUNr8oCG z8q;9r_al)=&r~Y-a5|k~8Zd+q`&GBS^40$Uu34)~J6>7V00000NkvXX Hu0mjfKq-L> literal 0 HcmV?d00001 diff --git a/database.py b/database.py new file mode 100644 index 0000000..af39169 --- /dev/null +++ b/database.py @@ -0,0 +1,223 @@ +import sqlite3 +import datetime +from typing import List, Optional, Dict, Any + +class Database: + def __init__(self, db_name="exercise_diary.db"): + self.db_name = db_name + self.init_db() + + def get_connection(self): + return sqlite3.connect(self.db_name) + + def init_db(self): + """Initialize the database tables.""" + conn = self.get_connection() + cursor = conn.cursor() + + # Daily Log Table (One entry per day) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS daily_logs ( + date TEXT PRIMARY KEY, + weight REAL, + waist REAL, + sleep_hours REAL, + sleep_quality INTEGER, + mood INTEGER, + notes TEXT + ) + ''') + + # Exercises Table (Library of available exercises) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS exercises ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + type TEXT DEFAULT 'reps' -- 'reps' or 'time' + ) + ''') + + # Pre-populate some basic calisthenics exercises if empty + cursor.execute("SELECT count(*) FROM exercises") + if cursor.fetchone()[0] == 0: + basic_exercises = [ + ("Pushups", "Standard pushups", "reps"), + ("Squats", "Bodyweight squats", "reps"), + ("Plank", "Static hold", "time"), + ("Lunges", "Alternating lunges", "reps"), + ("Crunches", "Abdominal crunches", "reps"), + ("Burpees", "Full body cardio/strength", "reps"), + ("Wall Sit", "Static leg hold", "time") + ] + cursor.executemany("INSERT INTO exercises (name, description, type) VALUES (?, ?, ?)", basic_exercises) + + # Workout Sessions Table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS workout_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Workout Sets Table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS workout_sets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER, + exercise_id INTEGER, + reps INTEGER, + duration_seconds INTEGER, + rpe INTEGER, -- 1-10 intensity + variation TEXT, -- e.g., "Knee pushups" + FOREIGN KEY (session_id) REFERENCES workout_sessions (id), + FOREIGN KEY (exercise_id) REFERENCES exercises (id) + ) + ''') + + # User Profile Table (Single row for settings) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS user_profile ( + id INTEGER PRIMARY KEY CHECK (id = 1), + height_cm REAL, + goal_weight_kg REAL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + conn.commit() + conn.close() + + # --- User Profile Operations --- + def get_user_profile(self) -> Optional[Dict]: + conn = self.get_connection() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM user_profile WHERE id = 1") + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + def save_user_profile(self, height_cm: float, goal_weight_kg: float): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute("INSERT OR REPLACE INTO user_profile (id, height_cm, goal_weight_kg) VALUES (1, ?, ?)", (height_cm, goal_weight_kg)) + conn.commit() + conn.close() + + # --- Daily Log Operations --- + def save_daily_log(self, date: str, data: Dict[str, Any]): + """Insert or update a daily log.""" + conn = self.get_connection() + cursor = conn.cursor() + + # Check if exists + cursor.execute("SELECT date FROM daily_logs WHERE date = ?", (date,)) + exists = cursor.fetchone() + + if exists: + # Dynamic update based on keys provided + set_clause = ", ".join([f"{key} = ?" for key in data.keys()]) + values = list(data.values()) + [date] + cursor.execute(f"UPDATE daily_logs SET {set_clause} WHERE date = ?", values) + else: + columns = ", ".join(["date"] + list(data.keys())) + placeholders = ", ".join(["?"] * (len(data) + 1)) + values = [date] + list(data.values()) + cursor.execute(f"INSERT INTO daily_logs ({columns}) VALUES ({placeholders})", values) + + conn.commit() + conn.close() + + def get_daily_log(self, date: str) -> Optional[Dict]: + conn = self.get_connection() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM daily_logs WHERE date = ?", (date,)) + row = cursor.fetchone() + conn.close() + if row: + return dict(row) + return None + + def get_log_history(self, limit=30) -> List[Dict]: + """Get the last N days of logs.""" + conn = self.get_connection() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM daily_logs ORDER BY date DESC LIMIT ?", (limit,)) + rows = cursor.fetchall() + conn.close() + return [dict(row) for row in rows] + + # --- Exercise Operations --- + def get_all_exercises(self) -> List[Dict]: + conn = self.get_connection() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM exercises ORDER BY name") + rows = cursor.fetchall() + conn.close() + return [dict(row) for row in rows] + + def add_exercise(self, name, description="", type="reps"): + conn = self.get_connection() + try: + cursor = conn.cursor() + cursor.execute("INSERT INTO exercises (name, description, type) VALUES (?, ?, ?)", (name, description, type)) + conn.commit() + return True + except sqlite3.IntegrityError: + return False # Name already exists + finally: + conn.close() + + # --- Workout Operations --- + def create_workout_session(self, date: str, notes: str = "") -> int: + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute("INSERT INTO workout_sessions (date, notes) VALUES (?, ?)", (date, notes)) + session_id = cursor.lastrowid + conn.commit() + conn.close() + return session_id + + def add_set(self, session_id: int, exercise_id: int, reps: int = 0, duration: int = 0, rpe: int = 0, variation: str = ""): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO workout_sets (session_id, exercise_id, reps, duration_seconds, rpe, variation) + VALUES (?, ?, ?, ?, ?, ?) + ''', (session_id, exercise_id, reps, duration, rpe, variation)) + conn.commit() + conn.close() + + def delete_set(self, set_id: int): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM workout_sets WHERE id = ?", (set_id,)) + conn.commit() + conn.close() + + def get_workouts_by_date(self, date: str) -> List[Dict]: + conn = self.get_connection() + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + # Get sessions + cursor.execute("SELECT * FROM workout_sessions WHERE date = ?", (date,)) + sessions = [dict(row) for row in cursor.fetchall()] + + # Get sets for each session + for session in sessions: + cursor.execute(''' + SELECT s.*, e.name as exercise_name + FROM workout_sets s + JOIN exercises e ON s.exercise_id = e.id + WHERE s.session_id = ? + ''', (session['id'],)) + session['sets'] = [dict(row) for row in cursor.fetchall()] + + conn.close() + return sessions diff --git a/main.py b/main.py new file mode 100644 index 0000000..8458064 --- /dev/null +++ b/main.py @@ -0,0 +1,15 @@ +import customtkinter as ctk +from ui.app import ExerciseDiaryApp +import os + +def main(): + # Set appearance + ctk.set_appearance_mode("Dark") # Modes: "System" (standard), "Dark", "Light" + ctk.set_default_color_theme("blue") # Themes: "blue" (standard), "green", "dark-blue" + + # Initialize app + app = ExerciseDiaryApp() + app.mainloop() + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d54d2d1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +customtkinter +matplotlib +packaging +pillow diff --git a/ui/app.py b/ui/app.py new file mode 100644 index 0000000..7649216 --- /dev/null +++ b/ui/app.py @@ -0,0 +1,72 @@ +import customtkinter as ctk +from database import Database +from datetime import date +from tkinter import messagebox + +class ExerciseDiaryApp(ctk.CTk): + def __init__(self): + super().__init__() + + self.title("Exercise Diary - Level Sub-0") + self.geometry("1000x700") + + self.db = Database() + self.current_date = date.today().isoformat() + + self.grid_columnconfigure(1, weight=1) + self.grid_rowconfigure(0, weight=1) + + # Sidebar + self.sidebar_frame = ctk.CTkFrame(self, width=200, corner_radius=0) + self.sidebar_frame.grid(row=0, column=0, sticky="nsew") + self.sidebar_frame.grid_rowconfigure(5, weight=1) + + self.logo_label = ctk.CTkLabel(self.sidebar_frame, text="My Diary", font=ctk.CTkFont(size=20, weight="bold")) + self.logo_label.grid(row=0, column=0, padx=20, pady=(20, 10)) + + self.btn_dashboard = ctk.CTkButton(self.sidebar_frame, text="Dashboard", command=self.show_dashboard) + self.btn_dashboard.grid(row=1, column=0, padx=20, pady=10) + + self.btn_daily = ctk.CTkButton(self.sidebar_frame, text="Daily Log", command=self.show_daily_log) + self.btn_daily.grid(row=2, column=0, padx=20, pady=10) + + self.btn_workout = ctk.CTkButton(self.sidebar_frame, text="Workouts", command=self.show_workout_log) + self.btn_workout.grid(row=3, column=0, padx=20, pady=10) + + self.btn_stats = ctk.CTkButton(self.sidebar_frame, text="Progress", command=self.show_stats) + self.btn_stats.grid(row=4, column=0, padx=20, pady=10) + + # Main Content Area + self.main_frame = ctk.CTkFrame(self, corner_radius=0, fg_color="transparent") + self.main_frame.grid(row=0, column=1, sticky="nsew", padx=20, pady=20) + + # Default View + self.show_dashboard() + + def clear_main_frame(self): + for widget in self.main_frame.winfo_children(): + widget.destroy() + + def show_dashboard(self): + self.clear_main_frame() + from ui.dashboard import DashboardFrame + frame = DashboardFrame(self.main_frame, self.db) + frame.pack(fill="both", expand=True) + + def show_daily_log(self): + self.clear_main_frame() + from ui.daily_log import DailyLogFrame + frame = DailyLogFrame(self.main_frame, self.db, self.current_date) + frame.pack(fill="both", expand=True) + + def show_workout_log(self): + self.clear_main_frame() + from ui.workout import WorkoutFrame + frame = WorkoutFrame(self.main_frame, self.db, self.current_date) + frame.pack(fill="both", expand=True) + + def show_stats(self): + self.clear_main_frame() + from ui.stats import StatsFrame + frame = StatsFrame(self.main_frame, self.db) + frame.pack(fill="both", expand=True) \ No newline at end of file diff --git a/ui/daily_log.py b/ui/daily_log.py new file mode 100644 index 0000000..3b0b444 --- /dev/null +++ b/ui/daily_log.py @@ -0,0 +1,122 @@ +import customtkinter as ctk +from database import Database +from tkinter import messagebox +from utils import parse_float, format_float + +class DailyLogFrame(ctk.CTkFrame): + def __init__(self, master, db: Database, date_str: str): + super().__init__(master) + self.db = db + self.date_str = date_str + + self.setup_ui() + self.load_data() + + def setup_ui(self): + self.grid_columnconfigure((0, 1), weight=1) + + # Header + self.header = ctk.CTkLabel(self, text=f"Daily Log: {self.date_str}", font=ctk.CTkFont(size=20, weight="bold")) + self.header.grid(row=0, column=0, columnspan=2, pady=10, sticky="w") + + # Sleep Section + self.sleep_frame = ctk.CTkFrame(self) + self.sleep_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=10) + + ctk.CTkLabel(self.sleep_frame, text="Sleep Duration (Hours):").grid(row=0, column=0, padx=10, pady=10) + self.sleep_entry = ctk.CTkEntry(self.sleep_frame, placeholder_text="e.g. 7,5") + self.sleep_entry.grid(row=0, column=1, padx=10, pady=10) + + ctk.CTkLabel(self.sleep_frame, text="Sleep Quality (1-10):").grid(row=0, column=2, padx=10, pady=10) + self.sleep_quality_slider = ctk.CTkSlider(self.sleep_frame, from_=1, to=10, number_of_steps=9) + self.sleep_quality_slider.grid(row=0, column=3, padx=10, pady=10) + self.sleep_quality_label = ctk.CTkLabel(self.sleep_frame, text="5") + self.sleep_quality_label.grid(row=0, column=4, padx=5) + self.sleep_quality_slider.configure(command=lambda val: self.sleep_quality_label.configure(text=str(int(val)))) + + # Body Stats Section + self.body_frame = ctk.CTkFrame(self) + self.body_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=10) + + ctk.CTkLabel(self.body_frame, text="Weight (kg):").grid(row=0, column=0, padx=10, pady=10) + self.weight_entry = ctk.CTkEntry(self.body_frame, placeholder_text="0,0") + self.weight_entry.grid(row=0, column=1, padx=10, pady=10) + + ctk.CTkLabel(self.body_frame, text="Waist (cm):").grid(row=0, column=2, padx=10, pady=10) + self.waist_entry = ctk.CTkEntry(self.body_frame, placeholder_text="0,0") + self.waist_entry.grid(row=0, column=3, padx=10, pady=10) + + # Notes / Mood + self.notes_frame = ctk.CTkFrame(self) + self.notes_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=10) + + ctk.CTkLabel(self.notes_frame, text="Mood/Energy (1-10):").grid(row=0, column=0, padx=10, pady=10) + self.mood_slider = ctk.CTkSlider(self.notes_frame, from_=1, to=10, number_of_steps=9) + self.mood_slider.grid(row=0, column=1, padx=10, pady=10, sticky="ew") + self.mood_label = ctk.CTkLabel(self.notes_frame, text="5") + self.mood_label.grid(row=0, column=2, padx=5) + self.mood_slider.configure(command=lambda val: self.mood_label.configure(text=str(int(val)))) + + ctk.CTkLabel(self.notes_frame, text="Notes:").grid(row=1, column=0, padx=10, pady=10, sticky="n") + self.notes_entry = ctk.CTkTextbox(self.notes_frame, height=100) + self.notes_entry.grid(row=1, column=1, columnspan=2, padx=10, pady=10, sticky="ew") + + # Save Button + self.save_btn = ctk.CTkButton(self, text="Save Daily Log", command=self.save_log) + self.save_btn.grid(row=4, column=0, columnspan=2, pady=20) + + def load_data(self): + log = self.db.get_daily_log(self.date_str) + if log: + if log['sleep_hours'] is not None: self.sleep_entry.insert(0, format_float(log['sleep_hours'])) + if log['sleep_quality']: + self.sleep_quality_slider.set(log['sleep_quality']) + self.sleep_quality_label.configure(text=str(log['sleep_quality'])) + if log['weight'] is not None: self.weight_entry.insert(0, format_float(log['weight'])) + if log['waist'] is not None: self.waist_entry.insert(0, format_float(log['waist'])) + if log['mood']: + self.mood_slider.set(log['mood']) + self.mood_label.configure(text=str(log['mood'])) + if log['notes']: self.notes_entry.insert("0.0", log['notes']) + + def save_log(self): + # We allow entries even if some fields are empty (parse_float handles this) + # However, if they typed something invalid, we should catch it + s_val = self.sleep_entry.get() + w_val = self.weight_entry.get() + wa_val = self.waist_entry.get() + + sleep = parse_float(s_val) if s_val else None + weight = parse_float(w_val) if w_val else None + waist = parse_float(wa_val) if wa_val else None + + # Validation check: if they entered text but it parsed to None + if (s_val and sleep is None) or (w_val and weight is None) or (wa_val and waist is None): + messagebox.showerror("Error", "Please enter valid numbers (e.g. 75.5 or 75,5)") + return + + data = { + 'sleep_hours': sleep, + 'sleep_quality': int(self.sleep_quality_slider.get()), + 'weight': weight, + 'waist': waist, + 'mood': int(self.mood_slider.get()), + 'notes': self.notes_entry.get("0.0", "end").strip() + } + self.db.save_daily_log(self.date_str, data) + self.save_btn.configure(text="Saved!", fg_color="green") + + # Cancel previous timer if exists + if hasattr(self, '_reset_btn_id'): + self.after_cancel(self._reset_btn_id) + + self._reset_btn_id = self.after(2000, self.reset_save_button) + + def reset_save_button(self): + if self.winfo_exists(): + self.save_btn.configure(text="Save Daily Log", fg_color=["#3B8ED0", "#1F6AA5"]) + + def destroy(self): + if hasattr(self, '_reset_btn_id'): + self.after_cancel(self._reset_btn_id) + super().destroy() diff --git a/ui/dashboard.py b/ui/dashboard.py new file mode 100644 index 0000000..f84db51 --- /dev/null +++ b/ui/dashboard.py @@ -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) \ No newline at end of file diff --git a/ui/stats.py b/ui/stats.py new file mode 100644 index 0000000..313efb6 --- /dev/null +++ b/ui/stats.py @@ -0,0 +1,99 @@ +import customtkinter as ctk +from database import Database +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +import datetime + +class StatsFrame(ctk.CTkFrame): + def __init__(self, master, db: Database): + super().__init__(master) + self.db = db + self.setup_ui() + + def setup_ui(self): + self.grid_columnconfigure(0, weight=1) + + self.header = ctk.CTkLabel(self, text="Progress & Motivation", font=ctk.CTkFont(size=20, weight="bold")) + self.header.grid(row=0, column=0, pady=10, sticky="w") + + # Stats Summary (Streaks, etc.) + self.summary_frame = ctk.CTkFrame(self) + self.summary_frame.grid(row=1, column=0, sticky="ew", pady=10) + + logs = self.db.get_log_history(30) + streak = self.calculate_streak(logs) + + self.streak_label = ctk.CTkLabel(self.summary_frame, text=f"🔥 Current Streak: {streak} Days", font=ctk.CTkFont(size=16)) + self.streak_label.pack(side="left", padx=20, pady=10) + + # Chart Section + self.chart_frame = ctk.CTkFrame(self) + self.chart_frame.grid(row=2, column=0, sticky="nsew", pady=10) + self.chart_frame.grid_columnconfigure(0, weight=1) + + self.plot_weight_waist(logs) + + def calculate_streak(self, logs): + if not logs: return 0 + streak = 0 + today = datetime.date.today() + + # Simple streak calculation based on existing logs + # Checks if logs are consecutive + log_dates = [datetime.datetime.strptime(l['date'], "%Y-%m-%d").date() for l in logs] + log_dates.sort(reverse=True) + + current = today + if log_dates and log_dates[0] < today - datetime.timedelta(days=1): + return 0 # Missed yesterday and today + + for d in log_dates: + if d == current or d == current - datetime.timedelta(days=1): + streak += 1 + current = d + else: + break + return streak + + def plot_weight_waist(self, logs): + if not logs or len(logs) < 2: + ctk.CTkLabel(self.chart_frame, text="Log data for at least 2 days to see charts.").pack(pady=50) + return + + # Prepare data + logs.reverse() # Chronological + dates = [l['date'][5:] for l in logs if l['weight'] or l['waist']] # MM-DD + weights = [l['weight'] for l in logs if l['weight'] or l['waist']] + waists = [l['waist'] for l in logs if l['weight'] or l['waist']] + + if not weights: return + + # Create plot + fig, ax1 = plt.subplots(figsize=(6, 4), dpi=100) + fig.patch.set_facecolor('#2b2b2b') # Matches dark mode + ax1.set_facecolor('#2b2b2b') + + color = 'tab:blue' + ax1.set_xlabel('Date') + ax1.set_ylabel('Weight (kg)', color=color) + ax1.plot(dates, weights, color=color, marker='o', label='Weight') + ax1.tick_params(axis='y', labelcolor=color) + ax1.tick_params(axis='x', rotation=45, colors='white') + ax1.spines['bottom'].set_color('white') + ax1.spines['top'].set_color('none') + ax1.spines['left'].set_color('white') + ax1.spines['right'].set_color('none') + + # Second axis for Waist + ax2 = ax1.twinx() + color = 'tab:red' + ax2.set_ylabel('Waist (cm)', color=color) + ax2.plot(dates, waists, color=color, marker='x', linestyle='--', label='Waist') + ax2.tick_params(axis='y', labelcolor=color) + ax2.spines['right'].set_color('white') + + fig.tight_layout() + + canvas = FigureCanvasTkAgg(fig, master=self.chart_frame) + canvas.draw() + canvas.get_tk_widget().pack(fill="both", expand=True) diff --git a/ui/workout.py b/ui/workout.py new file mode 100644 index 0000000..a8af088 --- /dev/null +++ b/ui/workout.py @@ -0,0 +1,145 @@ +import customtkinter as ctk +from database import Database +from datetime import date +from tkinter import messagebox +from utils import parse_float + +class WorkoutFrame(ctk.CTkFrame): + def __init__(self, master, db: Database, date_str: str): + super().__init__(master) + self.db = db + self.date_str = date_str + self.session_id = None + + self.setup_ui() + self.refresh_workouts() + + def setup_ui(self): + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(2, weight=1) + + # Header + self.header_frame = ctk.CTkFrame(self, fg_color="transparent") + self.header_frame.grid(row=0, column=0, sticky="ew", pady=(0, 20)) + + self.header_label = ctk.CTkLabel(self.header_frame, text=f"Workouts: {self.date_str}", font=ctk.CTkFont(size=20, weight="bold")) + self.header_label.pack(side="left") + + # Log New Set Section + self.input_frame = ctk.CTkFrame(self) + self.input_frame.grid(row=1, column=0, sticky="ew", pady=10, padx=5) + + # Exercise Selection + ctk.CTkLabel(self.input_frame, text="Exercise:").grid(row=0, column=0, padx=10, pady=10) + self.exercises = self.db.get_all_exercises() + self.exercise_names = [e['name'] for e in self.exercises] + self.exercise_var = ctk.StringVar(value=self.exercise_names[0] if self.exercise_names else "") + self.exercise_menu = ctk.CTkOptionMenu(self.input_frame, values=self.exercise_names, variable=self.exercise_var, command=self.update_input_fields) + self.exercise_menu.grid(row=0, column=1, padx=10, pady=10) + + # Variation + ctk.CTkLabel(self.input_frame, text="Variation:").grid(row=0, column=2, padx=10, pady=10) + self.variation_entry = ctk.CTkEntry(self.input_frame, placeholder_text="e.g. Knee, Incline") + self.variation_entry.grid(row=0, column=3, padx=10, pady=10) + + # Reps/Time + self.reps_label = ctk.CTkLabel(self.input_frame, text="Reps:") + self.reps_label.grid(row=1, column=0, padx=10, pady=10) + self.reps_entry = ctk.CTkEntry(self.input_frame, placeholder_text="0") + self.reps_entry.grid(row=1, column=1, padx=10, pady=10) + + # RPE + ctk.CTkLabel(self.input_frame, text="RPE (1-10):").grid(row=1, column=2, padx=10, pady=10) + self.rpe_slider = ctk.CTkSlider(self.input_frame, from_=1, to=10, number_of_steps=9) + self.rpe_slider.grid(row=1, column=3, padx=10, pady=10) + + self.add_btn = ctk.CTkButton(self.input_frame, text="Add Set", command=self.add_set) + self.add_btn.grid(row=1, column=4, padx=20, pady=10) + + # History of today's sets + self.history_label = ctk.CTkLabel(self, text="Today's Sets", font=ctk.CTkFont(size=16, weight="bold")) + self.history_label.grid(row=2, column=0, sticky="w", pady=(20, 5)) + + self.scrollable_frame = ctk.CTkScrollableFrame(self) + self.scrollable_frame.grid(row=3, column=0, sticky="nsew", pady=5) + self.scrollable_frame.grid_columnconfigure(0, weight=1) + + def update_input_fields(self, choice): + exercise = next((e for e in self.exercises if e['name'] == choice), None) + if exercise and exercise['type'] == 'time': + self.reps_label.configure(text="Seconds:") + else: + self.reps_label.configure(text="Reps:") + + def refresh_workouts(self): + # Clear scrollable frame + for widget in self.scrollable_frame.winfo_children(): + widget.destroy() + + workouts = self.db.get_workouts_by_date(self.date_str) + if not workouts: + ctk.CTkLabel(self.scrollable_frame, text="No sets logged for today.").pack(pady=20) + return + + for session in workouts: + for s in session.get('sets', []): + set_str = f"{s['exercise_name']}" + if s['variation']: set_str += f" ({s['variation']})" + + val_type = "reps" if s['reps'] > 0 else "sec" + val = s['reps'] if s['reps'] > 0 else s['duration_seconds'] + + set_detail = f"{val} {val_type} | RPE: {s['rpe']}" + + row = ctk.CTkFrame(self.scrollable_frame) + row.pack(fill="x", pady=2, padx=5) + + # Delete Button + del_btn = ctk.CTkButton(row, text="X", width=30, fg_color="#c0392b", hover_color="#e74c3c", + command=lambda sid=s['id']: self.delete_set(sid)) + del_btn.pack(side="right", padx=5, pady=5) + + ctk.CTkLabel(row, text=set_detail).pack(side="right", padx=10) + ctk.CTkLabel(row, text=set_str, font=ctk.CTkFont(weight="bold")).pack(side="left", padx=10) + + def delete_set(self, set_id): + self.db.delete_set(set_id) + self.refresh_workouts() + + def add_set(self): + if not self.session_id: + # Create session for today if it doesn't exist + # Note: For simplicity, we create one session per day in this view + existing = self.db.get_workouts_by_date(self.date_str) + if existing: + self.session_id = existing[0]['id'] + else: + self.session_id = self.db.create_workout_session(self.date_str) + + exercise = next((e for e in self.exercises if e['name'] == self.exercise_var.get()), None) + if not exercise: return + + val_raw = self.reps_entry.get() + val_parsed = parse_float(val_raw) + + if val_parsed is None and val_raw: + messagebox.showerror("Error", "Please enter a valid number.") + return + + try: + val = int(val_parsed) if val_parsed is not None else 0 + reps = val if exercise['type'] == 'reps' else 0 + duration = val if exercise['type'] == 'time' else 0 + + self.db.add_set( + session_id=self.session_id, + exercise_id=exercise['id'], + reps=reps, + duration=duration, + rpe=int(self.rpe_slider.get()), + variation=self.variation_entry.get() + ) + self.reps_entry.delete(0, 'end') + self.refresh_workouts() + except (ValueError, TypeError): + messagebox.showerror("Error", "Please enter a valid number for Reps/Seconds.") diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..1aab827 --- /dev/null +++ b/utils.py @@ -0,0 +1,72 @@ +import os +import subprocess +from PIL import Image +import customtkinter as ctk + +def parse_float(val_str: str) -> float: + """Safely parse a string to float, handling both '.' and ',' as decimal separators.""" + if not val_str: + return None + clean_val = val_str.replace(',', '.') + try: + return float(clean_val) + except ValueError: + return None + +def format_float(val: float, precision: int = 1) -> str: + """Format a float for display.""" + if val is None: + return "" + return f"{val:.{precision}f}" + +def get_bmi_status(bmi): + if bmi < 16: return "Severely Underweight", "bmi_severely_underweight.svg", "#3498db" + if bmi < 18.5: return "Underweight", "bmi_underweight.svg", "#3498db" + if bmi < 25: return "Normal", "bmi_normal.svg", "#2ecc71" + if bmi < 30: return "Overweight", "bmi_overweight.svg", "#f1c40f" + return "Obese", "bmi_severely_obese.svg", "#e74c3c" + +def get_bmi_icon(bmi: float, size: tuple = (100, 100)) -> ctk.CTkImage: + """ + Returns a CTkImage for the given BMI. + Converts SVG to PNG using rsvg-convert if needed. + """ + if bmi is None: return None + + _, filename, _ = get_bmi_status(bmi) + + base_dir = os.path.dirname(os.path.abspath(__file__)) + assets_dir = os.path.join(base_dir, "assets", "bmi_icons") + cache_dir = os.path.join(assets_dir, "png_cache") + + svg_path = os.path.join(assets_dir, filename) + png_filename = filename.replace(".svg", f"_{size[0]}x{size[1]}.png") + png_path = os.path.join(cache_dir, png_filename) + + if not os.path.exists(svg_path): + print(f"Warning: Icon not found at {svg_path}") + return None + + if not os.path.exists(cache_dir): + os.makedirs(cache_dir) + + # Convert if PNG doesn't exist + if not os.path.exists(png_path): + try: + subprocess.run([ + "rsvg-convert", + "-w", str(size[0]), + "-h", str(size[1]), + "-o", png_path, + svg_path + ], check=True) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + print(f"Error converting SVG: {e}") + return None + + try: + img = Image.open(png_path) + return ctk.CTkImage(light_image=img, dark_image=img, size=size) + except Exception as e: + print(f"Error loading image: {e}") + return None \ No newline at end of file