feat; added workout screen lock

This commit is contained in:
Krzysztof kuhy Rudnicki 2025-11-18 18:07:15 +01:00
parent c1390d4c18
commit 5a6095bd8f
6 changed files with 744 additions and 0 deletions

View File

@ -0,0 +1,52 @@
#!/bin/bash
# Script to add screen locker to i3 autostart
# This will run the workout screen locker on system startup
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py"
I3_CONFIG="$HOME/.config/i3/config"
# Check if screen_lock.py exists
if [ ! -f "$SCREEN_LOCK_PATH" ]; then
echo "Error: screen_lock.py not found at $SCREEN_LOCK_PATH"
exit 1
fi
# Make sure screen_lock.py is executable
chmod +x "$SCREEN_LOCK_PATH"
# Check if i3 config exists
if [ ! -f "$I3_CONFIG" ]; then
echo "Error: i3 config not found at $I3_CONFIG"
echo "Please create i3 config first or specify correct path"
exit 1
fi
# Check if autostart line already exists
if grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
echo "Screen locker autostart already configured in i3 config"
echo "Current line:"
grep "exec.*screen_lock.py" "$I3_CONFIG"
read -p "Do you want to replace it? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
# Remove old line
sed -i '/exec.*screen_lock\.py/d' "$I3_CONFIG"
else
echo "Keeping existing configuration"
exit 0
fi
fi
# Add autostart line to i3 config
echo "" >> "$I3_CONFIG"
echo "# Workout screen locker on startup (demo mode)" >> "$I3_CONFIG"
echo "exec --no-startup-id python3 $SCREEN_LOCK_PATH" >> "$I3_CONFIG"
echo "✓ Screen locker added to i3 autostart (demo mode)"
echo "✓ Configuration added to: $I3_CONFIG"
echo ""
echo "The screen locker will run on next i3 restart/login"
echo ""
echo "To test now, run: i3-msg restart"
echo "To switch to production mode later, edit $I3_CONFIG and add --production flag"

View File

@ -0,0 +1,30 @@
#!/bin/bash
# Install workout locker as a systemd user service
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
USER_SERVICE_DIR="$HOME/.config/systemd/user"
SERVICE_NAME="workout-locker.service"
# Create user systemd directory if it doesn't exist
mkdir -p "$USER_SERVICE_DIR"
# Copy service file to user systemd directory
cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME"
# Update the ExecStart path in the service file to use absolute path
sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 $SCRIPT_DIR/screen_lock.py|" "$USER_SERVICE_DIR/$SERVICE_NAME"
# Reload systemd daemon
systemctl --user daemon-reload
# Enable the service to start on login
systemctl --user enable "$SERVICE_NAME"
echo "✓ Workout locker service installed"
echo "✓ Service will start automatically on next login"
echo ""
echo "To start now: systemctl --user start workout-locker"
echo "To check status: systemctl --user status workout-locker"
echo "To stop: systemctl --user stop workout-locker"
echo "To disable autostart: systemctl --user disable workout-locker"

View File

@ -0,0 +1,33 @@
#!/bin/bash
# Script to remove screen locker from i3 autostart
I3_CONFIG="$HOME/.config/i3/config"
# Check if i3 config exists
if [ ! -f "$I3_CONFIG" ]; then
echo "Error: i3 config not found at $I3_CONFIG"
exit 1
fi
# Check if autostart line exists
if ! grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
echo "Screen locker autostart not found in i3 config"
exit 0
fi
# Show what will be removed
echo "Found screen locker configuration:"
grep -B1 "exec.*screen_lock.py" "$I3_CONFIG"
echo ""
read -p "Remove screen locker from autostart? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
# Remove the autostart lines
sed -i '/# Workout screen locker on startup/d' "$I3_CONFIG"
sed -i '/exec.*screen_lock\.py/d' "$I3_CONFIG"
echo "✓ Screen locker removed from i3 autostart"
echo "Changes will take effect on next i3 restart"
else
echo "Cancelled"
fi

View File

