From 6df17080cbb1247df35e75fef92f9f76a9bf71e7 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 14 Feb 2026 18:42:20 +0100 Subject: [PATCH] fixes for existing scripts and pomodoro with local sync --- screen_locker/screen_lock.py | 1610 +++++++++-------------- screen_locker/tests/test_screen_lock.py | 198 --- 2 files changed, 594 insertions(+), 1214 deletions(-) diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index bcd046b..8625f90 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -4,6 +4,9 @@ Requires user to log their workout to unlock the screen. """ +from __future__ import annotations + +import contextlib from datetime import datetime, timezone import json import logging @@ -11,6 +14,10 @@ from pathlib import Path import subprocess import sys import tkinter as tk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable _logger = logging.getLogger(__name__) @@ -24,75 +31,68 @@ MAX_REPS = 100 MAX_WEIGHT_KG = 500 SICK_LOCKOUT_SECONDS = 120 # 2 minutes wait when sick SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf") -# Table tennis minimum requirements (harder to fake) -MIN_TABLE_TENNIS_SETS = 15 -MIN_POINTS_PER_SET = 11 # Standard table tennis minimum points to win a set -TABLE_TENNIS_SUBMIT_DELAY = 60 # 60 seconds delay for table tennis # 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" +_STRENGTH_FIELDS: list[tuple[str, int]] = [ + ("Exercises (comma-separated):", 50), + ("Sets per exercise (comma-separated):", 20), + ("Reps (comma-sep, + for variable: 12+11+12):", 30), + ("Weight per exercise kg (comma-separated):", 20), + ("Total weight lifted (kg):", 15), +] + 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.lockout_time = 10 if demo_mode else 1800 self.workout_data: dict[str, str] = {} + self._setup_window() + if demo_mode: + self._setup_demo_close_button() + self.container = tk.Frame(self.root, bg="#1a1a1a") + self.container.place(relx=0.5, rely=0.5, anchor="center") + self.ask_workout_done() + self._grab_input() - # 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) + def _setup_window(self) -> None: + """Configure the window for fullscreen lock.""" + screen_w = self.root.winfo_screenwidth() + screen_h = self.root.winfo_screenheight() 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.geometry(f"{screen_w}x{screen_h}+0+0") 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) + def _setup_demo_close_button(self) -> None: + """Add close button for demo mode.""" + 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 + def _grab_input(self) -> None: + """Force input focus to the locker window.""" self.root.update_idletasks() self.root.focus_force() self.root.grab_set_global() @@ -102,158 +102,224 @@ class ScreenLocker: for widget in self.container.winfo_children(): widget.destroy() + # ------------------------------------------------------------------ + # UI helper methods + # ------------------------------------------------------------------ + + def _label( + self, + text: str, + *, + font_size: int = 36, + color: str = "white", + pady: int = 20, + ) -> tk.Label: + """Create and pack a bold label in the container.""" + label = tk.Label( + self.container, + text=text, + font=("Arial", font_size, "bold"), + fg=color, + bg="#1a1a1a", + ) + label.pack(pady=pady) + return label + + def _text( + self, + text: str, + *, + font_size: int = 18, + color: str = "white", + pady: int = 10, + ) -> tk.Label: + """Create and pack a non-bold text label in the container.""" + label = tk.Label( + self.container, + text=text, + font=("Arial", font_size), + fg=color, + bg="#1a1a1a", + ) + label.pack(pady=pady) + return label + + def _button( + self, + parent: tk.Widget, + text: str, + *, + bg: str, + command: Callable[[], None], + width: int = 10, + ) -> tk.Button: + """Create a styled button (caller must pack).""" + return tk.Button( + parent, + text=text, + font=("Arial", 24, "bold"), + bg=bg, + fg="white", + width=width, + command=command, + cursor="hand2" if self.demo_mode else "", + ) + + def _button_row(self) -> tk.Frame: + """Create and pack a horizontal button container.""" + frame = tk.Frame(self.container, bg="#1a1a1a") + frame.pack(pady=20) + return frame + + def _entry_row( + self, + label_text: str, + *, + width: int = 10, + font_size: int = 20, + ) -> tk.Entry: + """Create a labeled entry row, returning the Entry widget.""" + frame = tk.Frame(self.container, bg="#1a1a1a") + frame.pack(pady=10) + tk.Label( + frame, + text=label_text, + font=("Arial", font_size), + fg="white", + bg="#1a1a1a", + ).pack(side="left", padx=10) + entry = tk.Entry(frame, font=("Arial", font_size), width=width) + entry.pack(side="left", padx=10) + return entry + + def _disabled_submit_button(self) -> tk.Button: + """Create a disabled submit button.""" + 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 "", + ) + btn.pack(pady=10) + return btn + + def _back_button(self, command: Callable[[], None]) -> tk.Button: + """Create and pack a back button.""" + btn = tk.Button( + self.container, + text="← BACK", + font=("Arial", 18), + bg="#666666", + fg="white", + width=15, + command=command, + cursor="hand2" if self.demo_mode else "", + ) + btn.pack(pady=10) + return btn + + def _setup_form_controls( + self, + entries: list[tk.Entry], + verify_command: Callable[[], None], + back_command: Callable[[], None], + ) -> None: + """Set up timer, submit button, and back button for a form.""" + self.timer_label = self._text("", font_size=16, color="#ffaa00") + self.submit_btn = self._disabled_submit_button() + self._back_button(back_command) + self.submit_unlock_time = 30 + self.entries_to_check = entries + self.submit_command = verify_command + self.update_submit_timer() + + # ------------------------------------------------------------------ + # Main screen flows + # ------------------------------------------------------------------ + 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"), + self._label("Did you work out today?", pady=30) + frame = self._button_row() + self._button( + frame, + "YES", 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"), + ).pack(side="left", padx=20) + self._button( + frame, + "NO", 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) + ).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", + self._label("Are you sick?", pady=30) + self._text( + "If yes, shutdown time will be moved 1.5 hours earlier", + color="#ffaa00", ) - question.pack(pady=30) + self._sick_question_buttons() - 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"), + def _sick_question_buttons(self) -> None: + """Create the sick day yes/no buttons.""" + frame = self._button_row() + self._button( + frame, + "YES (sick)", 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, + ).pack(side="left", padx=20) + self._button( + frame, + "NO", + bg="#aa0000", command=self.lockout, - cursor="hand2" if self.demo_mode else "", - ) - no_btn.pack(side="left", padx=20) + width=12, + ).pack(side="left", padx=20) + + def _get_sick_day_status(self) -> tuple[str, str]: + """Determine sick day status text and color.""" + if self._sick_mode_used_today(): + return "Shutdown time already adjusted today", "#ffaa00" + if self._adjust_shutdown_time_earlier(): + return ( + "Shutdown time moved 1.5 hours earlier ✓\n(Will revert tomorrow)" + ), "#00aa00" + return "Could not adjust shutdown time (check permissions)", "#ff4444" 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) - + status_text, status_color = self._get_sick_day_status() + self._show_sick_day_ui(status_text, status_color) self.sick_remaining_time = SICK_LOCKOUT_SECONDS self._update_sick_countdown() + def _show_sick_day_ui(self, status_text: str, status_color: str) -> None: + """Display sick day UI labels and countdown.""" + self._label("Sick Day Mode", color="#cc6600", pady=20) + self._text(status_text, color=status_color) + self._text( + "Please wait 2 minutes before unlocking...", + font_size=24, + pady=20, + ) + self.sick_countdown_label = self._label( + str(SICK_LOCKOUT_SECONDS), + font_size=80, + pady=30, + ) + def _update_sick_countdown(self) -> None: """Update the sick day countdown timer.""" if self.sick_remaining_time > 0: @@ -266,6 +332,27 @@ class ScreenLocker: self.workout_data["note"] = "Sick day - shutdown moved earlier" self.unlock_screen() + # ------------------------------------------------------------------ + # Shutdown schedule adjustment + # ------------------------------------------------------------------ + + def _apply_earlier_shutdown(self, today: str) -> bool: + """Read config, save state, and write earlier shutdown hours.""" + config_values = self._read_shutdown_config() + if config_values is None: + return False + mon_wed_hour, thu_sun_hour, morning_end_hour = config_values + if not self._save_sick_day_state(today, mon_wed_hour, thu_sun_hour): + _logger.error("Failed to save state - aborting adjustment") + return False + new_mon_wed = max(18, mon_wed_hour - 1) + new_thu_sun = max(18, thu_sun_hour - 1) + return self._write_shutdown_config( + new_mon_wed, + new_thu_sun, + morning_end_hour, + ) + def _adjust_shutdown_time_earlier(self) -> bool: """Adjust shutdown schedule 1.5 hours earlier (stricter). @@ -275,74 +362,34 @@ class ScreenLocker: 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 - ) - + return self._apply_earlier_shutdown(today) except (OSError, ValueError) as e: _logger.warning("Failed to adjust shutdown time: %s", e) return False def _adjust_shutdown_time_later(self) -> bool: - """Adjust shutdown schedule 1.5 hours later as workout reward. - - This moves the shutdown time later regardless of the initial time, - so working out even at 21:00 still makes sense. + """Adjust shutdown schedule 2 hours later as workout reward. Returns True if successful, False otherwise. """ 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 - - # Move shutdown times 1.5 hours (rounded to 2 hours) later - new_mon_wed = mon_wed_hour + 2 - new_thu_sun = thu_sun_hour + 2 - - # Cap at 23 (11 PM) to avoid going past midnight - new_mon_wed = min(23, new_mon_wed) - new_thu_sun = min(23, new_thu_sun) - - # Write new config with restore flag to allow later times + new_mon_wed = min(23, mon_wed_hour + 2) + new_thu_sun = min(23, thu_sun_hour + 2) return self._write_shutdown_config( - new_mon_wed, new_thu_sun, morning_end_hour, restore=True + new_mon_wed, + new_thu_sun, + morning_end_hour, + restore=True, ) - except (OSError, ValueError) as e: _logger.warning("Failed to adjust shutdown time for workout: %s", e) return False @@ -361,7 +408,10 @@ class ScreenLocker: return False def _save_sick_day_state( - self, date: str, orig_mon_wed: int, orig_thu_sun: int + self, + date: str, + orig_mon_wed: int, + orig_thu_sun: int, ) -> bool: """Save sick day state with original config values. @@ -382,70 +432,94 @@ class ScreenLocker: _logger.info("Saved sick day state for %s", date) return True + def _load_sick_day_state(self) -> tuple[str, int, int] | None: + """Load sick day state file. + + Returns (date, orig_mon_wed_hour, orig_thu_sun_hour) or None. + """ + with SICK_DAY_STATE_FILE.open() as f: + state = json.load(f) + date = state.get("date") + orig_mw = state.get("original_mon_wed_hour") + orig_ts = state.get("original_thu_sun_hour") + if date is None or orig_mw is None or orig_ts is None: + return None + return (str(date), int(orig_mw), int(orig_ts)) + + def _write_restored_config( + self, + orig_mw: int, + orig_ts: int, + state_date: str, + ) -> None: + """Write restored config values and clean up state file.""" + config_values = self._read_shutdown_config() + if config_values: + _, _, morning_end = config_values + _logger.info( + "Restoring original shutdown config from %s", + state_date, + ) + self._write_shutdown_config( + orig_mw, + orig_ts, + morning_end, + restore=True, + ) + SICK_DAY_STATE_FILE.unlink() + _logger.info("Removed stale sick day state from %s", state_date) + def _restore_original_config_if_needed(self) -> None: - """Restore original config values if sick day state is from a previous day.""" + """Restore original config 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") + loaded = self._load_sick_day_state() + if loaded is None: + return + state_date, orig_mw, orig_ts = loaded 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) - + if state_date != today: + self._write_restored_config(orig_mw, orig_ts, 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. - """ + """Read shutdown config. Returns (mw_hour, ts_hour, me_hour) or None.""" if not SHUTDOWN_CONFIG_FILE.exists(): - _logger.warning("Shutdown config file not found: %s", SHUTDOWN_CONFIG_FILE) + _logger.warning("Config not found: %s", SHUTDOWN_CONFIG_FILE) return None - - mon_wed_hour = None - thu_sun_hour = None - morning_end_hour = None - + parsed: dict[str, int] = {} + keys = ("MON_WED_HOUR", "THU_SUN_HOUR", "MORNING_END_HOUR") 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: + for line in f: + stripped = line.strip() + for key in keys: + if stripped.startswith(f"{key}="): + parsed[key] = int(stripped.split("=")[1]) + if len(parsed) < len(keys): _logger.warning("Shutdown config missing required values") return None + return ( + parsed["MON_WED_HOUR"], + parsed["THU_SUN_HOUR"], + parsed["MORNING_END_HOUR"], + ) - return (mon_wed_hour, thu_sun_hour, morning_end_hour) + def _build_shutdown_cmd( + self, + mon_wed: int, + thu_sun: int, + morning: int, + *, + restore: bool, + ) -> list[str]: + """Build the shutdown adjustment command.""" + cmd = ["/usr/bin/sudo", str(ADJUST_SHUTDOWN_SCRIPT)] + if restore: + cmd.append("--restore") + cmd.extend([str(mon_wed), str(thu_sun), str(morning)]) + return cmd def _write_shutdown_config( self, @@ -461,21 +535,31 @@ class ScreenLocker: 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). + restore: If True, allows restoring to later times. Returns True if successful, False otherwise. """ if not ADJUST_SHUTDOWN_SCRIPT.exists(): _logger.warning( - "Adjust shutdown script not found: %s", ADJUST_SHUTDOWN_SCRIPT + "Script not found: %s", + ADJUST_SHUTDOWN_SCRIPT, ) return False + cmd = self._build_shutdown_cmd( + mon_wed_hour, + thu_sun_hour, + morning_end_hour, + restore=restore, + ) + return self._run_shutdown_cmd(cmd, mon_wed_hour, thu_sun_hour) - 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)]) - + def _run_shutdown_cmd( + self, + cmd: list[str], + mon_wed_hour: int, + thu_sun_hour: int, + ) -> bool: + """Execute the shutdown adjustment command.""" try: result = subprocess.run( cmd, @@ -486,38 +570,32 @@ class ScreenLocker: 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", + "Adjusted shutdown: Mon-Wed=%d, Thu-Sun=%d. %s", mon_wed_hour, thu_sun_hour, result.stdout.strip(), ) return True - return True + + # ------------------------------------------------------------------ + # Lockout flow + # ------------------------------------------------------------------ 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 = self._label( + f"Go work out!\nLocked for {self.lockout_time} seconds", + font_size=48, + color="#ff4444", + pady=30, ) - 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 = self._label( + str(self.lockout_time), + font_size=120, + pady=30, ) - self.countdown_label.pack(pady=30) - self.remaining_time = self.lockout_time self.update_lockout_countdown() @@ -530,425 +608,123 @@ class ScreenLocker: else: self.ask_workout_done() + # ------------------------------------------------------------------ + # Workout type selection + # ------------------------------------------------------------------ + 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 option removed - too easy to fake - - strength_btn = tk.Button( - button_frame, - text="STRENGTH", - font=("Arial", 24, "bold"), + self._label("What type of workout?", pady=30) + frame = self._button_row() + self._button( + frame, + "STRENGTH", bg="#cc6600", - fg="white", - width=12, command=self.ask_strength_details, - cursor="hand2" if self.demo_mode else "", - ) - strength_btn.pack(side="left", padx=20) - - table_tennis_btn = tk.Button( - button_frame, - text="TABLE TENNIS", - font=("Arial", 20, "bold"), - bg="#00cc66", - fg="white", width=12, - command=self.ask_table_tennis_details, - cursor="hand2" if self.demo_mode else "", - ) - table_tennis_btn.pack(side="left", padx=20) + ).pack(side="left", padx=20) + + # ------------------------------------------------------------------ + # Running workout + # ------------------------------------------------------------------ + + def _create_running_entries(self) -> list[tk.Entry]: + """Create running workout entry fields.""" + self.distance_entry = self._entry_row("Distance (km):") + self.time_entry = self._entry_row("Time (minutes):") + self.pace_entry = self._entry_row("Pace (min/km):") + return [self.distance_entry, self.time_entry, self.pace_entry] 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", + self._label("Running Details", pady=20) + entries = self._create_running_entries() + self._setup_form_controls( + entries, + self.verify_running_data, + self.ask_workout_type, ) - 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) + def _check_running_ranges( + self, + distance: float, + time_mins: float, + pace: float, + ) -> str | None: + """Check if running values are in valid ranges.""" + if distance <= 0 or distance > MAX_DISTANCE_KM: + return f"Distance seems unrealistic (0-{MAX_DISTANCE_KM} km)" + if time_mins <= 0 or time_mins > MAX_TIME_MINUTES: + return f"Time seems unrealistic (0-{MAX_TIME_MINUTES} minutes)" + if pace <= 0 or pace > MAX_PACE_MIN_PER_KM: + return f"Pace seems unrealistic (0-{MAX_PACE_MIN_PER_KM} min/km)" + expected_pace = time_mins / distance + tolerance = expected_pace * 0.15 # 15% tolerance + if abs(pace - expected_pace) > tolerance: + return ( + f"Pace doesn't match! " + f"Expected ~{expected_pace:.2f} min/km, got {pace:.2f}" + ) + return None - # 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.""" + def _validate_running_input(self) -> tuple[float, float, float] | None: + """Parse and validate running input fields.""" 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") + return None + error = self._check_running_ranges(distance, time_mins, pace) + if error: + self.show_error(error) + return None + return distance, time_mins, pace - def ask_strength_details(self) -> None: - """Display strength training input form.""" - self.clear_container() - self.workout_data["type"] = "strength" + def verify_running_data(self) -> None: + """Validate running workout data and unlock if valid.""" + result = self._validate_running_input() + if result is None: + return + distance, time_mins, pace = result + 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() - title = tk.Label( - self.container, - text="Strength Training Details", - font=("Arial", 36, "bold"), - fg="white", - bg="#1a1a1a", - ) - title.pack(pady=20) + # ------------------------------------------------------------------ + # Strength workout + # ------------------------------------------------------------------ - # 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 = [ + def _create_strength_entries(self) -> list[tk.Entry]: + """Create strength training entry fields.""" + entries = [ + self._entry_row(lbl, width=w, font_size=18) for lbl, w in _STRENGTH_FIELDS + ] + ( 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() + ) = entries + return entries - def ask_table_tennis_details(self) -> None: - """Display table tennis workout input form.""" + def ask_strength_details(self) -> None: + """Display strength training input form.""" self.clear_container() - self.workout_data["type"] = "table_tennis" - - title = tk.Label( - self.container, - text="Table Tennis Details", - font=("Arial", 36, "bold"), - fg="white", - bg="#1a1a1a", + self.workout_data["type"] = "strength" + self._label("Strength Training Details", pady=20) + entries = self._create_strength_entries() + self._setup_form_controls( + entries, + self.verify_strength_data, + self.ask_workout_type, ) - title.pack(pady=20) - - # Instructions/Requirements - requirements = tk.Label( - self.container, - text=( - f"Requirements: Minimum {MIN_TABLE_TENNIS_SETS} sets, " - f"each set needs at least {MIN_POINTS_PER_SET} total points" - ), - font=("Arial", 14), - fg="#aaaaaa", - bg="#1a1a1a", - ) - requirements.pack(pady=5) - - # Duration - duration_frame = tk.Frame(self.container, bg="#1a1a1a") - duration_frame.pack(pady=10) - tk.Label( - duration_frame, - text="Duration (minutes):", - font=("Arial", 20), - fg="white", - bg="#1a1a1a", - ).pack(side="left", padx=10) - self.tt_duration_entry = tk.Entry(duration_frame, font=("Arial", 20), width=10) - self.tt_duration_entry.pack(side="left", padx=10) - - # Sets played - sets_frame = tk.Frame(self.container, bg="#1a1a1a") - sets_frame.pack(pady=10) - tk.Label( - sets_frame, - text="Sets played:", - font=("Arial", 20), - fg="white", - bg="#1a1a1a", - ).pack(side="left", padx=10) - self.tt_sets_entry = tk.Entry(sets_frame, font=("Arial", 20), width=10) - self.tt_sets_entry.pack(side="left", padx=10) - - # Points won - won_frame = tk.Frame(self.container, bg="#1a1a1a") - won_frame.pack(pady=10) - tk.Label( - won_frame, - text="Points won:", - font=("Arial", 20), - fg="white", - bg="#1a1a1a", - ).pack(side="left", padx=10) - self.tt_won_entry = tk.Entry(won_frame, font=("Arial", 20), width=10) - self.tt_won_entry.pack(side="left", padx=10) - - # Points lost - lost_frame = tk.Frame(self.container, bg="#1a1a1a") - lost_frame.pack(pady=10) - tk.Label( - lost_frame, - text="Points lost:", - font=("Arial", 20), - fg="white", - bg="#1a1a1a", - ).pack(side="left", padx=10) - self.tt_lost_entry = tk.Entry(lost_frame, font=("Arial", 20), width=10) - self.tt_lost_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 60 second timer (increased from 30) - self.submit_unlock_time = TABLE_TENNIS_SUBMIT_DELAY - self.entries_to_check = [ - self.tt_duration_entry, - self.tt_sets_entry, - self.tt_won_entry, - self.tt_lost_entry, - ] - self.submit_command = self.verify_table_tennis_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'.""" @@ -979,7 +755,10 @@ class ScreenLocker: return self._validate_reps(exercises, sets, reps) def _validate_reps( - self, exercises: list[str], sets: list[int], reps: list[list[int]] + 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): @@ -987,13 +766,16 @@ class ScreenLocker: 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]})" + f"For {exercises[i]!r}: variable reps count " + f"({len(rep_list)}) doesn't match sets ({sets[i]})" ) return None def _calculate_expected_total( - self, sets: list[int], reps: list[list[int]], weights: list[float] + self, + sets: list[int], + reps: list[list[int]], + weights: list[float], ) -> float: """Calculate expected total weight lifted.""" expected_total = 0.0 @@ -1004,299 +786,122 @@ class ScreenLocker: expected_total += sum(rep_list) * weights[i] return expected_total + def _parse_strength_entries( + self, + ) -> tuple[list[str], list[int], list[list[int]], list[float], float]: + """Parse raw strength training input from entry widgets.""" + 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()) + return exercises, sets, reps, weights, total_weight + + def _check_total_weight( + self, + sets: list[int], + reps: list[list[int]], + weights: list[float], + total_weight: float, + ) -> str | None: + """Verify total weight matches individual exercise calculations.""" + expected = self._calculate_expected_total(sets, reps, weights) + tolerance = expected * 0.15 # 15% tolerance + if abs(total_weight - expected) > tolerance: + return ( + f"Total weight doesn't match! " + f"Expected ~{expected:.1f} kg, got {total_weight:.1f}" + ) + return None + + def _store_strength_data( + self, + exercises: list[str], + sets: list[int], + reps: list[list[int]], + weights: list[float], + total_weight: float, + ) -> None: + """Store validated strength workout 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) + 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() - + self._verify_strength_data_inner() except ValueError: self.show_error("Please enter valid data in correct format") - def verify_table_tennis_data(self) -> None: - """Validate table tennis workout data and unlock if valid.""" - try: - duration = float(self.tt_duration_entry.get()) - sets_played = int(self.tt_sets_entry.get()) - points_won = int(self.tt_won_entry.get()) - points_lost = int(self.tt_lost_entry.get()) + def _verify_strength_data_inner(self) -> None: + """Parse, validate, and store strength data.""" + data = self._parse_strength_entries() + exercises, sets, reps, weights, total_weight = data + error = self._validate_strength_inputs(exercises, sets, reps, weights) + if error: + self.show_error(error) + return + total_err = self._check_total_weight(sets, reps, weights, total_weight) + if total_err: + self.show_error(total_err) + return + self._store_strength_data(exercises, sets, reps, weights, total_weight) + self.unlock_screen() - # Basic validation - if duration <= 0: - self.show_error("Duration must be greater than 0 minutes") - return - if sets_played <= 0: - self.show_error("Sets played must be greater than 0") - return - if points_won < 0 or points_lost < 0: - self.show_error("Points cannot be negative") - return - if points_won + points_lost == 0: - self.show_error("You must have played some points") - return + # ------------------------------------------------------------------ + # Submit timer and entry checking + # ------------------------------------------------------------------ - # Stricter validation - minimum sets requirement - if sets_played < MIN_TABLE_TENNIS_SETS: - self.show_error( - f"Minimum {MIN_TABLE_TENNIS_SETS} sets required for a valid workout" - ) - return + def _tick_submit_timer(self) -> None: + """Decrement submit timer and schedule next tick.""" + 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) - # Mathematical cross-check: total_points >= sets_played * MIN_POINTS_PER_SET - total_points = points_won + points_lost - min_expected_points = sets_played * MIN_POINTS_PER_SET - if total_points < min_expected_points: - self.show_error( - f"Invalid data: {sets_played} sets needs " - f"at least {min_expected_points} total points " - f"(min {MIN_POINTS_PER_SET} per set). " - f"You entered {total_points}." - ) - return - - # Reasonable duration check: at least 2 minutes per set - min_expected_duration = sets_played * 2 - if duration < min_expected_duration: - self.show_error( - f"Duration too short: {sets_played} sets should " - f"take at least {min_expected_duration} minutes" - ) - return - - # Ask verification question about the data - self.ask_table_tennis_verification( - duration, sets_played, points_won, points_lost + def _try_enable_submit(self) -> None: + """Enable submit button if all entries are filled.""" + 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, ) - - except ValueError: - self.show_error("Please enter valid numbers") - - def ask_table_tennis_verification( - self, duration: float, sets_played: int, points_won: int, points_lost: int - ) -> None: - """Ask a math verification question about the entered data.""" - import random - - self.clear_container() - - # Store data for later submission - self._pending_tt_data = { - "duration": duration, - "sets_played": sets_played, - "points_won": points_won, - "points_lost": points_lost, - } - - # Generate a random verification question based on their data - total_points = points_won + points_lost - question_types = [ - ( - "total_points", - "What is the TOTAL number of points played? (won + lost)", - total_points, - ), - ( - "avg_per_set", - "Rounded DOWN: what is the average points per set? (total ÷ sets)", - total_points // sets_played, - ), - ( - "point_diff", - "What is the difference between won and lost points? (won - lost)", - abs(points_won - points_lost), - ), - ] - - question_type, question_text, expected_answer = random.choice(question_types) - self._tt_expected_answer = expected_answer - self._tt_question_type = question_type - - title = tk.Label( - self.container, - text="🔢 Verification Question", - font=("Arial", 30, "bold"), - fg="white", - bg="#1a1a1a", - ) - title.pack(pady=20) - - info = tk.Label( - self.container, - text=( - f"Based on your data: {sets_played} sets, " - f"{points_won} won, {points_lost} lost" - ), - font=("Arial", 16), - fg="#aaaaaa", - bg="#1a1a1a", - ) - info.pack(pady=10) - - question = tk.Label( - self.container, - text=question_text, - font=("Arial", 20, "bold"), - fg="#ffaa00", - bg="#1a1a1a", - ) - question.pack(pady=20) - - answer_frame = tk.Frame(self.container, bg="#1a1a1a") - answer_frame.pack(pady=10) - tk.Label( - answer_frame, - text="Your answer:", - font=("Arial", 20), - fg="white", - bg="#1a1a1a", - ).pack(side="left", padx=10) - self.tt_verify_entry = tk.Entry(answer_frame, font=("Arial", 20), width=10) - self.tt_verify_entry.pack(side="left", padx=10) - self.tt_verify_entry.focus_set() - - submit_btn = tk.Button( - self.container, - text="VERIFY & SUBMIT", - font=("Arial", 24, "bold"), - bg="#00aa00", - fg="white", - width=15, - command=self.verify_table_tennis_answer, - cursor="hand2" if self.demo_mode else "", - ) - submit_btn.pack(pady=20) - - # Back button - back_btn = tk.Button( - self.container, - text="← BACK", - font=("Arial", 18), - bg="#666666", - fg="white", - width=15, - command=self.ask_table_tennis_details, - cursor="hand2" if self.demo_mode else "", - ) - back_btn.pack(pady=10) - - def verify_table_tennis_answer(self) -> None: - """Check the verification answer and unlock if correct.""" - try: - user_answer = int(self.tt_verify_entry.get()) - if user_answer != self._tt_expected_answer: - self.show_error( - f"Incorrect! The answer was {self._tt_expected_answer}. " - "Did you enter accurate data?" - ) - # Go back to input form - self.root.after(2000, self.ask_table_tennis_details) - return - - # Answer correct - store data and unlock - data = self._pending_tt_data - self.workout_data["duration_minutes"] = str(data["duration"]) - self.workout_data["sets_played"] = str(data["sets_played"]) - self.workout_data["points_won"] = str(data["points_won"]) - self.workout_data["points_lost"] = str(data["points_lost"]) - self.unlock_screen() - - except ValueError: - self.show_error("Please enter a valid number") + 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) 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: + with contextlib.suppress(tk.TclError): 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) + self._tick_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 + self._try_enable_submit() 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) + with contextlib.suppress(tk.TclError): + self._try_enable_submit() - 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 + # ------------------------------------------------------------------ + # Error, unlock, and logging + # ------------------------------------------------------------------ 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) - + self._label("ERROR", font_size=48, color="#ff4444", pady=20) msg_label = tk.Label( self.container, text=message, @@ -1306,63 +911,37 @@ class ScreenLocker: wraplength=800, ) msg_label.pack(pady=20) - - retry_btn = tk.Button( + self._button( self.container, - text="TRY AGAIN", - font=("Arial", 24, "bold"), + "TRY AGAIN", bg="#0066cc", - fg="white", - width=15, command=self.ask_workout_done, - cursor="hand2" if self.demo_mode else "", - ) - retry_btn.pack(pady=30) + width=15, + ).pack(pady=30) + + def _try_adjust_shutdown_for_workout(self) -> bool: + """Try to adjust shutdown time later for actual workouts.""" + workout_type = self.workout_data.get("type", "") + if workout_type not in ("running", "strength"): + return False + adjusted = self._adjust_shutdown_time_later() + if adjusted: + _logger.info("Shutdown time moved 1.5 hours later as workout reward") + return adjusted def unlock_screen(self) -> None: """Save workout log and display success message.""" - # Save workout data to log self.save_workout_log() - - # Adjust shutdown time later for actual workouts (not sick days) - shutdown_adjusted = False - workout_type = self.workout_data.get("type", "") - if workout_type in ("running", "strength", "table_tennis"): - shutdown_adjusted = self._adjust_shutdown_time_later() - if shutdown_adjusted: - _logger.info("Shutdown time moved 1.5 hours later as workout reward") - + shutdown_adjusted = self._try_adjust_shutdown_for_workout() 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) - - # Show shutdown adjustment status + self._label("Great job! 💪", font_size=48, color="#00ff00", pady=30) if shutdown_adjusted: - bonus_label = tk.Label( - self.container, - text="Shutdown time +1.5h later! 🎁", - font=("Arial", 24), - fg="#ffaa00", - bg="#1a1a1a", + self._text( + "Shutdown time +1.5h later! 🎁", + font_size=24, + color="#ffaa00", ) - bonus_label.pack(pady=10) - - unlock_label = tk.Label( - self.container, - text="Screen Unlocked!", - font=("Arial", 36), - fg="white", - bg="#1a1a1a", - ) - unlock_label.pack(pady=20) - + self._text("Screen Unlocked!", font_size=36, pady=20) self.root.after(1500, self.close) def has_logged_today(self) -> bool: @@ -1379,25 +958,24 @@ class ScreenLocker: today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") return today in logs + def _load_existing_logs(self) -> dict: + """Load existing workout logs from file.""" + if not self.log_file.exists(): + return {} + try: + with self.log_file.open() as f: + return json.load(f) + except (OSError, json.JSONDecodeError): + return {} + 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 + logs = self._load_existing_logs() 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) diff --git a/screen_locker/tests/test_screen_lock.py b/screen_locker/tests/test_screen_lock.py index fb117aa..d1c76df 100644 --- a/screen_locker/tests/test_screen_lock.py +++ b/screen_locker/tests/test_screen_lock.py @@ -55,15 +55,6 @@ class StrengthData(NamedTuple): total_weight: str -class TableTennisData(NamedTuple): - """Table tennis workout data for tests.""" - - duration: str - sets_played: str - points_won: str - points_lost: str - - @pytest.fixture def mock_tk() -> Generator[MagicMock]: """Mock tkinter module for testing without display.""" @@ -137,18 +128,6 @@ def setup_strength_entries(locker: ScreenLocker, data: StrengthData) -> None: locker.total_weight_entry.get.return_value = data.total_weight -def setup_table_tennis_entries(locker: ScreenLocker, data: TableTennisData) -> None: - """Set up mock table tennis entry widgets.""" - locker.tt_duration_entry = MagicMock() - locker.tt_duration_entry.get.return_value = data.duration - locker.tt_sets_entry = MagicMock() - locker.tt_sets_entry.get.return_value = data.sets_played - locker.tt_won_entry = MagicMock() - locker.tt_won_entry.get.return_value = data.points_won - locker.tt_lost_entry = MagicMock() - locker.tt_lost_entry.get.return_value = data.points_lost - - class TestConstants: """Tests for module constants.""" @@ -731,109 +710,6 @@ class TestVerifyStrengthData: assert "valid data" in locker.show_error.call_args[0][0] -class TestVerifyTableTennisData: - """Tests for verify_table_tennis_data method.""" - - def test_valid_table_tennis_data( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, # noqa: ARG002 - tmp_path: Path, - ) -> None: - """Test valid table tennis data unlocks screen.""" - locker = create_locker(mock_tk, tmp_path) - setup_table_tennis_entries(locker, TableTennisData("60", "3", "21", "15")) - locker.unlock_screen = MagicMock() # type: ignore[method-assign] - - locker.verify_table_tennis_data() - - locker.unlock_screen.assert_called_once() - assert locker.workout_data["duration_minutes"] == "60.0" - assert locker.workout_data["sets_played"] == "3" - assert locker.workout_data["points_won"] == "21" - assert locker.workout_data["points_lost"] == "15" - - def test_invalid_duration_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, # noqa: ARG002 - tmp_path: Path, - ) -> None: - """Test duration <= 0 is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_table_tennis_entries(locker, TableTennisData("0", "3", "21", "15")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_table_tennis_data() - - locker.show_error.assert_called_once() - assert "greater than 0" in locker.show_error.call_args[0][0] - - def test_invalid_sets_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, # noqa: ARG002 - tmp_path: Path, - ) -> None: - """Test sets <= 0 is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_table_tennis_entries(locker, TableTennisData("60", "0", "21", "15")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_table_tennis_data() - - locker.show_error.assert_called_once() - assert "greater than 0" in locker.show_error.call_args[0][0] - - def test_invalid_points_negative( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, # noqa: ARG002 - tmp_path: Path, - ) -> None: - """Test negative points are rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_table_tennis_entries(locker, TableTennisData("60", "3", "-1", "15")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_table_tennis_data() - - locker.show_error.assert_called_once() - assert "cannot be negative" in locker.show_error.call_args[0][0] - - def test_invalid_no_points_played( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, # noqa: ARG002 - tmp_path: Path, - ) -> None: - """Test zero total points is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_table_tennis_entries(locker, TableTennisData("60", "3", "0", "0")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_table_tennis_data() - - locker.show_error.assert_called_once() - assert "played some points" in locker.show_error.call_args[0][0] - - def test_invalid_number_format( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, # noqa: ARG002 - tmp_path: Path, - ) -> None: - """Test invalid format is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_table_tennis_entries(locker, TableTennisData("abc", "3", "21", "15")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_table_tennis_data() - - locker.show_error.assert_called_once() - assert "valid numbers" in locker.show_error.call_args[0][0] - - class TestUITransitions: """Tests for UI state transitions.""" @@ -1266,62 +1142,6 @@ class TestAskStrengthDetails: locker.update_submit_timer.assert_called_once() -class TestAskTableTennisDetails: - """Tests for ask_table_tennis_details method.""" - - def test_ask_table_tennis_details_sets_workout_type( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, # noqa: ARG002 - tmp_path: Path, - ) -> None: - """Test ask_table_tennis_details sets workout type to table_tennis.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker.update_submit_timer = MagicMock() # type: ignore[method-assign] - - locker.ask_table_tennis_details() - - assert locker.workout_data["type"] == "table_tennis" - locker.clear_container.assert_called_once() - - def test_ask_table_tennis_details_creates_entry_fields( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, # noqa: ARG002 - tmp_path: Path, - ) -> None: - """Test ask_table_tennis_details creates entry fields.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker.update_submit_timer = MagicMock() # type: ignore[method-assign] - - locker.ask_table_tennis_details() - - # Verify Entry fields were created - mock_tk.Entry.assert_called() - assert hasattr(locker, "tt_duration_entry") - assert hasattr(locker, "tt_sets_entry") - assert hasattr(locker, "tt_won_entry") - assert hasattr(locker, "tt_lost_entry") - - def test_ask_table_tennis_details_sets_timer( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, # noqa: ARG002 - tmp_path: Path, - ) -> None: - """Test ask_table_tennis_details initializes submit timer.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker.update_submit_timer = MagicMock() # type: ignore[method-assign] - - locker.ask_table_tennis_details() - - assert locker.submit_unlock_time == 30 - locker.update_submit_timer.assert_called_once() - - class TestAskWorkoutDone: """Tests for ask_workout_done method.""" @@ -1458,24 +1278,6 @@ class TestUnlockScreenShutdownAdjustment: locker._adjust_shutdown_time_later.assert_called_once() - def test_unlock_screen_adjusts_for_table_tennis( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, # noqa: ARG002 - tmp_path: Path, - ) -> None: - """Test unlock_screen adjusts shutdown for table tennis workout.""" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = tmp_path / "workout_log.json" - locker.workout_data = {"type": "table_tennis"} - locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] - return_value=True - ) - - locker.unlock_screen() - - locker._adjust_shutdown_time_later.assert_called_once() - def test_unlock_screen_skips_adjustment_for_sick_day( self, mock_tk: MagicMock,