Reworked 'Levels' for progress
This commit is contained in:
BIN
assets/App.png
Normal file
BIN
assets/App.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
51
database.py
51
database.py
@@ -83,10 +83,19 @@ class Database:
|
|||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
height_cm REAL,
|
height_cm REAL,
|
||||||
goal_weight_kg REAL,
|
goal_weight_kg REAL,
|
||||||
|
total_xp INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -97,16 +106,56 @@ class Database:
|
|||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("SELECT * FROM user_profile WHERE id = 1")
|
cursor.execute("SELECT * FROM user_profile WHERE id = 1")
|
||||||
row = cursor.fetchone()
|
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()
|
conn.close()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
def save_user_profile(self, height_cm: float, goal_weight_kg: float):
|
def save_user_profile(self, height_cm: float, goal_weight_kg: float):
|
||||||
conn = self.get_connection()
|
conn = self.get_connection()
|
||||||
cursor = conn.cursor()
|
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.commit()
|
||||||
conn.close()
|
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 ---
|
# --- Daily Log Operations ---
|
||||||
def save_daily_log(self, date: str, data: Dict[str, Any]):
|
def save_daily_log(self, date: str, data: Dict[str, Any]):
|
||||||
"""Insert or update a daily log."""
|
"""Insert or update a daily log."""
|
||||||
|
|||||||
114
gamification.py
Normal file
114
gamification.py
Normal file
@@ -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
|
||||||
19
main.py
19
main.py
@@ -1,15 +1,24 @@
|
|||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
from ui.app import ExerciseDiaryApp
|
from ui.app import ExerciseDiaryApp
|
||||||
import os
|
import sys
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Set appearance
|
# Set appearance
|
||||||
ctk.set_appearance_mode("Dark") # Modes: "System" (standard), "Dark", "Light"
|
ctk.set_appearance_mode("Dark")
|
||||||
ctk.set_default_color_theme("blue") # Themes: "blue" (standard), "green", "dark-blue"
|
ctk.set_default_color_theme("blue")
|
||||||
|
|
||||||
# Initialize app
|
# Initialize app
|
||||||
app = ExerciseDiaryApp()
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
@@ -2,12 +2,14 @@ import customtkinter as ctk
|
|||||||
from database import Database
|
from database import Database
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
from utils import parse_float, format_float
|
from utils import parse_float, format_float
|
||||||
|
from gamification import GamificationManager
|
||||||
|
|
||||||
class DailyLogFrame(ctk.CTkFrame):
|
class DailyLogFrame(ctk.CTkFrame):
|
||||||
def __init__(self, master, db: Database, date_str: str):
|
def __init__(self, master, db: Database, date_str: str):
|
||||||
super().__init__(master)
|
super().__init__(master)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.date_str = date_str
|
self.date_str = date_str
|
||||||
|
self.gm = GamificationManager(self.db)
|
||||||
|
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
self.load_data()
|
self.load_data()
|
||||||
@@ -104,7 +106,9 @@ class DailyLogFrame(ctk.CTkFrame):
|
|||||||
'notes': self.notes_entry.get("0.0", "end").strip()
|
'notes': self.notes_entry.get("0.0", "end").strip()
|
||||||
}
|
}
|
||||||
self.db.save_daily_log(self.date_str, data)
|
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
|
# Cancel previous timer if exists
|
||||||
if hasattr(self, '_reset_btn_id'):
|
if hasattr(self, '_reset_btn_id'):
|
||||||
|
|||||||
@@ -5,23 +5,63 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
|||||||
import datetime
|
import datetime
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from utils import parse_float, get_bmi_status, get_bmi_icon
|
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):
|
class DashboardFrame(ctk.CTkFrame):
|
||||||
def __init__(self, master, db: Database):
|
def __init__(self, master, db: Database):
|
||||||
super().__init__(master)
|
super().__init__(master)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.profile = self.db.get_user_profile()
|
self.profile = self.db.get_user_profile()
|
||||||
|
self.gm = GamificationManager(self.db)
|
||||||
self.setup_ui()
|
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):
|
def setup_ui(self):
|
||||||
self.clear_ui()
|
self.clear_ui()
|
||||||
|
|
||||||
self.grid_columnconfigure(0, weight=1)
|
self.grid_columnconfigure(0, weight=1)
|
||||||
self.grid_rowconfigure(2, weight=1)
|
self.grid_rowconfigure(3, weight=1)
|
||||||
|
|
||||||
# Header
|
# --- Header with Level ---
|
||||||
self.header = ctk.CTkLabel(self, text="Status & Prognosis", font=ctk.CTkFont(size=24, weight="bold"))
|
self.header_frame = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.header.grid(row=0, column=0, pady=20, sticky="n")
|
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
|
# Check if profile exists
|
||||||
if not self.profile:
|
if not self.profile:
|
||||||
@@ -91,7 +131,6 @@ class DashboardFrame(ctk.CTkFrame):
|
|||||||
bmi_icon_img = get_bmi_icon(bmi, size=(80, 80))
|
bmi_icon_img = get_bmi_icon(bmi, size=(80, 80))
|
||||||
|
|
||||||
# Layout BMI Card
|
# Layout BMI Card
|
||||||
# Left: Icon, Right: Text
|
|
||||||
bmi_inner = ctk.CTkFrame(bmi_frame, fg_color="transparent")
|
bmi_inner = ctk.CTkFrame(bmi_frame, fg_color="transparent")
|
||||||
bmi_inner.pack(pady=10, padx=10)
|
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="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=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))
|
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 Estimate Card
|
||||||
date_frame = ctk.CTkFrame(cards_frame)
|
date_frame = ctk.CTkFrame(cards_frame)
|
||||||
@@ -151,7 +199,7 @@ class DashboardFrame(ctk.CTkFrame):
|
|||||||
|
|
||||||
# --- Graph ---
|
# --- Graph ---
|
||||||
self.graph_frame = ctk.CTkFrame(self)
|
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 [])
|
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 = FigureCanvasTkAgg(fig, master=self.graph_frame)
|
||||||
canvas.draw()
|
canvas.draw()
|
||||||
canvas.get_tk_widget().pack(fill="both", expand=True)
|
canvas.get_tk_widget().pack(fill="both", expand=True)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from database import Database
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
from utils import parse_float
|
from utils import parse_float
|
||||||
|
from gamification import GamificationManager
|
||||||
|
|
||||||
class WorkoutFrame(ctk.CTkFrame):
|
class WorkoutFrame(ctk.CTkFrame):
|
||||||
def __init__(self, master, db: Database, date_str: str):
|
def __init__(self, master, db: Database, date_str: str):
|
||||||
@@ -10,6 +11,7 @@ class WorkoutFrame(ctk.CTkFrame):
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.date_str = date_str
|
self.date_str = date_str
|
||||||
self.session_id = None
|
self.session_id = None
|
||||||
|
self.gm = GamificationManager(self.db)
|
||||||
|
|
||||||
self.setup_ui()
|
self.setup_ui()
|
||||||
self.refresh_workouts()
|
self.refresh_workouts()
|
||||||
@@ -139,6 +141,7 @@ class WorkoutFrame(ctk.CTkFrame):
|
|||||||
rpe=int(self.rpe_slider.get()),
|
rpe=int(self.rpe_slider.get()),
|
||||||
variation=self.variation_entry.get()
|
variation=self.variation_entry.get()
|
||||||
)
|
)
|
||||||
|
self.db.add_xp(10)
|
||||||
self.reps_entry.delete(0, 'end')
|
self.reps_entry.delete(0, 'end')
|
||||||
self.refresh_workouts()
|
self.refresh_workouts()
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|||||||
Reference in New Issue
Block a user