Initial commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
49
DEVELOPMENT_PLAN.md
Normal file
49
DEVELOPMENT_PLAN.md
Normal file
@@ -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.
|
||||||
22
README.md
Normal file
22
README.md
Normal file
@@ -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
|
||||||
10
assets/bmi_icons/bmi_normal.svg
Normal file
10
assets/bmi_icons/bmi_normal.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<rect x="0" y="0" width="100" height="100" rx="10" fill="#f0f0f0" />
|
||||||
|
<!-- Head -->
|
||||||
|
<circle cx="50" cy="20" r="10" fill="#4caf50" />
|
||||||
|
<!-- Body -->
|
||||||
|
<rect x="35" y="32" width="30" height="40" rx="8" fill="#4caf50" />
|
||||||
|
<!-- Legs -->
|
||||||
|
<rect x="38" y="70" width="10" height="20" rx="2" fill="#4caf50" />
|
||||||
|
<rect x="52" y="70" width="10" height="20" rx="2" fill="#4caf50" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 474 B |
10
assets/bmi_icons/bmi_overweight.svg
Normal file
10
assets/bmi_icons/bmi_overweight.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<rect x="0" y="0" width="100" height="100" rx="10" fill="#f0f0f0" />
|
||||||
|
<!-- Head -->
|
||||||
|
<circle cx="50" cy="20" r="10" fill="#ff9800" />
|
||||||
|
<!-- Body -->
|
||||||
|
<rect x="30" y="32" width="40" height="40" rx="10" fill="#ff9800" />
|
||||||
|
<!-- Legs -->
|
||||||
|
<rect x="35" y="70" width="12" height="20" rx="2" fill="#ff9800" />
|
||||||
|
<rect x="53" y="70" width="12" height="20" rx="2" fill="#ff9800" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 475 B |
10
assets/bmi_icons/bmi_severely_obese.svg
Normal file
10
assets/bmi_icons/bmi_severely_obese.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<rect x="0" y="0" width="100" height="100" rx="10" fill="#f0f0f0" />
|
||||||
|
<!-- Head -->
|
||||||
|
<circle cx="50" cy="20" r="10" fill="#f44336" />
|
||||||
|
<!-- Body -->
|
||||||
|
<rect x="20" y="32" width="60" height="40" rx="15" fill="#f44336" />
|
||||||
|
<!-- Legs -->
|
||||||
|
<rect x="30" y="70" width="15" height="20" rx="2" fill="#f44336" />
|
||||||
|
<rect x="55" y="70" width="15" height="20" rx="2" fill="#f44336" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 475 B |
10
assets/bmi_icons/bmi_severely_underweight.svg
Normal file
10
assets/bmi_icons/bmi_severely_underweight.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<rect x="0" y="0" width="100" height="100" rx="10" fill="#f0f0f0" />
|
||||||
|
<!-- Head -->
|
||||||
|
<circle cx="50" cy="20" r="10" fill="#64b5f6" />
|
||||||
|
<!-- Body -->
|
||||||
|
<rect x="42" y="32" width="16" height="40" rx="5" fill="#64b5f6" />
|
||||||
|
<!-- Legs -->
|
||||||
|
<rect x="42" y="70" width="6" height="20" rx="2" fill="#64b5f6" />
|
||||||
|
<rect x="52" y="70" width="6" height="20" rx="2" fill="#64b5f6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 472 B |
10
assets/bmi_icons/bmi_underweight.svg
Normal file
10
assets/bmi_icons/bmi_underweight.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||||
|
<rect x="0" y="0" width="100" height="100" rx="10" fill="#f0f0f0" />
|
||||||
|
<!-- Head -->
|
||||||
|
<circle cx="50" cy="20" r="10" fill="#2196f3" />
|
||||||
|
<!-- Body -->
|
||||||
|
<rect x="40" y="32" width="20" height="40" rx="5" fill="#2196f3" />
|
||||||
|
<!-- Legs -->
|
||||||
|
<rect x="40" y="70" width="8" height="20" rx="2" fill="#2196f3" />
|
||||||
|
<rect x="52" y="70" width="8" height="20" rx="2" fill="#2196f3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 472 B |
BIN
assets/bmi_icons/png_cache/bmi_severely_obese_80x80.png
Normal file
BIN
assets/bmi_icons/png_cache/bmi_severely_obese_80x80.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
223
database.py
Normal file
223
database.py
Normal file
@@ -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
|
||||||
15
main.py
Normal file
15
main.py
Normal file
@@ -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()
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
customtkinter
|
||||||
|
matplotlib
|
||||||
|
packaging
|
||||||
|
pillow
|
||||||
72
ui/app.py
Normal file
72
ui/app.py
Normal file
@@ -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)
|
||||||
122
ui/daily_log.py
Normal file
122
ui/daily_log.py
Normal file
@@ -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()
|
||||||
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)
|
||||||
99
ui/stats.py
Normal file
99
ui/stats.py
Normal file
@@ -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)
|
||||||
145
ui/workout.py
Normal file
145
ui/workout.py
Normal file
@@ -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.")
|
||||||
72
utils.py
Normal file
72
utils.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user