testsAndMisc/python_pkg/screen_locker/screen_lock.py

1220 lines
43 KiB
Python
Raw Normal View History

2025-11-18 18:07:15 +01:00
#!/usr/bin/env python3
"""Screen locker with workout verification for Arch Linux / i3wm.
2025-11-18 18:07:15 +01:00
Requires user to log their workout to unlock the screen.
"""
from __future__ import annotations
import contextlib
from datetime import datetime, timezone
2025-11-18 18:07:15 +01:00
import json
import logging
from pathlib import Path
2026-02-23 22:50:42 +01:00
import shutil
import sqlite3
2026-01-06 13:10:54 +01:00
import subprocess
import sys
2026-02-23 22:50:42 +01:00
import tempfile
import tkinter as tk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
2025-11-18 18:07:15 +01:00
_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
2026-01-06 13:10:54 +01:00
SICK_LOCKOUT_SECONDS = 120 # 2 minutes wait when sick
2026-02-23 22:50:42 +01:00
SUBMIT_DELAY_DEMO = 30
SUBMIT_DELAY_PRODUCTION = 180
PHONE_PENALTY_DELAY_DEMO = 10
PHONE_PENALTY_DELAY_PRODUCTION = 600
ADB_TIMEOUT = 15
STRONGLIFTS_DB_REMOTE = (
"/data/data/com.stronglifts.app/databases/StrongLifts-Database-3"
)
2026-01-06 13:10:54 +01:00
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"
_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),
]
2025-11-18 18:07:15 +01:00
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."""
script_dir = Path(__file__).resolve().parent
self.log_file = script_dir / "workout_log.json"
2025-11-18 18:07:15 +01:00
if self.has_logged_today():
_logger.info("Workout already logged today. Skipping screen lock.")
2025-11-18 18:07:15 +01:00
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
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")
2026-02-23 22:50:42 +01:00
self._start_phone_check()
self._grab_input()
def _setup_window(self) -> None:
"""Configure the window for fullscreen lock."""
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
2025-11-18 18:07:15 +01:00
self.root.overrideredirect(True)
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")
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)
def _grab_input(self) -> None:
"""Force input focus to the locker window."""
2025-11-18 18:07:15 +01:00
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."""
2025-11-18 18:07:15 +01:00
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(
2025-11-18 18:07:15 +01:00
self.container,
text=text,
font=("Arial", font_size, "bold"),
fg=color,
bg="#1a1a1a",
2025-11-18 18:07:15 +01:00
)
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",
2025-11-18 18:07:15 +01:00
)
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,
2026-01-06 13:10:54 +01:00
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
2026-01-06 13:10:54 +01:00
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),
2026-01-06 13:10:54 +01:00
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
2026-01-06 13:10:54 +01:00
def _disabled_submit_button(self) -> tk.Button:
"""Create a disabled submit button."""
btn = tk.Button(
2026-01-06 13:10:54 +01:00
self.container,
text="SUBMIT (locked)",
2026-01-06 13:10:54 +01:00
font=("Arial", 24, "bold"),
bg="#666666",
2026-01-06 13:10:54 +01:00
fg="white",
width=15,
state="disabled",
2026-01-06 13:10:54 +01:00
cursor="hand2" if self.demo_mode else "",
)
btn.pack(pady=10)
return btn
2026-01-06 13:10:54 +01:00
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",
2026-01-06 13:10:54 +01:00
fg="white",
width=15,
command=command,
cursor="hand2" if self.demo_mode else "",
2025-11-18 18:07:15 +01:00
)
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)
2026-02-23 22:50:42 +01:00
self.submit_unlock_time = (
SUBMIT_DELAY_DEMO if self.demo_mode else SUBMIT_DELAY_PRODUCTION
)
self.entries_to_check = entries
self.submit_command = verify_command
self.update_submit_timer()
2026-01-06 13:10:54 +01:00
# ------------------------------------------------------------------
# Main screen flows
# ------------------------------------------------------------------
2026-01-06 13:10:54 +01:00
def ask_workout_done(self) -> None:
"""Display the initial workout question dialog."""
self.clear_container()
self._label("Did you work out today?", pady=30)
frame = self._button_row()
self._button(
frame,
"YES",
bg="#00aa00",
command=self.ask_workout_type,
).pack(side="left", padx=20)
self._button(
frame,
"NO",
bg="#aa0000",
command=self.ask_if_sick,
).pack(side="left", padx=20)
2026-01-06 13:10:54 +01:00
2026-02-23 22:50:42 +01:00
def _start_phone_check(self) -> None:
"""Check phone for today's workout immediately at startup."""
self.clear_container()
self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30)
self._text("Looking for today's workout in StrongLifts...", font_size=18)
self.root.after(100, self._handle_startup_phone_result)
def _handle_startup_phone_result(self) -> None:
"""Route to appropriate screen based on startup phone check result."""
status, message = self._verify_phone_workout()
if status == "verified":
self.clear_container()
self._label(
"\u2713 Workout Verified!", font_size=36, color="#00cc00", pady=20
)
self._text(message, color="#88ff88")
self._text("\nLog the details below to unlock.", font_size=18)
self.root.after(1500, self.ask_workout_type)
elif status == "not_verified":
self.clear_container()
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
self._text(
f"\u274c {message}\n\n"
"StrongLifts shows no workout today.\n"
"Go do your workout first!",
color="#ffaa00",
)
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg="#0066cc",
command=self._start_phone_check,
width=12,
).pack(side="left", padx=10)
self._button(
frame,
"I'm sick",
bg="#cc6600",
command=self.ask_if_sick,
width=12,
).pack(side="left", padx=10)
else:
# no_phone or error — penalty timer, then proceed to logging form
self._show_phone_penalty(message, on_done=self.ask_workout_done)
def ask_if_sick(self) -> None:
"""Display sick day question dialog."""
self.clear_container()
self._label("Are you sick?", pady=30)
self._text(
"If yes, shutdown time will be moved 1.5 hours earlier",
color="#ffaa00",
)
self._sick_question_buttons()
def _sick_question_buttons(self) -> None:
"""Create the sick day yes/no buttons."""
frame = self._button_row()
self._button(
frame,
"YES (sick)",
bg="#cc6600",
command=self.handle_sick_day,
width=12,
).pack(side="left", padx=20)
self._button(
frame,
"NO",
bg="#aa0000",
command=self.lockout,
width=12,
).pack(side="left", padx=20)
2026-01-06 13:10:54 +01:00
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"
2026-01-06 13:10:54 +01:00
def handle_sick_day(self) -> None:
"""Handle sick day: adjust shutdown time and start 2-minute wait."""
self.clear_container()
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()
2026-01-06 13:10:54 +01:00
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,
2026-01-06 13:10:54 +01:00
)
self.sick_countdown_label = self._label(
str(SICK_LOCKOUT_SECONDS),
font_size=80,
pady=30,
2026-01-06 13:10:54 +01:00
)
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()
# ------------------------------------------------------------------
# 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,
)
2026-01-06 13:10:54 +01:00
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")
self._restore_original_config_if_needed()
if self._sick_mode_used_today():
_logger.warning("Sick mode already used today")
return False
try:
return self._apply_earlier_shutdown(today)
2026-01-06 13:10:54 +01:00
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 2 hours later as workout reward.
Returns True if successful, False otherwise.
"""
try:
config_values = self._read_shutdown_config()
if config_values is None:
return False
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
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,
)
except (OSError, ValueError) as e:
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
return False
2026-01-06 13:10:54 +01:00
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,
2026-01-06 13:10:54 +01:00
) -> 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 _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)
2026-01-06 13:10:54 +01:00
def _restore_original_config_if_needed(self) -> None:
"""Restore original config if sick day state is from a previous day."""
2026-01-06 13:10:54 +01:00
if not SICK_DAY_STATE_FILE.exists():
return
try:
loaded = self._load_sick_day_state()
if loaded is None:
return
state_date, orig_mw, orig_ts = loaded
2026-01-06 13:10:54 +01:00
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
if state_date != today:
self._write_restored_config(orig_mw, orig_ts, state_date)
2026-01-06 13:10:54 +01:00
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 shutdown config. Returns (mw_hour, ts_hour, me_hour) or None."""
2026-01-06 13:10:54 +01:00
if not SHUTDOWN_CONFIG_FILE.exists():
_logger.warning("Config not found: %s", SHUTDOWN_CONFIG_FILE)
2026-01-06 13:10:54 +01:00
return None
parsed: dict[str, int] = {}
keys = ("MON_WED_HOUR", "THU_SUN_HOUR", "MORNING_END_HOUR")
2026-01-06 13:10:54 +01:00
with SHUTDOWN_CONFIG_FILE.open() as f:
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):
2026-01-06 13:10:54 +01:00
_logger.warning("Shutdown config missing required values")
return None
return (
parsed["MON_WED_HOUR"],
parsed["THU_SUN_HOUR"],
parsed["MORNING_END_HOUR"],
)
2026-01-06 13:10:54 +01:00
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
2026-01-06 13:10:54 +01:00
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.
2026-01-06 13:10:54 +01:00
Returns True if successful, False otherwise.
"""
if not ADJUST_SHUTDOWN_SCRIPT.exists():
_logger.warning(
"Script not found: %s",
ADJUST_SHUTDOWN_SCRIPT,
2026-01-06 13:10:54 +01:00
)
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)
2026-01-06 13:10:54 +01:00
def _run_shutdown_cmd(
self,
cmd: list[str],
mon_wed_hour: int,
thu_sun_hour: int,
) -> bool:
"""Execute the shutdown adjustment command."""
2026-01-06 13:10:54 +01:00
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: Mon-Wed=%d, Thu-Sun=%d. %s",
2026-01-06 13:10:54 +01:00
mon_wed_hour,
thu_sun_hour,
result.stdout.strip(),
)
return True
# ------------------------------------------------------------------
# Lockout flow
# ------------------------------------------------------------------
2026-01-06 13:10:54 +01:00
def lockout(self) -> None:
"""Display lockout screen with countdown timer."""
2025-11-18 18:07:15 +01:00
self.clear_container()
self.lockout_label = self._label(
f"Go work out!\nLocked for {self.lockout_time} seconds",
font_size=48,
color="#ff4444",
pady=30,
2025-11-18 18:07:15 +01:00
)
self.countdown_label = self._label(
str(self.lockout_time),
font_size=120,
pady=30,
2025-11-18 18:07:15 +01:00
)
self.remaining_time = self.lockout_time
self.update_lockout_countdown()
def update_lockout_countdown(self) -> None:
"""Update the lockout countdown timer display."""
2025-11-18 18:07:15 +01:00
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()
# ------------------------------------------------------------------
# Workout type selection
# ------------------------------------------------------------------
def ask_workout_type(self) -> None:
"""Display workout type selection dialog."""
2025-11-18 18:07:15 +01:00
self.clear_container()
self._label("What type of workout?", pady=30)
frame = self._button_row()
self._button(
frame,
"STRENGTH",
bg="#cc6600",
2025-11-18 18:07:15 +01:00
command=self.ask_strength_details,
width=12,
).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."""
2025-11-18 18:07:15 +01:00
self.clear_container()
self.workout_data["type"] = "running"
self._label("Running Details", pady=20)
entries = self._create_running_entries()
self._setup_form_controls(
entries,
self.verify_running_data,
self.ask_workout_type,
2025-11-18 18:07:15 +01:00
)
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
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())
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 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)
2026-02-23 22:50:42 +01:00
self._attempt_unlock()
# ------------------------------------------------------------------
# Strength workout
# ------------------------------------------------------------------
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,
) = entries
return entries
def ask_strength_details(self) -> None:
"""Display strength training input form."""
2025-11-18 18:07:15 +01:00
self.clear_container()
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,
)
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]!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],
) -> 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 _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:
self._verify_strength_data_inner()
except ValueError:
self.show_error("Please enter valid data in correct format")
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)
2026-02-23 22:50:42 +01:00
self._attempt_unlock()
# ------------------------------------------------------------------
# Phone workout verification via ADB + StrongLifts DB
# ------------------------------------------------------------------
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
"""Run an ADB command and return success flag and stdout."""
adb = shutil.which("adb") or "adb"
try:
result = subprocess.run(
[adb, *args],
capture_output=True,
text=True,
timeout=ADB_TIMEOUT,
check=False,
)
except (FileNotFoundError, OSError) as exc:
_logger.warning("ADB not available: %s", exc)
return False, ""
except subprocess.TimeoutExpired:
_logger.warning("ADB command timed out: %s", args)
return False, ""
return result.returncode == 0, result.stdout
def _adb_shell(
self,
command: str,
*,
root: bool = False,
) -> tuple[bool, str]:
"""Run a shell command on the connected Android device."""
if root:
return self._run_adb(["shell", "su", "-c", command])
return self._run_adb(["shell", command])
def _is_phone_connected(self) -> bool:
"""Check if an Android device is connected via ADB."""
success, output = self._run_adb(["devices"])
if not success:
return False
lines = output.strip().split("\n")[1:]
return any("device" in line and "offline" not in line for line in lines)
def _pull_stronglifts_db(self) -> Path | None:
"""Pull StrongLifts database from phone to a local temp file.
Returns:
Path to the local copy, or None on failure.
"""
tmp = Path(tempfile.gettempdir()) / "stronglifts_check.db"
success, _ = self._adb_shell(
f"cat '{STRONGLIFTS_DB_REMOTE}' > /sdcard/_sl_tmp.db",
root=True,
)
if not success:
return None
ok, _ = self._run_adb(["pull", "/sdcard/_sl_tmp.db", str(tmp)])
if not ok:
return None
return tmp
def _count_today_workouts(self, db_path: Path) -> int:
"""Count today's workouts in a local copy of StrongLifts DB.
Args:
db_path: Path to the locally-pulled StrongLifts database.
Returns:
Number of workouts started today (local time).
"""
try:
conn = sqlite3.connect(str(db_path))
try:
cursor = conn.execute(
"SELECT COUNT(*) FROM workouts "
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
"= date('now', 'localtime')",
)
row = cursor.fetchone()
return int(row[0]) if row else 0
finally:
conn.close()
except (sqlite3.Error, ValueError, TypeError):
_logger.warning("Failed to query StrongLifts database")
return 0
def _verify_phone_workout(self) -> tuple[str, str]:
"""Verify workout was recorded in StrongLifts on the phone.
Returns:
Tuple of (status, message) where status is one of:
- "verified": Workout confirmed on phone.
- "not_verified": Phone connected but no workout found.
- "no_phone": No phone connected via ADB.
- "error": Could not access StrongLifts database.
"""
if not self._is_phone_connected():
return "no_phone", "No phone connected via ADB"
local_db = self._pull_stronglifts_db()
if local_db is None:
return "error", "StrongLifts database not found on phone"
count = self._count_today_workouts(local_db)
if count > 0:
return (
"verified",
f"Workout verified! ({count} session(s) found on phone)",
)
return "not_verified", "No workout found on phone today"
def _attempt_unlock(self) -> None:
"""Unlock screen after workout form submission."""
self.unlock_screen()
2026-02-23 22:50:42 +01:00
def _show_phone_penalty(
self, message: str, *, on_done: Callable[[], None] | None = None
) -> None:
"""Show penalty countdown when phone verification is unavailable."""
self.clear_container()
self._phone_penalty_done_fn: Callable[[], None] = (
on_done if on_done is not None else self.unlock_screen
)
delay = (
PHONE_PENALTY_DELAY_DEMO
if self.demo_mode
else PHONE_PENALTY_DELAY_PRODUCTION
)
self._label(
"Cannot Verify Workout",
font_size=36,
color="#ff8800",
pady=20,
)
self._text(message, color="#ffaa00")
self._text(
"Connect phone via ADB to skip this wait,\n"
"or wait for the penalty timer.\n\n"
"Note: Phone must be rooted and StrongLifts installed.",
font_size=18,
)
self.phone_penalty_remaining = delay
self.phone_penalty_label = self._label(
str(delay),
font_size=80,
pady=20,
)
self._update_phone_penalty()
def _update_phone_penalty(self) -> None:
"""Update phone penalty countdown."""
if self.phone_penalty_remaining > 0:
self.phone_penalty_label.config(
text=str(self.phone_penalty_remaining),
)
self.phone_penalty_remaining -= 1
self.root.after(1000, self._update_phone_penalty)
else:
self._phone_penalty_done_fn()
# ------------------------------------------------------------------
# Submit timer and entry checking
# ------------------------------------------------------------------
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)
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,
)
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."""
with contextlib.suppress(tk.TclError):
2025-11-18 18:07:15 +01:00
if self.submit_unlock_time > 0:
self._tick_submit_timer()
2025-11-18 18:07:15 +01:00
else:
self._try_enable_submit()
def check_entries_filled(self) -> None:
"""Continuously check if entries are filled after timer expires."""
with contextlib.suppress(tk.TclError):
self._try_enable_submit()
# ------------------------------------------------------------------
# Error, unlock, and logging
# ------------------------------------------------------------------
def show_error(self, message: str) -> None:
"""Display error message with retry option."""
2025-11-18 18:07:15 +01:00
self.clear_container()
self._label("ERROR", font_size=48, color="#ff4444", pady=20)
2025-11-18 18:07:15 +01:00
msg_label = tk.Label(
self.container,
text=message,
font=("Arial", 24),
fg="white",
bg="#1a1a1a",
wraplength=800,
2025-11-18 18:07:15 +01:00
)
msg_label.pack(pady=20)
self._button(
2025-11-18 18:07:15 +01:00
self.container,
"TRY AGAIN",
bg="#0066cc",
2025-11-18 18:07:15 +01:00
command=self.ask_workout_done,
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."""
2025-11-18 18:07:15 +01:00
self.save_workout_log()
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
2025-11-18 18:07:15 +01:00
self.clear_container()
self._label("Great job! 💪", font_size=48, color="#00ff00", pady=30)
if shutdown_adjusted:
self._text(
"Shutdown time +1.5h later! 🎁",
font_size=24,
color="#ffaa00",
)
self._text("Screen Unlocked!", font_size=36, pady=20)
2025-11-18 18:07:15 +01:00
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():
2025-11-18 18:07:15 +01:00
return False
2025-11-18 18:07:15 +01:00
try:
with self.log_file.open() as f:
2025-11-18 18:07:15 +01:00
logs = json.load(f)
except (OSError, json.JSONDecodeError):
2025-11-18 18:07:15 +01:00
return False
else:
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."""
logs = self._load_existing_logs()
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
2025-11-18 18:07:15 +01:00
logs[today] = {
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"workout_data": self.workout_data,
2025-11-18 18:07:15 +01:00
}
try:
with self.log_file.open("w") as f:
2025-11-18 18:07:15 +01:00
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."""
2025-11-18 18:07:15 +01:00
self.root.destroy()
sys.exit(0)
def run(self) -> None:
"""Start the Tkinter main event loop."""
2025-11-18 18:07:15 +01:00
self.root.mainloop()
if __name__ == "__main__":
2025-11-18 18:07:15 +01:00
# Check for --production flag
demo_mode = True # Default to demo mode for safety
if len(sys.argv) > 1 and sys.argv[1] == "--production":
2025-11-18 18:07:15 +01:00
demo_mode = False
2025-11-18 18:07:15 +01:00
locker = ScreenLocker(demo_mode=demo_mode)
locker.run()