@ -0,0 +1,19 @@
#!/bin/bash
# Remove workout locker systemd service
SERVICE_NAME="workout-locker.service"
USER_SERVICE_DIR="$HOME/.config/systemd/user"
# Stop the service if running
systemctl --user stop "$SERVICE_NAME" 2>/dev/null
# Disable the service
systemctl --user disable "$SERVICE_NAME" 2>/dev/null
# Remove service file
rm -f "$USER_SERVICE_DIR/$SERVICE_NAME"
# Reload systemd daemon
systemctl --user daemon-reload
echo "✓ Workout locker service removed"

View File

@ -0,0 +1,596 @@
#!/usr/bin/env python3
"""
Screen locker with workout verification for Arch Linux / i3wm
Requires user to log their workout to unlock the screen.
"""
import tkinter as tk
import time
import sys
import re
import json
import os
from datetime import datetime
class ScreenLocker:
def __init__(self, demo_mode=True):
# Set up log file path
script_dir = os.path.dirname(os.path.abspath(__file__))
self.log_file = os.path.join(script_dir, 'workout_log.json')
# Check if already logged today
if self.has_logged_today():
print("Workout already logged today. Skipping screen lock.")
sys.exit(0)
self.root = tk.Tk()
self.root.title("Workout Locker" + (" [DEMO MODE]" if demo_mode else ""))
self.demo_mode = demo_mode
self.lockout_time = 10 if demo_mode else 1800 # 10 seconds for demo, 30 minutes for production
self.workout_data = {}
# Get total screen dimensions across all monitors
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
# Override redirect to bypass window manager (needed for multi-monitor spanning)
self.root.overrideredirect(True)
# Position window at 0,0 and span all monitors
self.root.geometry(f'{screen_width}x{screen_height}+0+0')
# Make window fullscreen and on top
self.root.attributes('-fullscreen', True)
self.root.attributes('-topmost', True)
self.root.configure(bg='#1a1a1a', cursor='arrow')
if demo_mode:
# Demo mode: only close button allowed
# Add close button in top-left corner
close_btn = tk.Button(
self.root,
text="✕ Close Demo",
font=('Arial', 12),
bg='#ff4444',
fg='white',
command=self.close,
cursor='hand2'
)
close_btn.place(x=10, y=10)
# Create main container
self.container = tk.Frame(self.root, bg='#1a1a1a')
self.container.place(relx=0.5, rely=0.5, anchor='center')
# Start with initial question
self.ask_workout_done()
# Force window to update and grab input after everything is set up
self.root.update_idletasks()
self.root.focus_force()
self.root.grab_set_global()
def clear_container(self):
for widget in self.container.winfo_children():
widget.destroy()
def ask_workout_done(self):
self.clear_container()
question = tk.Label(
self.container,
text="Did you work out today?",
font=('Arial', 36, 'bold'),
fg='white',
bg='#1a1a1a'
)
question.pack(pady=30)
button_frame = tk.Frame(self.container, bg='#1a1a1a')
button_frame.pack(pady=20)
yes_btn = tk.Button(
button_frame,
text="YES",
font=('Arial', 24, 'bold'),
bg='#00aa00',
fg='white',
width=10,
command=self.ask_workout_type,
cursor='hand2' if self.demo_mode else ''
)
yes_btn.pack(side='left', padx=20)
no_btn = tk.Button(
button_frame,
text="NO",
font=('Arial', 24, 'bold'),
bg='#aa0000',
fg='white',
width=10,
command=self.lockout,
cursor='hand2' if self.demo_mode else ''
)
no_btn.pack(side='left', padx=20)
def lockout(self):
self.clear_container()
self.lockout_label = tk.Label(
self.container,
text=f"Go work out!\nLocked for {self.lockout_time} seconds",
font=('Arial', 48, 'bold'),
fg='#ff4444',
bg='#1a1a1a'
)
self.lockout_label.pack(pady=30)
self.countdown_label = tk.Label(
self.container,
text=str(self.lockout_time),
font=('Arial', 120, 'bold'),
fg='white',
bg='#1a1a1a'
)
self.countdown_label.pack(pady=30)
self.remaining_time = self.lockout_time
self.update_lockout_countdown()
def update_lockout_countdown(self):
if self.remaining_time > 0:
self.countdown_label.config(text=str(self.remaining_time))
self.remaining_time -= 1
self.root.after(1000, self.update_lockout_countdown)
else:
self.ask_workout_done()
def ask_workout_type(self):
self.clear_container()
question = tk.Label(
self.container,
text="What type of workout?",
font=('Arial', 36, 'bold'),
fg='white',
bg='#1a1a1a'
)
question.pack(pady=30)
button_frame = tk.Frame(self.container, bg='#1a1a1a')
button_frame.pack(pady=20)
running_btn = tk.Button(
button_frame,
text="RUNNING",
font=('Arial', 24, 'bold'),
bg='#0066cc',
fg='white',
width=15,
command=self.ask_running_details,
cursor='hand2' if self.demo_mode else ''
)
running_btn.pack(side='left', padx=20)
strength_btn = tk.Button(
button_frame,
text="STRENGTH",
font=('Arial', 24, 'bold'),
bg='#cc6600',
fg='white',
width=15,
command=self.ask_strength_details,
cursor='hand2' if self.demo_mode else ''
)
strength_btn.pack(side='left', padx=20)
def ask_running_details(self):
self.clear_container()
self.workout_data['type'] = 'running'
title = tk.Label(
self.container,
text="Running Details",
font=('Arial', 36, 'bold'),
fg='white',
bg='#1a1a1a'
)
title.pack(pady=20)
# Distance
dist_frame = tk.Frame(self.container, bg='#1a1a1a')
dist_frame.pack(pady=10)
tk.Label(dist_frame, text="Distance (km):", font=('Arial', 20), fg='white', bg='#1a1a1a').pack(side='left', padx=10)
self.distance_entry = tk.Entry(dist_frame, font=('Arial', 20), width=10)
self.distance_entry.pack(side='left', padx=10)
# Time
time_frame = tk.Frame(self.container, bg='#1a1a1a')
time_frame.pack(pady=10)
tk.Label(time_frame, text="Time (minutes):", font=('Arial', 20), fg='white', bg='#1a1a1a').pack(side='left', padx=10)
self.time_entry = tk.Entry(time_frame, font=('Arial', 20), width=10)
self.time_entry.pack(side='left', padx=10)
# Pace
pace_frame = tk.Frame(self.container, bg='#1a1a1a')
pace_frame.pack(pady=10)
tk.Label(pace_frame, text="Pace (min/km):", font=('Arial', 20), fg='white', bg='#1a1a1a').pack(side='left', padx=10)
self.pace_entry = tk.Entry(pace_frame, font=('Arial', 20), width=10)
self.pace_entry.pack(side='left', padx=10)
# Timer countdown label
self.timer_label = tk.Label(
self.container,
text="",
font=('Arial', 16),
fg='#ffaa00',
bg='#1a1a1a'
)
self.timer_label.pack(pady=10)
self.submit_btn = tk.Button(
self.container,
text="SUBMIT (locked)",
font=('Arial', 24, 'bold'),
bg='#666666',
fg='white',
width=15,
state='disabled',
cursor='hand2' if self.demo_mode else ''
)
self.submit_btn.pack(pady=10)
# Back button
back_btn = tk.Button(
self.container,
text="← BACK",
font=('Arial', 18),
bg='#666666',
fg='white',
width=15,
command=self.ask_workout_type,
cursor='hand2' if self.demo_mode else ''
)
back_btn.pack(pady=10)
# Start 30 second timer
self.submit_unlock_time = 30
self.entries_to_check = [self.distance_entry, self.time_entry, self.pace_entry]
self.submit_command = self.verify_running_data
self.update_submit_timer()
def verify_running_data(self):
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 > 100:
self.show_error("Distance seems unrealistic (0-100 km)")
return
if time_mins <= 0 or time_mins > 600:
self.show_error("Time seems unrealistic (0-600 minutes)")
return
if pace <= 0 or pace > 20:
self.show_error("Pace seems unrealistic (0-20 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! Expected ~{expected_pace:.2f} min/km, got {pace:.2f}")
return
# Data looks good
self.unlock_screen()
except ValueError:
self.show_error("Please enter valid numbers")
def ask_strength_details(self):
self.clear_container()
self.workout_data['type'] = 'strength'
title = tk.Label(
self.container,
text="Strength Training Details",
font=('Arial', 36, 'bold'),
fg='white',
bg='#1a1a1a'
)
title.pack(pady=20)
# Exercises
ex_frame = tk.Frame(self.container, bg='#1a1a1a')
ex_frame.pack(pady=10)
tk.Label(ex_frame, text="Exercises (comma-separated):", font=('Arial', 18), fg='white', bg='#1a1a1a').pack(side='left', padx=10)
self.exercises_entry = tk.Entry(ex_frame, font=('Arial', 18), width=30)
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
reps_frame = tk.Frame(self.container, bg='#1a1a1a')
reps_frame.pack(pady=10)
tk.Label(reps_frame, text="Reps per set (comma-separated):", font=('Arial', 18), fg='white', bg='#1a1a1a').pack(side='left', padx=10)
self.reps_entry = tk.Entry(reps_frame, font=('Arial', 18), width=20)
self.reps_entry.pack(side='left', padx=10)
# Weights
weights_frame = tk.Frame(self.container, bg='#1a1a1a')
weights_frame.pack(pady=10)
tk.Label(weights_frame, text="Weight per exercise in kg (comma-separated):", font=('Arial', 18), fg='white', bg='#1a1a1a').pack(side='left', padx=10)
self.weights_entry = tk.Entry(weights_frame, font=('Arial', 18), width=20)
self.weights_entry.pack(side='left', padx=10)
# Total weight lifted
total_frame = tk.Frame(self.container, bg='#1a1a1a')
total_frame.pack(pady=10)
tk.Label(total_frame, text="Total weight lifted (kg):", font=('Arial', 18), fg='white', bg='#1a1a1a').pack(side='left', padx=10)
self.total_weight_entry = tk.Entry(total_frame, font=('Arial', 18), width=15)
self.total_weight_entry.pack(side='left', padx=10)
# Timer countdown label
self.timer_label = tk.Label(
self.container,
text="",
font=('Arial', 16),
fg='#ffaa00',
bg='#1a1a1a'
)
self.timer_label.pack(pady=10)
self.submit_btn = tk.Button(
self.container,
text="SUBMIT (locked)",
font=('Arial', 24, 'bold'),
bg='#666666',
fg='white',
width=15,
state='disabled',
cursor='hand2' if self.demo_mode else ''
)
self.submit_btn.pack(pady=10)
# Back button
back_btn = tk.Button(
self.container,
text="← BACK",
font=('Arial', 18),
bg='#666666',
fg='white',
width=15,
command=self.ask_workout_type,
cursor='hand2' if self.demo_mode else ''
)
back_btn.pack(pady=10)
# Start 30 second timer
self.submit_unlock_time = 30
self.entries_to_check = [self.exercises_entry, self.sets_entry, self.reps_entry, self.weights_entry, self.total_weight_entry]
self.submit_command = self.verify_strength_data
self.update_submit_timer()
def verify_strength_data(self):
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 = [int(r.strip()) for r in self.reps_entry.get().split(',')]
weights = [float(w.strip()) for w in self.weights_entry.get().split(',')]
total_weight = float(self.total_weight_entry.get())
# Check all lists have same length
if not (len(exercises) == len(sets) == len(reps) == len(weights)):
self.show_error("Number of exercises, sets, reps, and weights must match")
return
# Check for empty or lazy entries
if any(len(ex) < 3 for ex in exercises):
self.show_error("Exercise names too short - be specific")
return
# Sanity checks
if any(s < 1 or s > 20 for s in sets):
self.show_error("Sets should be between 1-20")
return
if any(r < 1 or r > 100 for r in reps):
self.show_error("Reps should be between 1-100")
return
if any(w < 0 or w > 500 for w in weights):
self.show_error("Weights should be between 0-500 kg")
return
# Calculate expected total weight
expected_total = sum(sets[i] * reps[i] * weights[i] for i in range(len(exercises)))
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! Expected ~{expected_total:.1f} kg, got {total_weight:.1f}")
return
# Data looks good
self.unlock_screen()
except ValueError:
self.show_error("Please enter valid data in correct format")
def update_submit_timer(self):
"""Update countdown timer and check if submit can be enabled"""
# Check if widgets still exist (user might have clicked back)
try:
if self.submit_unlock_time > 0:
self.timer_label.config(text=f"Submit available in {self.submit_unlock_time} seconds...")
self.submit_unlock_time -= 1
self.root.after(1000, self.update_submit_timer)
else:
# Timer finished, check if all entries have data
all_filled = all(entry.get().strip() for entry in self.entries_to_check)
if all_filled:
# Enable submit button
self.submit_btn.config(
text="SUBMIT",
state='normal',
bg='#00aa00',
command=self.submit_command
)
self.timer_label.config(text="You can now submit!")
else:
# Check again in 1 second
self.timer_label.config(text="Fill all fields to enable submit")
self.root.after(1000, self.check_entries_filled)
except tk.TclError:
# Widgets were destroyed (user clicked back), stop the timer
pass
def check_entries_filled(self):
"""Continuously check if entries are filled after timer expires"""
try:
all_filled = all(entry.get().strip() for entry in self.entries_to_check)
if all_filled:
self.submit_btn.config(
text="SUBMIT",
state='normal',
bg='#00aa00',
command=self.submit_command
)
self.timer_label.config(text="You can now submit!")
else:
self.timer_label.config(text="Fill all fields to enable submit")
self.root.after(1000, self.check_entries_filled)
except tk.TclError:
# Widgets were destroyed (user clicked back), stop checking
pass
def show_error(self, message):
self.clear_container()
error_label = tk.Label(
self.container,
text="ERROR",
font=('Arial', 48, 'bold'),
fg='#ff4444',
bg='#1a1a1a'
)
error_label.pack(pady=20)
msg_label = tk.Label(
self.container,
text=message,
font=('Arial', 24),
fg='white',
bg='#1a1a1a',
wraplength=800
)
msg_label.pack(pady=20)
retry_btn = tk.Button(
self.container,
text="TRY AGAIN",
font=('Arial', 24, 'bold'),
bg='#0066cc',
fg='white',
width=15,
command=self.ask_workout_done,
cursor='hand2' if self.demo_mode else ''
)
retry_btn.pack(pady=30)
def unlock_screen(self):
# Save workout data to log
self.save_workout_log()
self.clear_container()
success_label = tk.Label(
self.container,
text="Great job! 💪",
font=('Arial', 48, 'bold'),
fg='#00ff00',
bg='#1a1a1a'
)
success_label.pack(pady=30)
unlock_label = tk.Label(
self.container,
text="Screen Unlocked!",
font=('Arial', 36),
fg='white',
bg='#1a1a1a'
)
unlock_label.pack(pady=20)
self.root.after(1500, self.close)
def has_logged_today(self):
"""Check if workout has been logged today"""
if not os.path.exists(self.log_file):
return False
try:
with open(self.log_file, 'r') as f:
logs = json.load(f)
today = datetime.now().strftime('%Y-%m-%d')
return today in logs
except (json.JSONDecodeError, IOError):
return False
def save_workout_log(self):
"""Save workout data to log file"""
# Load existing logs
logs = {}
if os.path.exists(self.log_file):
try:
with open(self.log_file, 'r') as f:
logs = json.load(f)
except (json.JSONDecodeError, IOError):
logs = {}
# Add today's workout
today = datetime.now().strftime('%Y-%m-%d')
logs[today] = {
'timestamp': datetime.now().isoformat(),
'workout_data': self.workout_data
}
# Save updated logs
try:
with open(self.log_file, 'w') as f:
json.dump(logs, f, indent=2)
except IOError as e:
print(f"Warning: Could not save workout log: {e}")
def close(self):
self.root.destroy()
sys.exit(0)
def run(self):
self.root.mainloop()
if __name__ == '__main__':
# Check for --production flag
demo_mode = True # Default to demo mode for safety
if len(sys.argv) > 1 and sys.argv[1] == '--production':
demo_mode = False
locker = ScreenLocker(demo_mode=demo_mode)
locker.run()

View File

@ -0,0 +1,14 @@
[Unit]
Description=Workout Screen Locker
After=graphical-session.target
[Service]
Type=simple
Environment=DISPLAY=:0
ExecStartPre=/bin/sleep 3
ExecStart=/usr/bin/python3 /home/kuhy/testsAndMisc/PYTHON/screen_locker/screen_lock.py
Restart=no
User=%u
[Install]
WantedBy=graphical-session.target