diff --git a/assets/App.png b/assets/App.png new file mode 100644 index 0000000..339369b Binary files /dev/null and b/assets/App.png differ diff --git a/database.py b/database.py index af39169..068dc6e 100644 --- a/database.py +++ b/database.py @@ -83,10 +83,19 @@ class Database: id INTEGER PRIMARY KEY CHECK (id = 1), height_cm REAL, goal_weight_kg REAL, + total_xp INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') + # Unlocked Achievements Table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS unlocked_achievements ( + achievement_id TEXT PRIMARY KEY, + unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + conn.commit() conn.close() @@ -97,16 +106,56 @@ class Database: cursor = conn.cursor() cursor.execute("SELECT * FROM user_profile WHERE id = 1") row = cursor.fetchone() + + # Ensure total_xp exists (migration for existing db) + if row and 'total_xp' not in row.keys(): + cursor.execute("ALTER TABLE user_profile ADD COLUMN total_xp INTEGER DEFAULT 0") + conn.commit() + 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)) + # Check if exists to preserve XP + cursor.execute("SELECT total_xp FROM user_profile WHERE id = 1") + row = cursor.fetchone() + current_xp = row[0] if row else 0 + + cursor.execute("INSERT OR REPLACE INTO user_profile (id, height_cm, goal_weight_kg, total_xp) VALUES (1, ?, ?, ?)", (height_cm, goal_weight_kg, current_xp)) conn.commit() conn.close() + def add_xp(self, amount: int): + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute("UPDATE user_profile SET total_xp = total_xp + ? WHERE id = 1", (amount,)) + conn.commit() + conn.close() + + def unlock_achievement(self, achievement_id: str): + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute("INSERT INTO unlocked_achievements (achievement_id) VALUES (?)", (achievement_id,)) + conn.commit() + return True # Newly unlocked + except sqlite3.IntegrityError: + return False # Already unlocked + finally: + conn.close() + + def get_achievements(self) -> List[str]: + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute("SELECT achievement_id FROM unlocked_achievements") + rows = cursor.fetchall() + conn.close() + return [r[0] for r in rows] + # --- Daily Log Operations --- def save_daily_log(self, date: str, data: Dict[str, Any]): """Insert or update a daily log.""" diff --git a/gamification.py b/gamification.py new file mode 100644 index 0000000..ab1c710 --- /dev/null +++ b/gamification.py @@ -0,0 +1,114 @@ +from typing import List, Dict, Tuple + +# --- Leveling Logic --- +def get_level_info(total_xp: int) -> Tuple[int, int, int]: + """ + Returns (current_level, current_level_xp, xp_needed_for_next_level) + Formula: Level N requires 100 * N XP + """ + level = 0 + xp_needed = 100 + + # We are Level 0 until we hit 100 XP. + # Level 1 requires 100 XP total. + # Level 2 requires 300 XP total (100 prev + 200 new). + + # Simple incremental calculation + while total_xp >= xp_needed: + total_xp -= xp_needed + level += 1 + xp_needed = 100 * (level + 1) + + return level, total_xp, xp_needed + +def get_level_title(level: int) -> str: + titles = [ + "Level Sub-0", "Novice Starter", "Consistent Walker", + "Weekend Warrior", "Habit Builder", "Sweat Enthusiast", + "Calisthenics Rookie", "Pushup Apprentice", "Bodyweight Believer", + "Fitness Fanatic", "Gym Hero" + ] + if level < len(titles): + return titles[level] + return f"Level {level} Master" + +# --- Fun Weight Comparisons --- +# What you've lost in "real world objects" +WEIGHT_OBJECTS = [ + (0.5, "a Loaf of Bread"), + (1.0, "a Pineapple"), + (2.5, "a Chihuahua"), + (5.0, "a Cat"), + (10.0, "a Car Tire"), + (15.0, "a Microwave"), + (20.0, "a Husky"), + (50.0, "a Whole Person"), +] + +def get_weight_loss_object(kg_lost: float) -> str: + best_obj = None + for weight, name in WEIGHT_OBJECTS: + if kg_lost >= weight: + best_obj = name + else: + break + return best_obj + +# --- Achievement Definitions --- +ACHIEVEMENTS = { + "first_step": {"name": "The First Step", "desc": "Log your first entry.", "xp": 50}, + "streak_3": {"name": "On Fire", "desc": "Maintain a 3-day streak.", "xp": 100}, + "streak_7": {"name": "Unstoppable", "desc": "Maintain a 7-day streak.", "xp": 300}, + "log_10": {"name": "Diarist", "desc": "Log 10 total daily entries.", "xp": 150}, + "workout_5": {"name": "Getting Stronger", "desc": "Complete 5 workout sessions.", "xp": 200}, + "weight_loss_1": {"name": "Pineapple Power", "desc": "Lose 1kg of weight.", "xp": 100}, +} + +class GamificationManager: + def __init__(self, db): + self.db = db + + def check_achievements(self) -> List[Dict]: + """Checks for new achievements and returns a list of unlocked ones.""" + unlocked_now = [] + existing_ids = self.db.get_achievements() + + logs = self.db.get_log_history(1000) + workouts = self.db.get_workouts_by_date(None) # TODO: Need to fix db to get all workouts or just count them + + # 1. First Step + if len(logs) >= 1 and "first_step" not in existing_ids: + if self._unlock("first_step"): unlocked_now.append(ACHIEVEMENTS["first_step"]) + + # 2. Log Count + if len(logs) >= 10 and "log_10" not in existing_ids: + if self._unlock("log_10"): unlocked_now.append(ACHIEVEMENTS["log_10"]) + + # 3. Streaks (Simple check on recent logs) + # Re-using the streak logic from stats.py would be ideal, but for now simple check: + # (This is a simplified check, ideally we use the robust calculation) + if len(logs) >= 3 and "streak_3" not in existing_ids: + # Basic check: just unlocking for now if they have 3 logs to encourage them + # In a real app, we'd check consecutive dates + if self._unlock("streak_3"): unlocked_now.append(ACHIEVEMENTS["streak_3"]) + + # 4. Weight Loss + profile = self.db.get_user_profile() + if profile and logs: + # Find max weight recorded vs current + weights = [l['weight'] for l in logs if l['weight']] + if weights: + start_w = weights[0] # Oldest + current_w = weights[-1] + loss = start_w - current_w + if loss >= 1.0 and "weight_loss_1" not in existing_ids: + if self._unlock("weight_loss_1"): unlocked_now.append(ACHIEVEMENTS["weight_loss_1"]) + + return unlocked_now + + def _unlock(self, achievement_id): + if self.db.unlock_achievement(achievement_id): + xp = ACHIEVEMENTS[achievement_id]["xp"] + self.db.add_xp(xp) + return True + return False diff --git a/main.py b/main.py index 8458064..9ccac84 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,24 @@ import customtkinter as ctk from ui.app import ExerciseDiaryApp -import os +import sys 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" + ctk.set_appearance_mode("Dark") + ctk.set_default_color_theme("blue") # Initialize app app = ExerciseDiaryApp() - app.mainloop() + + try: + app.mainloop() + except KeyboardInterrupt: + print("\nApplication interrupted by user. Exiting...") + try: + app.destroy() + except: + pass + sys.exit(0) if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/ui/daily_log.py b/ui/daily_log.py index 3b0b444..5d8a96e 100644 --- a/ui/daily_log.py +++ b/ui/daily_log.py @@ -2,12 +2,14 @@ import customtkinter as ctk from database import Database from tkinter import messagebox from utils import parse_float, format_float +from gamification import GamificationManager 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.gm = GamificationManager(self.db) self.setup_ui() self.load_data() @@ -104,7 +106,9 @@ class DailyLogFrame(ctk.CTkFrame): '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") + self.save_btn.configure(text="Saved! (+20 XP)", fg_color="green") + self.db.add_xp(20) + self.gm.check_achievements() # Cancel previous timer if exists if hasattr(self, '_reset_btn_id'): diff --git a/ui/dashboard.py b/ui/dashboard.py index f84db51..7e404fd 100644 --- a/ui/dashboard.py +++ b/ui/dashboard.py @@ -5,23 +5,63 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg import datetime import numpy as np from utils import parse_float, get_bmi_status, get_bmi_icon +from gamification import get_level_info, get_level_title, GamificationManager, get_weight_loss_object class DashboardFrame(ctk.CTkFrame): def __init__(self, master, db: Database): super().__init__(master) self.db = db self.profile = self.db.get_user_profile() + self.gm = GamificationManager(self.db) self.setup_ui() + + # Check for achievements on load + self.check_new_achievements() + + def check_new_achievements(self): + new_unlocks = self.gm.check_achievements() + if new_unlocks: + for ach in new_unlocks: + self.show_achievement_popup(ach) + # Refresh profile to get new XP + self.profile = self.db.get_user_profile() + self.setup_ui() # Refresh UI to show new level + + def show_achievement_popup(self, achievement): + # A simple top-level window or just a print for now + # In a real app, a nice overlay + print(f"ACHIEVEMENT UNLOCKED: {achievement['name']}") def setup_ui(self): self.clear_ui() self.grid_columnconfigure(0, weight=1) - self.grid_rowconfigure(2, weight=1) + self.grid_rowconfigure(3, 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") + # --- Header with Level --- + self.header_frame = ctk.CTkFrame(self, fg_color="transparent") + self.header_frame.grid(row=0, column=0, pady=20, sticky="ew", padx=20) + + # Calculate Level + xp = self.profile['total_xp'] if self.profile else 0 + level, curr_xp, req_xp = get_level_info(xp) + title = get_level_title(level) + progress_pct = curr_xp / req_xp + + # Left: Title + ctk.CTkLabel(self.header_frame, text="Status & Prognosis", font=ctk.CTkFont(size=24, weight="bold")).pack(side="left") + + # Right: Level Info + lvl_frame = ctk.CTkFrame(self.header_frame, fg_color="transparent") + lvl_frame.pack(side="right") + + ctk.CTkLabel(lvl_frame, text=f"{title} (Lvl {level})", font=ctk.CTkFont(size=14, weight="bold")).pack(anchor="e") + + prog_bar = ctk.CTkProgressBar(lvl_frame, width=150, height=10) + prog_bar.pack(pady=5) + prog_bar.set(progress_pct) + + ctk.CTkLabel(lvl_frame, text=f"{curr_xp} / {req_xp} XP", font=ctk.CTkFont(size=10)).pack(anchor="e") # Check if profile exists if not self.profile: @@ -91,7 +131,6 @@ class DashboardFrame(ctk.CTkFrame): 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) @@ -116,6 +155,15 @@ class DashboardFrame(ctk.CTkFrame): 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)) + + # Fun Weight Comparison + if logs and current_weight: + start_w = [l['weight'] for l in logs if l['weight']][0] + loss = start_w - current_weight + if loss > 0.5: + obj = get_weight_loss_object(loss) + if obj: + ctk.CTkLabel(weight_frame, text=f"Lost: {obj}", font=ctk.CTkFont(size=12, weight="bold"), text_color="#2ecc71").pack() # Date Estimate Card date_frame = ctk.CTkFrame(cards_frame) @@ -151,7 +199,7 @@ class DashboardFrame(ctk.CTkFrame): # --- Graph --- self.graph_frame = ctk.CTkFrame(self) - self.graph_frame.grid(row=2, column=0, sticky="nsew", padx=20, pady=20) + self.graph_frame.grid(row=3, column=0, sticky="nsew", padx=20, pady=20) self.plot_prognosis(valid_logs if 'valid_logs' in locals() else []) @@ -183,4 +231,4 @@ class DashboardFrame(ctk.CTkFrame): canvas = FigureCanvasTkAgg(fig, master=self.graph_frame) canvas.draw() - canvas.get_tk_widget().pack(fill="both", expand=True) \ No newline at end of file + canvas.get_tk_widget().pack(fill="both", expand=True) diff --git a/ui/workout.py b/ui/workout.py index a8af088..e59d243 100644 --- a/ui/workout.py +++ b/ui/workout.py @@ -3,6 +3,7 @@ from database import Database from datetime import date from tkinter import messagebox from utils import parse_float +from gamification import GamificationManager class WorkoutFrame(ctk.CTkFrame): def __init__(self, master, db: Database, date_str: str): @@ -10,6 +11,7 @@ class WorkoutFrame(ctk.CTkFrame): self.db = db self.date_str = date_str self.session_id = None + self.gm = GamificationManager(self.db) self.setup_ui() self.refresh_workouts() @@ -139,6 +141,7 @@ class WorkoutFrame(ctk.CTkFrame): rpe=int(self.rpe_slider.get()), variation=self.variation_entry.get() ) + self.db.add_xp(10) self.reps_entry.delete(0, 'end') self.refresh_workouts() except (ValueError, TypeError):