screen-locker/screen_locker/screen_lock.py

1057 lines
36 KiB
Python
Executable File

#!/usr/bin/env python3
"""Screen locker with workout verification for Arch Linux / i3wm.
Requires user to log their workout to unlock the screen.
"""
from datetime import datetime, timezone
import json
import logging
from pathlib import Path
import subprocess
import sys
import tkinter as tk
_logger = logging.getLogger(__name__)
# Validation limits for workout data
MAX_DISTANCE_KM = 100
MAX_TIME_MINUTES = 600
MAX_PACE_MIN_PER_KM = 20
MIN_EXERCISE_NAME_LEN = 3
MAX_SETS = 20
MAX_REPS = 100
MAX_WEIGHT_KG = 500
SICK_LOCKOUT_SECONDS = 120 # 2 minutes wait when sick
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
# Helper script path (relative to this file)
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
# State file to track sick day usage and original config values
SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json"
class ScreenLocker:
"""Screen locker that requires workout logging to unlock."""
def __init__(self, *, demo_mode: bool = True) -> None:
"""Initialize screen locker with optional demo mode."""
# Set up log file path
script_dir = Path(__file__).resolve().parent
self.log_file = script_dir / "workout_log.json"
# Check if already logged today
if self.has_logged_today():
_logger.info("Workout already logged today. Skipping screen lock.")
sys.exit(0)
self.root = tk.Tk()
self.root.title("Workout Locker" + (" [DEMO MODE]" if demo_mode else ""))
self.demo_mode = demo_mode
self.lockout_time = (
10 if demo_mode else 1800
) # 10 seconds for demo, 30 minutes for production
self.workout_data: dict[str, str] = {}
# Get total screen dimensions across all monitors
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
# Override redirect to bypass window manager (needed for multi-monitor spanning)
self.root.overrideredirect(True)
# Position window at 0,0 and span all monitors
self.root.geometry(f"{screen_width}x{screen_height}+0+0")
# Make window fullscreen and on top
self.root.attributes("-fullscreen", True)
self.root.attributes("-topmost", True)
self.root.configure(bg="#1a1a1a", cursor="arrow")
if demo_mode:
# Demo mode: only close button allowed
# Add close button in top-left corner
close_btn = tk.Button(
self.root,
text="✕ Close Demo",
font=("Arial", 12),
bg="#ff4444",
fg="white",
command=self.close,
cursor="hand2",
)
close_btn.place(x=10, y=10)
# Create main container
self.container = tk.Frame(self.root, bg="#1a1a1a")
self.container.place(relx=0.5, rely=0.5, anchor="center")
# Start with initial question
self.ask_workout_done()
# Force window to update and grab input after everything is set up
self.root.update_idletasks()
self.root.focus_force()
self.root.grab_set_global()
def clear_container(self) -> None:
"""Remove all widgets from the main container."""
for widget in self.container.winfo_children():
widget.destroy()
def ask_workout_done(self) -> None:
"""Display the initial workout question dialog."""
self.clear_container()
question = tk.Label(
self.container,
text="Did you work out today?",
font=("Arial", 36, "bold"),
fg="white",
bg="#1a1a1a",
)
question.pack(pady=30)
button_frame = tk.Frame(self.container, bg="#1a1a1a")
button_frame.pack(pady=20)
yes_btn = tk.Button(
button_frame,
text="YES",
font=("Arial", 24, "bold"),
bg="#00aa00",
fg="white",
width=10,
command=self.ask_workout_type,
cursor="hand2" if self.demo_mode else "",
)
yes_btn.pack(side="left", padx=20)
no_btn = tk.Button(
button_frame,
text="NO",
font=("Arial", 24, "bold"),
bg="#aa0000",
fg="white",
width=10,
command=self.ask_if_sick,
cursor="hand2" if self.demo_mode else "",
)
no_btn.pack(side="left", padx=20)
def ask_if_sick(self) -> None:
"""Display sick day question dialog."""
self.clear_container()
question = tk.Label(
self.container,
text="Are you sick?",
font=("Arial", 36, "bold"),
fg="white",
bg="#1a1a1a",
)
question.pack(pady=30)
info_label = tk.Label(
self.container,
text="If yes, shutdown time will be moved 1.5 hours earlier",
font=("Arial", 18),
fg="#ffaa00",
bg="#1a1a1a",
)
info_label.pack(pady=10)
button_frame = tk.Frame(self.container, bg="#1a1a1a")
button_frame.pack(pady=20)
yes_btn = tk.Button(
button_frame,
text="YES (sick)",
font=("Arial", 24, "bold"),
bg="#cc6600",
fg="white",
width=12,
command=self.handle_sick_day,
cursor="hand2" if self.demo_mode else "",
)
yes_btn.pack(side="left", padx=20)
no_btn = tk.Button(
button_frame,
text="NO",
font=("Arial", 24, "bold"),
bg="#aa0000",
fg="white",
width=12,
command=self.lockout,
cursor="hand2" if self.demo_mode else "",
)
no_btn.pack(side="left", padx=20)
def handle_sick_day(self) -> None:
"""Handle sick day: adjust shutdown time and start 2-minute wait."""
self.clear_container()
# Check if sick mode was already used today (time already adjusted)
already_adjusted_today = self._sick_mode_used_today()
if already_adjusted_today:
# Already adjusted today, just show status and proceed to wait
status_text = "Shutdown time already adjusted today"
status_color = "#ffaa00"
else:
# First sick mode use today - adjust the shutdown time
adjustment_success = self._adjust_shutdown_time_earlier()
if adjustment_success:
status_text = (
"Shutdown time moved 1.5 hours earlier ✓\n(Will revert tomorrow)"
)
status_color = "#00aa00"
else:
status_text = "Could not adjust shutdown time (check permissions)"
status_color = "#ff4444"
title = tk.Label(
self.container,
text="Sick Day Mode",
font=("Arial", 36, "bold"),
fg="#cc6600",
bg="#1a1a1a",
)
title.pack(pady=20)
status_label = tk.Label(
self.container,
text=status_text,
font=("Arial", 18),
fg=status_color,
bg="#1a1a1a",
)
status_label.pack(pady=10)
wait_label = tk.Label(
self.container,
text="Please wait 2 minutes before unlocking...",
font=("Arial", 24),
fg="white",
bg="#1a1a1a",
)
wait_label.pack(pady=20)
self.sick_countdown_label = tk.Label(
self.container,
text=str(SICK_LOCKOUT_SECONDS),
font=("Arial", 80, "bold"),
fg="white",
bg="#1a1a1a",
)
self.sick_countdown_label.pack(pady=30)
self.sick_remaining_time = SICK_LOCKOUT_SECONDS
self._update_sick_countdown()
def _update_sick_countdown(self) -> None:
"""Update the sick day countdown timer."""
if self.sick_remaining_time > 0:
self.sick_countdown_label.config(text=str(self.sick_remaining_time))
self.sick_remaining_time -= 1
self.root.after(1000, self._update_sick_countdown)
else:
# Record sick day and unlock
self.workout_data["type"] = "sick_day"
self.workout_data["note"] = "Sick day - shutdown moved earlier"
self.unlock_screen()
def _adjust_shutdown_time_earlier(self) -> bool:
"""Adjust shutdown schedule 1.5 hours earlier (stricter).
This can only be used once per day. Original values are saved and
automatically restored when checked the next day.
Returns True if successful, False otherwise.
"""
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
# Restore original values if there's a state from a previous day
self._restore_original_config_if_needed()
# Check if sick mode was already used today (after potential restore)
if self._sick_mode_used_today():
_logger.warning("Sick mode already used today")
return False
try:
# Read current config
config_values = self._read_shutdown_config()
if config_values is None:
return False
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
# Save original values FIRST before any modification
if not self._save_sick_day_state(today, mon_wed_hour, thu_sun_hour):
_logger.error("Failed to save state - aborting adjustment")
return False
# Move shutdown times 1 hour earlier
new_mon_wed = mon_wed_hour - 1
new_thu_sun = thu_sun_hour - 1
# Ensure we don't go below reasonable hours (e.g., not before 18:00)
new_mon_wed = max(18, new_mon_wed)
new_thu_sun = max(18, new_thu_sun)
# Write new config
return self._write_shutdown_config(
new_mon_wed, new_thu_sun, morning_end_hour
)
except (OSError, ValueError) as e:
_logger.warning("Failed to adjust shutdown time: %s", e)
return False
def _sick_mode_used_today(self) -> bool:
"""Check if sick mode was already used today."""
if not SICK_DAY_STATE_FILE.exists():
return False
try:
with SICK_DAY_STATE_FILE.open() as f:
state = json.load(f)
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
return state.get("date") == today
except (OSError, json.JSONDecodeError):
return False
def _save_sick_day_state(
self, date: str, orig_mon_wed: int, orig_thu_sun: int
) -> bool:
"""Save sick day state with original config values.
Returns True if saved successfully, False otherwise.
"""
state = {
"date": date,
"original_mon_wed_hour": orig_mon_wed,
"original_thu_sun_hour": orig_thu_sun,
}
try:
with SICK_DAY_STATE_FILE.open("w") as f:
json.dump(state, f, indent=2)
except OSError as e:
_logger.warning("Failed to save sick day state: %s", e)
return False
_logger.info("Saved sick day state for %s", date)
return True
def _restore_original_config_if_needed(self) -> None:
"""Restore original config values if sick day state is from a previous day."""
if not SICK_DAY_STATE_FILE.exists():
return
try:
with SICK_DAY_STATE_FILE.open() as f:
state = json.load(f)
state_date = state.get("date")
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
# Only restore if state is from a previous day
if state_date and state_date != today:
orig_mon_wed = state.get("original_mon_wed_hour")
orig_thu_sun = state.get("original_thu_sun_hour")
if orig_mon_wed is not None and orig_thu_sun is not None:
# Read current morning end hour
config_values = self._read_shutdown_config()
if config_values:
_, _, morning_end_hour = config_values
_logger.info(
"Restoring original shutdown config from %s", state_date
)
self._write_shutdown_config(
orig_mon_wed, orig_thu_sun, morning_end_hour, restore=True
)
# Remove stale state file
SICK_DAY_STATE_FILE.unlink()
_logger.info("Removed stale sick day state from %s", state_date)
except (OSError, json.JSONDecodeError) as e:
_logger.warning("Error checking sick day state: %s", e)
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
"""Read current shutdown config values.
Returns tuple of (mon_wed_hour, thu_sun_hour, morning_end_hour) or None.
"""
if not SHUTDOWN_CONFIG_FILE.exists():
_logger.warning("Shutdown config file not found: %s", SHUTDOWN_CONFIG_FILE)
return None
mon_wed_hour = None
thu_sun_hour = None
morning_end_hour = None
with SHUTDOWN_CONFIG_FILE.open() as f:
for config_line in f:
stripped_line = config_line.strip()
if stripped_line.startswith("MON_WED_HOUR="):
mon_wed_hour = int(stripped_line.split("=")[1])
elif stripped_line.startswith("THU_SUN_HOUR="):
thu_sun_hour = int(stripped_line.split("=")[1])
elif stripped_line.startswith("MORNING_END_HOUR="):
morning_end_hour = int(stripped_line.split("=")[1])
if mon_wed_hour is None or thu_sun_hour is None or morning_end_hour is None:
_logger.warning("Shutdown config missing required values")
return None
return (mon_wed_hour, thu_sun_hour, morning_end_hour)
def _write_shutdown_config(
self,
mon_wed_hour: int,
thu_sun_hour: int,
morning_end_hour: int,
*,
restore: bool = False,
) -> bool:
"""Write new shutdown config values using helper script.
Args:
mon_wed_hour: Shutdown hour for Monday-Wednesday.
thu_sun_hour: Shutdown hour for Thursday-Sunday.
morning_end_hour: Morning end hour.
restore: If True, allows restoring to later times (for reverting sick day).
Returns True if successful, False otherwise.
"""
if not ADJUST_SHUTDOWN_SCRIPT.exists():
_logger.warning(
"Adjust shutdown script not found: %s", ADJUST_SHUTDOWN_SCRIPT
)
return False
cmd = ["/usr/bin/sudo", str(ADJUST_SHUTDOWN_SCRIPT)]
if restore:
cmd.append("--restore")
cmd.extend([str(mon_wed_hour), str(thu_sun_hour), str(morning_end_hour)])
try:
result = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
)
except subprocess.SubprocessError as e:
_logger.warning("Failed to adjust shutdown config: %s", e)
return False
_logger.info(
"Adjusted shutdown hours: Mon-Wed=%d, Thu-Sun=%d. Output: %s",
mon_wed_hour,
thu_sun_hour,
result.stdout.strip(),
)
return True
return True
def lockout(self) -> None:
"""Display lockout screen with countdown timer."""
self.clear_container()
self.lockout_label = tk.Label(
self.container,
text=f"Go work out!\nLocked for {self.lockout_time} seconds",
font=("Arial", 48, "bold"),
fg="#ff4444",
bg="#1a1a1a",
)
self.lockout_label.pack(pady=30)
self.countdown_label = tk.Label(
self.container,
text=str(self.lockout_time),
font=("Arial", 120, "bold"),
fg="white",
bg="#1a1a1a",
)
self.countdown_label.pack(pady=30)
self.remaining_time = self.lockout_time
self.update_lockout_countdown()
def update_lockout_countdown(self) -> None:
"""Update the lockout countdown timer display."""
if self.remaining_time > 0:
self.countdown_label.config(text=str(self.remaining_time))
self.remaining_time -= 1
self.root.after(1000, self.update_lockout_countdown)
else:
self.ask_workout_done()
def ask_workout_type(self) -> None:
"""Display workout type selection dialog."""
self.clear_container()
question = tk.Label(
self.container,
text="What type of workout?",
font=("Arial", 36, "bold"),
fg="white",
bg="#1a1a1a",
)
question.pack(pady=30)
button_frame = tk.Frame(self.container, bg="#1a1a1a")
button_frame.pack(pady=20)
running_btn = tk.Button(
button_frame,
text="RUNNING",
font=("Arial", 24, "bold"),
bg="#0066cc",
fg="white",
width=15,
command=self.ask_running_details,
cursor="hand2" if self.demo_mode else "",
)
running_btn.pack(side="left", padx=20)
strength_btn = tk.Button(
button_frame,
text="STRENGTH",
font=("Arial", 24, "bold"),
bg="#cc6600",
fg="white",
width=15,
command=self.ask_strength_details,
cursor="hand2" if self.demo_mode else "",
)
strength_btn.pack(side="left", padx=20)
def ask_running_details(self) -> None:
"""Display running workout input form."""
self.clear_container()
self.workout_data["type"] = "running"
title = tk.Label(
self.container,
text="Running Details",
font=("Arial", 36, "bold"),
fg="white",
bg="#1a1a1a",
)
title.pack(pady=20)
# Distance
dist_frame = tk.Frame(self.container, bg="#1a1a1a")
dist_frame.pack(pady=10)
tk.Label(
dist_frame,
text="Distance (km):",
font=("Arial", 20),
fg="white",
bg="#1a1a1a",
).pack(side="left", padx=10)
self.distance_entry = tk.Entry(dist_frame, font=("Arial", 20), width=10)
self.distance_entry.pack(side="left", padx=10)
# Time
time_frame = tk.Frame(self.container, bg="#1a1a1a")
time_frame.pack(pady=10)
tk.Label(
time_frame,
text="Time (minutes):",
font=("Arial", 20),
fg="white",
bg="#1a1a1a",
).pack(side="left", padx=10)
self.time_entry = tk.Entry(time_frame, font=("Arial", 20), width=10)
self.time_entry.pack(side="left", padx=10)
# Pace
pace_frame = tk.Frame(self.container, bg="#1a1a1a")
pace_frame.pack(pady=10)
tk.Label(
pace_frame,
text="Pace (min/km):",
font=("Arial", 20),
fg="white",
bg="#1a1a1a",
).pack(side="left", padx=10)
self.pace_entry = tk.Entry(pace_frame, font=("Arial", 20), width=10)
self.pace_entry.pack(side="left", padx=10)
# Timer countdown label
self.timer_label = tk.Label(
self.container, text="", font=("Arial", 16), fg="#ffaa00", bg="#1a1a1a"
)
self.timer_label.pack(pady=10)
self.submit_btn = tk.Button(
self.container,
text="SUBMIT (locked)",
font=("Arial", 24, "bold"),
bg="#666666",
fg="white",
width=15,
state="disabled",
cursor="hand2" if self.demo_mode else "",
)
self.submit_btn.pack(pady=10)
# Back button
back_btn = tk.Button(
self.container,
text="← BACK",
font=("Arial", 18),
bg="#666666",
fg="white",
width=15,
command=self.ask_workout_type,
cursor="hand2" if self.demo_mode else "",
)
back_btn.pack(pady=10)
# Start 30 second timer
self.submit_unlock_time = 30
self.entries_to_check = [self.distance_entry, self.time_entry, self.pace_entry]
self.submit_command = self.verify_running_data
self.update_submit_timer()
def verify_running_data(self) -> None:
"""Validate running workout data and unlock if valid."""
try:
distance = float(self.distance_entry.get())
time_mins = float(self.time_entry.get())
pace = float(self.pace_entry.get())
# Sanity checks
if distance <= 0 or distance > MAX_DISTANCE_KM:
self.show_error(f"Distance seems unrealistic (0-{MAX_DISTANCE_KM} km)")
return
if time_mins <= 0 or time_mins > MAX_TIME_MINUTES:
self.show_error(
f"Time seems unrealistic (0-{MAX_TIME_MINUTES} minutes)"
)
return
if pace <= 0 or pace > MAX_PACE_MIN_PER_KM:
self.show_error(
f"Pace seems unrealistic (0-{MAX_PACE_MIN_PER_KM} min/km)"
)
return
# Calculate expected pace and check if close enough
expected_pace = time_mins / distance
pace_diff = abs(pace - expected_pace)
tolerance = expected_pace * 0.15 # 15% tolerance
if pace_diff > tolerance:
self.show_error(
f"Pace doesn't match! "
f"Expected ~{expected_pace:.2f} min/km, got {pace:.2f}"
)
return
# Data looks good - store full data
self.workout_data["distance_km"] = str(distance)
self.workout_data["time_minutes"] = str(time_mins)
self.workout_data["pace_min_per_km"] = str(pace)
self.unlock_screen()
except ValueError:
self.show_error("Please enter valid numbers")
def ask_strength_details(self) -> None:
"""Display strength training input form."""
self.clear_container()
self.workout_data["type"] = "strength"
title = tk.Label(
self.container,
text="Strength Training Details",
font=("Arial", 36, "bold"),
fg="white",
bg="#1a1a1a",
)
title.pack(pady=20)
# Exercises
ex_frame = tk.Frame(self.container, bg="#1a1a1a")
ex_frame.pack(pady=10)
tk.Label(
ex_frame,
text="Exercises (comma-separated):",
font=("Arial", 18),
fg="white",
bg="#1a1a1a",
).pack(side="left", padx=10)
self.exercises_entry = tk.Entry(ex_frame, font=("Arial", 18), width=50)
self.exercises_entry.pack(side="left", padx=10)
# Sets per exercise
sets_frame = tk.Frame(self.container, bg="#1a1a1a")
sets_frame.pack(pady=10)
tk.Label(
sets_frame,
text="Sets per exercise (comma-separated):",
font=("Arial", 18),
fg="white",
bg="#1a1a1a",
).pack(side="left", padx=10)
self.sets_entry = tk.Entry(sets_frame, font=("Arial", 18), width=20)
self.sets_entry.pack(side="left", padx=10)
# Reps per set (can be variable, e.g., "12+12+11+11+12" for one exercise)
reps_frame = tk.Frame(self.container, bg="#1a1a1a")
reps_frame.pack(pady=10)
tk.Label(
reps_frame,
text="Reps (comma-sep, use + for variable: 12+11+12):",
font=("Arial", 18),
fg="white",
bg="#1a1a1a",
).pack(side="left", padx=10)
self.reps_entry = tk.Entry(reps_frame, font=("Arial", 18), width=30)
self.reps_entry.pack(side="left", padx=10)
# Weights
weights_frame = tk.Frame(self.container, bg="#1a1a1a")
weights_frame.pack(pady=10)
tk.Label(
weights_frame,
text="Weight per exercise in kg (comma-separated):",
font=("Arial", 18),
fg="white",
bg="#1a1a1a",
).pack(side="left", padx=10)
self.weights_entry = tk.Entry(weights_frame, font=("Arial", 18), width=20)
self.weights_entry.pack(side="left", padx=10)
# Total weight lifted
total_frame = tk.Frame(self.container, bg="#1a1a1a")
total_frame.pack(pady=10)
tk.Label(
total_frame,
text="Total weight lifted (kg):",
font=("Arial", 18),
fg="white",
bg="#1a1a1a",
).pack(side="left", padx=10)
self.total_weight_entry = tk.Entry(total_frame, font=("Arial", 18), width=15)
self.total_weight_entry.pack(side="left", padx=10)
# Timer countdown label
self.timer_label = tk.Label(
self.container, text="", font=("Arial", 16), fg="#ffaa00", bg="#1a1a1a"
)
self.timer_label.pack(pady=10)
self.submit_btn = tk.Button(
self.container,
text="SUBMIT (locked)",
font=("Arial", 24, "bold"),
bg="#666666",
fg="white",
width=15,
state="disabled",
cursor="hand2" if self.demo_mode else "",
)
self.submit_btn.pack(pady=10)
# Back button
back_btn = tk.Button(
self.container,
text="← BACK",
font=("Arial", 18),
bg="#666666",
fg="white",
width=15,
command=self.ask_workout_type,
cursor="hand2" if self.demo_mode else "",
)
back_btn.pack(pady=10)
# Start 30 second timer
self.submit_unlock_time = 30
self.entries_to_check = [
self.exercises_entry,
self.sets_entry,
self.reps_entry,
self.weights_entry,
self.total_weight_entry,
]
self.submit_command = self.verify_strength_data
self.update_submit_timer()
def _parse_reps(self, reps_raw: list[str]) -> list[list[int]]:
"""Parse reps input - can be single number or variable reps like '12+11+12'."""
reps: list[list[int]] = []
for r in reps_raw:
if "+" in r:
reps.append([int(x.strip()) for x in r.split("+")])
else:
reps.append([int(r)])
return reps
def _validate_strength_inputs(
self,
exercises: list[str],
sets: list[int],
reps: list[list[int]],
weights: list[float],
) -> str | None:
"""Validate strength workout inputs. Returns error message or None if valid."""
if not (len(exercises) == len(sets) == len(reps) == len(weights)):
return "Number of exercises, sets, reps, and weights must match"
if any(len(ex) < MIN_EXERCISE_NAME_LEN for ex in exercises):
return "Exercise names too short - be specific"
if any(s < 1 or s > MAX_SETS for s in sets):
return f"Sets should be between 1-{MAX_SETS}"
if any(w < 0 or w > MAX_WEIGHT_KG for w in weights):
return f"Weights should be between 0-{MAX_WEIGHT_KG} kg"
return self._validate_reps(exercises, sets, reps)
def _validate_reps(
self, exercises: list[str], sets: list[int], reps: list[list[int]]
) -> str | None:
"""Validate reps data. Returns error message or None if valid."""
for i, rep_list in enumerate(reps):
if any(r < 1 or r > MAX_REPS for r in rep_list):
return f"Reps should be between 1-{MAX_REPS}"
if len(rep_list) > 1 and len(rep_list) != sets[i]:
return (
f"For '{exercises[i]}': variable reps count ({len(rep_list)}) "
f"doesn't match sets ({sets[i]})"
)
return None
def _calculate_expected_total(
self, sets: list[int], reps: list[list[int]], weights: list[float]
) -> float:
"""Calculate expected total weight lifted."""
expected_total = 0.0
for i, rep_list in enumerate(reps):
if len(rep_list) == 1:
expected_total += sets[i] * rep_list[0] * weights[i]
else:
expected_total += sum(rep_list) * weights[i]
return expected_total
def verify_strength_data(self) -> None:
"""Validate strength workout data and unlock if valid."""
try:
exercises = [e.strip() for e in self.exercises_entry.get().split(",")]
sets = [int(s.strip()) for s in self.sets_entry.get().split(",")]
reps_raw = [r.strip() for r in self.reps_entry.get().split(",")]
reps = self._parse_reps(reps_raw)
weights = [float(w.strip()) for w in self.weights_entry.get().split(",")]
total_weight = float(self.total_weight_entry.get())
error = self._validate_strength_inputs(exercises, sets, reps, weights)
if error:
self.show_error(error)
return
expected_total = self._calculate_expected_total(sets, reps, weights)
weight_diff = abs(total_weight - expected_total)
tolerance = expected_total * 0.15 # 15% tolerance
if weight_diff > tolerance:
self.show_error(
f"Total weight doesn't match! "
f"Expected ~{expected_total:.1f} kg, got {total_weight:.1f}"
)
return
# Data looks good - store full data
self.workout_data["exercises"] = exercises
self.workout_data["sets"] = [str(s) for s in sets]
self.workout_data["reps"] = [
"+".join(str(r) for r in rep_list) for rep_list in reps
]
self.workout_data["weights_kg"] = [str(w) for w in weights]
self.workout_data["total_weight_kg"] = str(total_weight)
self.unlock_screen()
except ValueError:
self.show_error("Please enter valid data in correct format")
def update_submit_timer(self) -> None:
"""Update countdown timer and check if submit can be enabled."""
# Check if widgets still exist (user might have clicked back)
try:
if self.submit_unlock_time > 0:
self.timer_label.config(
text=f"Submit available in {self.submit_unlock_time} seconds..."
)
self.submit_unlock_time -= 1
self.root.after(1000, self.update_submit_timer)
else:
# Timer finished, check if all entries have data
all_filled = all(entry.get().strip() for entry in self.entries_to_check)
if all_filled:
# Enable submit button
self.submit_btn.config(
text="SUBMIT",
state="normal",
bg="#00aa00",
command=self.submit_command,
)
self.timer_label.config(text="You can now submit!")
else:
# Check again in 1 second
self.timer_label.config(text="Fill all fields to enable submit")
self.root.after(1000, self.check_entries_filled)
except tk.TclError:
# Widgets were destroyed (user clicked back), stop the timer
pass
def check_entries_filled(self) -> None:
"""Continuously check if entries are filled after timer expires."""
try:
all_filled = all(entry.get().strip() for entry in self.entries_to_check)
if all_filled:
self.submit_btn.config(
text="SUBMIT",
state="normal",
bg="#00aa00",
command=self.submit_command,
)
self.timer_label.config(text="You can now submit!")
else:
self.timer_label.config(text="Fill all fields to enable submit")
self.root.after(1000, self.check_entries_filled)
except tk.TclError:
# Widgets were destroyed (user clicked back), stop checking
pass
def show_error(self, message: str) -> None:
"""Display error message with retry option."""
self.clear_container()
error_label = tk.Label(
self.container,
text="ERROR",
font=("Arial", 48, "bold"),
fg="#ff4444",
bg="#1a1a1a",
)
error_label.pack(pady=20)
msg_label = tk.Label(
self.container,
text=message,
font=("Arial", 24),
fg="white",
bg="#1a1a1a",
wraplength=800,
)
msg_label.pack(pady=20)
retry_btn = tk.Button(
self.container,
text="TRY AGAIN",
font=("Arial", 24, "bold"),
bg="#0066cc",
fg="white",
width=15,
command=self.ask_workout_done,
cursor="hand2" if self.demo_mode else "",
)
retry_btn.pack(pady=30)
def unlock_screen(self) -> None:
"""Save workout log and display success message."""
# Save workout data to log
self.save_workout_log()
self.clear_container()
success_label = tk.Label(
self.container,
text="Great job! 💪",
font=("Arial", 48, "bold"),
fg="#00ff00",
bg="#1a1a1a",
)
success_label.pack(pady=30)
unlock_label = tk.Label(
self.container,
text="Screen Unlocked!",
font=("Arial", 36),
fg="white",
bg="#1a1a1a",
)
unlock_label.pack(pady=20)
self.root.after(1500, self.close)
def has_logged_today(self) -> bool:
"""Check if workout has been logged today."""
if not self.log_file.exists():
return False
try:
with self.log_file.open() as f:
logs = json.load(f)
except (OSError, json.JSONDecodeError):
return False
else:
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
return today in logs
def save_workout_log(self) -> None:
"""Save workout data to log file."""
# Load existing logs
logs = {}
if self.log_file.exists():
try:
with self.log_file.open() as f:
logs = json.load(f)
except (OSError, json.JSONDecodeError):
logs = {}
# Add today's workout
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
logs[today] = {
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"workout_data": self.workout_data,
}
# Save updated logs
try:
with self.log_file.open("w") as f:
json.dump(logs, f, indent=2)
except OSError as e:
_logger.warning("Could not save workout log: %s", e)
def close(self) -> None:
"""Close the application and exit."""
self.root.destroy()
sys.exit(0)
def run(self) -> None:
"""Start the Tkinter main event loop."""
self.root.mainloop()
if __name__ == "__main__":
# Check for --production flag
demo_mode = True # Default to demo mode for safety
if len(sys.argv) > 1 and sys.argv[1] == "--production":
demo_mode = False
locker = ScreenLocker(demo_mode=demo_mode)
locker.run()