Reworked 'Levels' for progress

This commit is contained in:
2026-01-10 17:05:43 +01:00
parent 2c99454951
commit febb7ae87b
7 changed files with 241 additions and 14 deletions

BIN
assets/App.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -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."""

114
gamification.py Normal file
View 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

17
main.py
View File

@@ -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()

View File

@@ -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'):

View File

@@ -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)
@@ -117,6 +156,15 @@ class DashboardFrame(ctk.CTkFrame):
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)
date_frame.grid(row=0, column=2, padx=10, sticky="ew")
@@ -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 [])

View File

@@ -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):