diff --git a/.fvmrc b/.fvmrc
new file mode 100644
index 0000000..5a4e0c2
--- /dev/null
+++ b/.fvmrc
@@ -0,0 +1,3 @@
+{
+ "flutter": "stable"
+}
diff --git a/.gitignore b/.gitignore
index 7b299c6..0cd5adc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -277,3 +277,6 @@ python_pkg/anki_decks/warsaw_districts/warszawa-dzielnice.geojson
# Wikipedia cache (can be refreshed)
python_pkg/anki_decks/polish_license_plates/.wikipedia_cache/
python_pkg/cinema_planner/pasted_content.txt
+
+# FVM Version Cache
+.fvm/
diff --git a/.vscode/settings.json b/.vscode/settings.json
index bdd0151..c726874 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -2,5 +2,6 @@
"files.associations": {
"*.py": "python",
"stdio.h": "c"
- }
+ },
+ "dart.flutterSdkPath": ".fvm/versions/stable"
}
diff --git a/linux_configuration/hosts/install.sh b/linux_configuration/hosts/install.sh
index 98ba020..d99f3c6 100755
--- a/linux_configuration/hosts/install.sh
+++ b/linux_configuration/hosts/install.sh
@@ -293,6 +293,64 @@ tee -a /etc/hosts >/dev/null <<'EOF'
0.0.0.0 r1---sn-4g5e6nls.googlevideo.com
0.0.0.0 r1---sn-4g5lne7s.googlevideo.com
+# Alternative YouTube Frontends (Invidious, Piped, etc.)
+0.0.0.0 invidious.io
+0.0.0.0 www.invidious.io
+0.0.0.0 invidio.us
+0.0.0.0 vid.puffyan.us
+0.0.0.0 invidious.snopyta.org
+0.0.0.0 yewtu.be
+0.0.0.0 invidious.kavin.rocks
+0.0.0.0 inv.riverside.rocks
+0.0.0.0 invidious.namazso.eu
+0.0.0.0 invidious.nerdvpn.de
+0.0.0.0 invidious.projectsegfau.lt
+0.0.0.0 invidious.slipfox.xyz
+0.0.0.0 invidious.privacydev.net
+0.0.0.0 invidious.perennialte.ch
+0.0.0.0 invidious.protokoll-11.de
+0.0.0.0 invidious.einfachzocken.eu
+0.0.0.0 invidious.fdn.fr
+0.0.0.0 inv.in.projectsegfau.lt
+0.0.0.0 invidious.tiekoetter.com
+0.0.0.0 invidious.lunar.icu
+0.0.0.0 iv.ggtyler.dev
+0.0.0.0 iv.melmac.space
+0.0.0.0 piped.video
+0.0.0.0 www.piped.video
+0.0.0.0 piped.kavin.rocks
+0.0.0.0 piped.mha.fi
+0.0.0.0 piped.mint.lgbt
+0.0.0.0 piped.projectsegfau.lt
+0.0.0.0 piped.privacydev.net
+0.0.0.0 piped.smnz.de
+0.0.0.0 piped.adminforge.de
+0.0.0.0 watch.whatever.social
+0.0.0.0 piped.lunar.icu
+0.0.0.0 viewtube.io
+0.0.0.0 www.viewtube.io
+0.0.0.0 freetube.io
+0.0.0.0 www.freetube.io
+0.0.0.0 tubo.media
+0.0.0.0 www.tubo.media
+0.0.0.0 materialious.nadeko.net
+0.0.0.0 clipious.org
+0.0.0.0 www.clipious.org
+0.0.0.0 newpipe.net
+0.0.0.0 www.newpipe.net
+0.0.0.0 newpipe.schabi.org
+0.0.0.0 grayjay.app
+0.0.0.0 www.grayjay.app
+0.0.0.0 libretube.dev
+0.0.0.0 www.libretube.dev
+0.0.0.0 hyperion.deishelon.com
+0.0.0.0 inv.n8pjl.ca
+0.0.0.0 inv.zzls.xyz
+0.0.0.0 inv.tux.pizza
+0.0.0.0 invidious.incogniweb.net
+0.0.0.0 invidious.drgns.space
+0.0.0.0 invidious.io.lol
+
# Steam Store
# Discord - media allowed
@@ -310,38 +368,129 @@ tee -a /etc/hosts >/dev/null <<'EOF'
0.0.0.0 pyszne.pl
0.0.0.0 www.pyszne.pl
0.0.0.0 m.pyszne.pl
+0.0.0.0 api.pyszne.pl
+0.0.0.0 app.pyszne.pl
0.0.0.0 glovo.com
0.0.0.0 www.glovo.com
0.0.0.0 m.glovo.com
+0.0.0.0 api.glovo.com
+0.0.0.0 glovoapp.com
+0.0.0.0 www.glovoapp.com
0.0.0.0 bolt.eu
+:: bolt.eu
+0.0.0.0 www.bolt.eu
+:: www.bolt.eu
0.0.0.0 food.bolt.eu
+:: food.bolt.eu
+0.0.0.0 m.bolt.eu
+:: m.bolt.eu
+0.0.0.0 api.bolt.eu
+:: api.bolt.eu
+0.0.0.0 node.bolt.eu
+:: node.bolt.eu
+0.0.0.0 gw.bolt.eu
+:: gw.bolt.eu
+0.0.0.0 client-api.bolt.eu
+:: client-api.bolt.eu
+0.0.0.0 auth.bolt.eu
+:: auth.bolt.eu
+0.0.0.0 cdn.bolt.eu
+:: cdn.bolt.eu
+0.0.0.0 images.bolt.eu
+:: images.bolt.eu
+0.0.0.0 static.bolt.eu
+:: static.bolt.eu
+0.0.0.0 assets.bolt.eu
+:: assets.bolt.eu
+0.0.0.0 fleet.bolt.eu
+:: fleet.bolt.eu
+0.0.0.0 user.bolt.eu
+:: user.bolt.eu
+0.0.0.0 courier.bolt.eu
+:: courier.bolt.eu
+0.0.0.0 rider.bolt.eu
+:: rider.bolt.eu
+0.0.0.0 restaurant.bolt.eu
+:: restaurant.bolt.eu
+0.0.0.0 partner-food.bolt.eu
+:: partner-food.bolt.eu
0.0.0.0 woltwojta.pl
0.0.0.0 www.woltwojta.pl
0.0.0.0 wolt.com
0.0.0.0 www.wolt.com
0.0.0.0 m.wolt.com
+0.0.0.0 api.wolt.com
+0.0.0.0 restaurant-api.wolt.com
+0.0.0.0 consumer-api.wolt.com
+0.0.0.0 jush.pl
+0.0.0.0 www.jush.pl
+0.0.0.0 m.jush.pl
+0.0.0.0 api.jush.pl
+0.0.0.0 delio.pl
+0.0.0.0 www.delio.pl
+0.0.0.0 m.delio.pl
+0.0.0.0 api.delio.pl
+0.0.0.0 delio.com
+0.0.0.0 www.delio.com
+0.0.0.0 delio.com.pl
+0.0.0.0 www.delio.com.pl
+0.0.0.0 api.delio.com.pl
+0.0.0.0 lisek.app
+0.0.0.0 www.lisek.app
+0.0.0.0 api.lisek.app
+0.0.0.0 stava.app
+0.0.0.0 www.stava.app
+0.0.0.0 api.stava.app
+0.0.0.0 biedronka.pl
+0.0.0.0 zakupy.biedronka.pl
+0.0.0.0 ezakupy.tesco.pl
+0.0.0.0 www.ezakupy.tesco.pl
+0.0.0.0 barbora.pl
+0.0.0.0 www.barbora.pl
+0.0.0.0 api.barbora.pl
+0.0.0.0 frisco.pl
+0.0.0.0 www.frisco.pl
+0.0.0.0 api.frisco.pl
+0.0.0.0 swiatkwiatow.pl
+0.0.0.0 www.swiatkwiatow.pl
+0.0.0.0 allegro.pl/kategoria/jedzenie
+0.0.0.0 szama.pl
+0.0.0.0 www.szama.pl
+0.0.0.0 api.szama.pl
+0.0.0.0 auchandirect.pl
+0.0.0.0 www.auchandirect.pl
# International services
0.0.0.0 ubereats.com
0.0.0.0 www.ubereats.com
0.0.0.0 m.ubereats.com
+0.0.0.0 api.ubereats.com
0.0.0.0 uber.com
0.0.0.0 www.uber.com
0.0.0.0 m.uber.com
+0.0.0.0 api.uber.com
+0.0.0.0 cn-geo1.uber.com
+0.0.0.0 login.uber.com
+0.0.0.0 auth.uber.com
+0.0.0.0 riders.uber.com
0.0.0.0 deliveroo.com
0.0.0.0 www.deliveroo.com
0.0.0.0 m.deliveroo.com
+0.0.0.0 api.deliveroo.com
0.0.0.0 deliveroo.co.uk
0.0.0.0 www.deliveroo.co.uk
0.0.0.0 foodpanda.com
0.0.0.0 www.foodpanda.com
0.0.0.0 m.foodpanda.com
+0.0.0.0 api.foodpanda.com
0.0.0.0 grubhub.com
0.0.0.0 www.grubhub.com
0.0.0.0 m.grubhub.com
+0.0.0.0 api.grubhub.com
0.0.0.0 doordash.com
0.0.0.0 www.doordash.com
0.0.0.0 m.doordash.com
+0.0.0.0 api.doordash.com
0.0.0.0 justeat.com
0.0.0.0 www.justeat.com
0.0.0.0 m.justeat.com
@@ -349,12 +498,31 @@ tee -a /etc/hosts >/dev/null <<'EOF'
0.0.0.0 www.justeat.co.uk
0.0.0.0 postmates.com
0.0.0.0 www.postmates.com
+0.0.0.0 api.postmates.com
0.0.0.0 seamless.com
0.0.0.0 www.seamless.com
0.0.0.0 menulog.com.au
0.0.0.0 www.menulog.com.au
0.0.0.0 delivery.com
0.0.0.0 www.delivery.com
+0.0.0.0 getir.com
+0.0.0.0 www.getir.com
+0.0.0.0 api.getir.com
+0.0.0.0 flink.com
+0.0.0.0 www.flink.com
+0.0.0.0 api.flink.com
+0.0.0.0 gorillas.io
+0.0.0.0 www.gorillas.io
+0.0.0.0 api.gorillas.io
+0.0.0.0 gopuff.com
+0.0.0.0 www.gopuff.com
+0.0.0.0 api.gopuff.com
+0.0.0.0 instacart.com
+0.0.0.0 www.instacart.com
+0.0.0.0 api.instacart.com
+0.0.0.0 takeaway.com
+0.0.0.0 www.takeaway.com
+0.0.0.0 api.takeaway.com
# Fast food chain apps and websites
0.0.0.0 mcdonalds.com
diff --git a/linux_configuration/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt b/linux_configuration/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt
index aa9d9ac..5ec691f 100644
--- a/linux_configuration/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt
+++ b/linux_configuration/scripts/digital_wellbeing/pacman/pacman_blocked_keywords.txt
@@ -56,4 +56,7 @@ youtube
# Chrome/Chromium variants
google-chrome
chromium
-ungoogled-chromium
\ No newline at end of file
+ungoogled-chromium
+# VirtualBox (can bypass /etc/hosts restrictions)
+virtualbox
+vbox
diff --git a/linux_configuration/scripts/digital_wellbeing/pacman/pacman_wrapper.sh b/linux_configuration/scripts/digital_wellbeing/pacman/pacman_wrapper.sh
index 3b3a155..2912492 100755
--- a/linux_configuration/scripts/digital_wellbeing/pacman/pacman_wrapper.sh
+++ b/linux_configuration/scripts/digital_wellbeing/pacman/pacman_wrapper.sh
@@ -453,22 +453,11 @@ function is_steam_package() {
[[ $1 == "steam" ]]
}
-# Helper to check if a package name is VirtualBox (hardcoded, cannot be bypassed by editing policy files)
-function is_virtualbox_package() {
- local pkg_lower="${1,,}"
- [[ $pkg_lower == *"virtualbox"* || $pkg_lower == *"vbox"* ]]
-}
-
# Function to check if user is trying to install steam (challenge-eligible package)
function check_for_steam() {
check_install_for is_steam_package "$@"
}
-# Function to check if user is trying to install VirtualBox (hardcoded enforcement)
-function check_for_virtualbox() {
- check_install_for is_virtualbox_package "$@"
-}
-
# Function to check if current day is a weekday (after 4PM Friday until midnight Sunday)
function is_weekday() {
local day_of_week
@@ -636,27 +625,6 @@ function prompt_for_greylist_challenge() {
run_word_challenge "Greylist" 6 120 90 30 15 20
}
-# Function to prompt for VirtualBox installation (enhanced security, hardcoded)
-function prompt_for_virtualbox_challenge() {
- echo -e "${RED}═══════════════════════════════════════════════════════${NC}"
- echo -e "${RED} VIRTUALBOX INSTALLATION ATTEMPT DETECTED ${NC}"
- echo -e "${RED}═══════════════════════════════════════════════════════${NC}"
- echo -e "${YELLOW}WARNING: You are trying to install VirtualBox.${NC}"
- echo -e "${YELLOW}This package can be used to bypass /etc/hosts restrictions.${NC}"
- echo -e ""
- echo -e "${CYAN}Security measures will be automatically applied:${NC}"
- echo -e " 1. VMs will use host's DNS resolution"
- echo -e " 2. Host's /etc/hosts will be shared with VMs (read-only)"
- echo -e " 3. Policy enforcement cannot be disabled via file editing"
- echo -e ""
- echo -e "${YELLOW}This is a HARDCODED restriction that cannot be bypassed by${NC}"
- echo -e "${YELLOW}modifying policy files or reinstalling the wrapper.${NC}"
- echo -e ""
-
- # More difficult challenge: word_length=7, words_count=150, timeout=120s, initial_delay=45, post_delay=30-50
- run_word_challenge "VirtualBox Security" 7 150 120 45 30 20
-}
-
# Check for wrapper-specific commands
if [[ $1 == "--help-wrapper" ]]; then
show_help
@@ -693,13 +661,6 @@ if check_for_steam "$@"; then
fi
fi
-# Check for VirtualBox (HARDCODED - cannot be bypassed by editing policy files)
-if check_for_virtualbox "$@"; then
- if ! prompt_for_virtualbox_challenge; then
- exit 1
- fi
-fi
-
# Check for greylisted packages (challenge-eligible)
if check_for_greylisted "$@"; then
if ! prompt_for_greylist_challenge; then
@@ -813,71 +774,59 @@ auto_install_leechblock() {
auto_install_leechblock "$@"
-# If VirtualBox was involved in this operation, enforce hosts file sharing
-enforce_vbox_hosts_if_needed() {
- # Only check after install operations
- if [[ -z ${1:-} ]]; then
+# If VirtualBox is installed, automatically remove all VMs
+auto_remove_virtualbox_vms() {
+ # Check if VBoxManage is available
+ if ! command -v VBoxManage &>/dev/null; then
return 0
fi
- if [[ $1 != "-S"* && $1 != "-U"* ]]; then
+ # Determine real user (wrapper may run as root via sudo)
+ local real_user="${SUDO_USER:-$USER}"
+
+ # Get list of registered VMs (run as real user since VMs are per-user)
+ local vm_list
+ vm_list=$(sudo -u "$real_user" VBoxManage list vms 2>/dev/null) || return 0
+
+ if [[ -z $vm_list ]]; then
return 0
fi
- # Check if ANY VirtualBox package is installed (use broader search)
- local vbox_installed=0
- if "$PACMAN_BIN" -Qq 2>/dev/null | grep -Eq '^(virtualbox|vbox)'; then
- vbox_installed=1
- fi
+ echo -e "${RED}═══════════════════════════════════════════════════════${NC}" >&2
+ echo -e "${RED} VIRTUALBOX VMs DETECTED - AUTO-REMOVING ${NC}" >&2
+ echo -e "${RED}═══════════════════════════════════════════════════════${NC}" >&2
- if [[ $vbox_installed -eq 0 ]]; then
- return 0
- fi
+ local vm_name
+ local success=0
+ local failed=0
- # Locate the enforcement script
- local script_dir
- script_dir="$(dirname "$(readlink -f "$0")")"
- local vbox_enforce_script=""
-
- # Try to find the enforcement script
- if [[ -f "$script_dir/../virtualbox/enforce_vbox_hosts.sh" ]]; then
- vbox_enforce_script="$script_dir/../virtualbox/enforce_vbox_hosts.sh"
- elif [[ -f "$HOME/linux-configuration/scripts/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" ]]; then
- vbox_enforce_script="$HOME/linux-configuration/scripts/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh"
- elif [[ -f "/usr/local/share/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh" ]]; then
- vbox_enforce_script="/usr/local/share/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh"
- fi
-
- if [[ -z $vbox_enforce_script ]]; then
- echo -e "${YELLOW}VirtualBox detected but enforcement script not found. Hosts file may not be enforced in VMs.${NC}" >&2
- return 0
- fi
-
- # Check if enforcement is already applied
- if bash "$vbox_enforce_script" check >/dev/null 2>&1; then
- return 0
- fi
-
- # VirtualBox is installed but enforcement not applied - this is critical
- echo -e "${YELLOW}VirtualBox detected. Applying /etc/hosts enforcement to VMs...${NC}" >&2
- # Note: The wrapper may be running as non-root user (via sudo pacman), but enforcement
- # script needs root. We check EUID to avoid double sudo if already running as root.
- if [[ $EUID -ne 0 ]]; then
- if ! sudo bash "$vbox_enforce_script" enforce; then
- echo -e "${RED}CRITICAL: Failed to enforce hosts on VirtualBox VMs!${NC}" >&2
- echo -e "${RED}VMs may bypass /etc/hosts restrictions. Please run manually:${NC}" >&2
- echo -e "${RED} sudo $vbox_enforce_script enforce${NC}" >&2
+ while IFS= read -r line; do
+ # VBoxManage list vms output format: "VM Name" {uuid}
+ vm_name=$(echo "$line" | sed 's/^"\(.*\)" {.*}$/\1/')
+ if [[ -z $vm_name ]]; then
+ continue
fi
- else
- if ! bash "$vbox_enforce_script" enforce; then
- echo -e "${RED}CRITICAL: Failed to enforce hosts on VirtualBox VMs!${NC}" >&2
- echo -e "${RED}VMs may bypass /etc/hosts restrictions. Please run manually:${NC}" >&2
- echo -e "${RED} $vbox_enforce_script enforce${NC}" >&2
+
+ echo -e "${YELLOW}Removing VM: ${vm_name}${NC}" >&2
+
+ # Power off the VM if it's running
+ sudo -u "$real_user" VBoxManage controlvm "$vm_name" poweroff 2>/dev/null || true
+ sleep 1
+
+ # Unregister and delete all files
+ if sudo -u "$real_user" VBoxManage unregistervm "$vm_name" --delete 2>/dev/null; then
+ echo -e "${GREEN} Removed: ${vm_name}${NC}" >&2
+ ((++success))
+ else
+ echo -e "${RED} Failed to remove: ${vm_name}${NC}" >&2
+ ((++failed))
fi
- fi
+ done <<< "$vm_list"
+
+ echo -e "${CYAN}VM removal complete: ${success} removed, ${failed} failed.${NC}" >&2
}
-enforce_vbox_hosts_if_needed "$@"
+auto_remove_virtualbox_vms
# Display some helpful tips depending on the operation
if [[ $1 == "-S" || $1 == "-S "* ]] && [ $exit_code -eq 0 ]; then
diff --git a/linux_configuration/scripts/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh b/linux_configuration/scripts/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh
old mode 100644
new mode 100755
index 972b517..3454535
--- a/linux_configuration/scripts/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh
+++ b/linux_configuration/scripts/digital_wellbeing/virtualbox/enforce_vbox_hosts.sh
@@ -17,7 +17,16 @@ NC='\033[0m' # No Color
if [ "$EUID" -ne 0 ]; then
echo -e "${YELLOW}This script requires root privileges to configure VirtualBox VMs.${NC}"
echo -e "${CYAN}Executing with sudo...${NC}"
- exec sudo "$0" "$@"
+ exec sudo bash "$0" "$@"
+fi
+
+# Determine the real (non-root) user who invoked this script.
+# VBoxManage must run as this user because VMs are registered per-user.
+REAL_USER="${SUDO_USER:-$USER}"
+if [[ $REAL_USER == "root" ]]; then
+ echo -e "${RED}Cannot determine the real user. Do not run this script as root directly.${NC}"
+ echo -e "${RED}Run it as a normal user (it will auto-sudo as needed).${NC}"
+ exit 1
fi
# Check if VBoxManage is available
@@ -26,30 +35,35 @@ if ! command -v VBoxManage > /dev/null 2>&1; then
exit 1
fi
+# Run VBoxManage as the real user so it sees their registered VMs
+vboxmanage_as_user() {
+ sudo -u "$REAL_USER" VBoxManage "$@"
+}
+
# Configuration
VBOX_SHARED_FOLDER_NAME="host_etc"
HOSTS_ENFORCEMENT_MARKER="/var/lib/vbox-hosts-enforced"
# Get list of all VMs
get_all_vms() {
- VBoxManage list vms | awk -F'"' '{print $2}'
+ vboxmanage_as_user list vms | awk -F'"' '{print $2}'
}
# Get list of running VMs
get_running_vms() {
- VBoxManage list runningvms | awk -F'"' '{print $2}'
+ vboxmanage_as_user list runningvms | awk -F'"' '{print $2}'
}
# Configure a VM to use host DNS (NAT network)
configure_vm_dns() {
local vm_name="$1"
echo -e "${BLUE}Configuring DNS for VM: ${vm_name}${NC}"
-
+
# Enable DNS proxy for NAT adapter (adapter 1 by default)
# This makes the VM use the host's DNS resolution
- VBoxManage modifyvm "$vm_name" --natdnshostresolver1 on 2>/dev/null || true
- VBoxManage modifyvm "$vm_name" --natdnsproxy1 on 2>/dev/null || true
-
+ vboxmanage_as_user modifyvm "$vm_name" --natdnshostresolver1 on 2>/dev/null || true
+ vboxmanage_as_user modifyvm "$vm_name" --natdnsproxy1 on 2>/dev/null || true
+
echo -e "${GREEN}DNS configuration applied to ${vm_name}${NC}"
}
@@ -57,12 +71,12 @@ configure_vm_dns() {
configure_hosts_shared_folder() {
local vm_name="$1"
echo -e "${BLUE}Setting up /etc/hosts sharing for VM: ${vm_name}${NC}"
-
+
# Remove existing shared folder if present
- VBoxManage sharedfolder remove "$vm_name" --name "$VBOX_SHARED_FOLDER_NAME" 2>/dev/null || true
-
+ vboxmanage_as_user sharedfolder remove "$vm_name" --name "$VBOX_SHARED_FOLDER_NAME" 2>/dev/null || true
+
# Add /etc as a shared folder (read-only)
- VBoxManage sharedfolder add "$vm_name" \
+ vboxmanage_as_user sharedfolder add "$vm_name" \
--name "$VBOX_SHARED_FOLDER_NAME" \
--hostpath "/etc" \
--readonly \
@@ -70,7 +84,7 @@ configure_hosts_shared_folder() {
echo -e "${YELLOW}Could not add shared folder to ${vm_name} (VM may be running)${NC}"
return 1
}
-
+
echo -e "${GREEN}Shared folder configured for ${vm_name}${NC}"
return 0
}
@@ -78,7 +92,7 @@ configure_hosts_shared_folder() {
# Create a startup script that can be placed in VMs
generate_vm_startup_script() {
local output_file="${1:-/tmp/vbox_hosts_sync.sh}"
-
+
cat > "$output_file" << 'EOF'
#!/bin/bash
# VirtualBox VM startup script to sync /etc/hosts from host machine
@@ -99,14 +113,14 @@ is_virtualbox() {
return 0
fi
fi
-
+
# Then try dmidecode (requires root, but script should already be running as root)
if command -v dmidecode > /dev/null 2>&1; then
if dmidecode -s system-product-name 2>/dev/null | grep -qi "VirtualBox"; then
return 0
fi
fi
-
+
return 1
}
@@ -137,11 +151,11 @@ if [ -f "$HOST_HOSTS_FILE" ]; then
if [ ! -f "$BACKUP_HOSTS_FILE" ]; then
cp "$VM_HOSTS_FILE" "$BACKUP_HOSTS_FILE"
fi
-
+
# Copy host's hosts file to VM
cp "$HOST_HOSTS_FILE" "$VM_HOSTS_FILE"
echo "Synced /etc/hosts from host machine"
-
+
# Make it harder to modify (though not impossible in VM)
chmod 444 "$VM_HOSTS_FILE"
fi
@@ -152,60 +166,202 @@ EOF
echo -e "${CYAN}Copy this script to your VMs and add it to their startup (e.g., /etc/rc.local or systemd)${NC}"
}
+# Get the disk image path for a VM (first SATA/IDE .vdi/.vmdk/.vhd)
+get_vm_disk_path() {
+ local vm_name="$1"
+ vboxmanage_as_user showvminfo "$vm_name" --machinereadable 2>/dev/null \
+ | grep -E '^"(SATA|IDE|SCSI|NVMe)-[0-9]+-[0-9]+"=' \
+ | grep -vE '="none"$' \
+ | grep -vE '\.iso"$' \
+ | head -1 \
+ | sed 's/^[^=]*="//; s/"$//'
+}
+
+# Inject host's /etc/hosts directly into a VM disk image using qemu-nbd.
+# This is the only reliable way to enforce blocking, because NAT DNS proxy
+# alone does not work when the guest browser uses DNS-over-HTTPS (DoH).
+inject_hosts_into_vm_disk() {
+ local vm_name="$1"
+ local disk_path
+ disk_path="$(get_vm_disk_path "$vm_name")"
+
+ if [[ -z $disk_path || ! -f $disk_path ]]; then
+ echo -e "${YELLOW}Could not find disk image for VM '${vm_name}', skipping hosts injection${NC}"
+ return 1
+ fi
+
+ # Ensure VM is not running
+ if vboxmanage_as_user list runningvms 2>/dev/null | grep -q "\"${vm_name}\""; then
+ echo -e "${YELLOW}VM '${vm_name}' is running, cannot inject hosts file. Stop it first.${NC}"
+ return 1
+ fi
+
+ # Check for qemu-nbd
+ if ! command -v qemu-nbd > /dev/null 2>&1; then
+ echo -e "${YELLOW}qemu-nbd not found. Install qemu-base to enable hosts file injection.${NC}"
+ return 1
+ fi
+
+ echo -e "${BLUE}Injecting /etc/hosts into disk image for VM: ${vm_name}${NC}"
+
+ # Load nbd module if needed
+ if [[ ! -e /dev/nbd0 ]]; then
+ modprobe nbd max_part=8 2>/dev/null || {
+ echo -e "${YELLOW}Could not load nbd kernel module${NC}"
+ return 1
+ }
+ fi
+
+ # Find a free nbd device
+ local nbd_dev=""
+ for dev in /dev/nbd{0..15}; do
+ if [[ -e $dev ]] && ! lsblk "$dev" > /dev/null 2>&1; then
+ nbd_dev="$dev"
+ break
+ fi
+ done
+ # Fallback: try /dev/nbd0 if no device was found via lsblk check
+ if [[ -z $nbd_dev ]]; then
+ nbd_dev="/dev/nbd0"
+ fi
+
+ local mount_point="/tmp/vbox_hosts_inject_$$"
+
+ # Connect disk image
+ qemu-nbd --connect="$nbd_dev" "$disk_path" 2>/dev/null || {
+ echo -e "${YELLOW}Could not connect disk image via qemu-nbd${NC}"
+ return 1
+ }
+
+ # Wait for partitions to appear
+ sleep 1
+ partprobe "$nbd_dev" 2>/dev/null || true
+
+ # Find the root partition (first Linux partition)
+ local part=""
+ for p in "${nbd_dev}p1" "${nbd_dev}p2" "${nbd_dev}p3"; do
+ if [[ -b $p ]]; then
+ part="$p"
+ break
+ fi
+ done
+
+ if [[ -z $part ]]; then
+ echo -e "${YELLOW}No partitions found on disk image${NC}"
+ qemu-nbd --disconnect "$nbd_dev" 2>/dev/null || true
+ return 1
+ fi
+
+ # Mount the partition
+ mkdir -p "$mount_point"
+ if ! mount "$part" "$mount_point" 2>/dev/null; then
+ # Journal may need recovery — run e2fsck then retry
+ e2fsck -y "$part" > /dev/null 2>&1 || true
+ if ! mount "$part" "$mount_point" 2>/dev/null; then
+ echo -e "${YELLOW}Could not mount partition $part${NC}"
+ qemu-nbd --disconnect "$nbd_dev" 2>/dev/null || true
+ rmdir "$mount_point" 2>/dev/null || true
+ return 1
+ fi
+ fi
+
+ # Check if this partition has /etc/hosts (i.e., it's the root fs)
+ if [[ ! -f "$mount_point/etc/hosts" ]]; then
+ echo -e "${YELLOW}Partition does not appear to be root filesystem (no /etc/hosts)${NC}"
+ umount "$mount_point" 2>/dev/null || true
+ qemu-nbd --disconnect "$nbd_dev" 2>/dev/null || true
+ rmdir "$mount_point" 2>/dev/null || true
+ return 1
+ fi
+
+ # Backup original if not already backed up
+ if [[ ! -f "$mount_point/etc/hosts.original" ]]; then
+ cp "$mount_point/etc/hosts" "$mount_point/etc/hosts.original"
+ echo -e "${CYAN}Backed up original hosts file${NC}"
+ fi
+
+ # Copy host's /etc/hosts into VM
+ cp /etc/hosts "$mount_point/etc/hosts"
+ chmod 444 "$mount_point/etc/hosts"
+
+ local blocked_count
+ blocked_count="$(grep -c '0.0.0.0' "$mount_point/etc/hosts")"
+
+ # Cleanup: unmount and disconnect
+ umount "$mount_point" 2>/dev/null || true
+ qemu-nbd --disconnect "$nbd_dev" 2>/dev/null || true
+ rmdir "$mount_point" 2>/dev/null || true
+
+ echo -e "${GREEN}Hosts file injected into VM '${vm_name}' (${blocked_count} domains blocked)${NC}"
+ return 0
+}
+
# Apply enforcement to all VMs
enforce_all_vms() {
local -a vms
mapfile -t vms < <(get_all_vms)
-
+
if [[ ${#vms[@]} -eq 0 ]]; then
echo -e "${YELLOW}No VirtualBox VMs found.${NC}"
return 0
fi
-
+
echo -e "${CYAN}Found ${#vms[@]} VM(s). Applying /etc/hosts enforcement...${NC}"
-
+
local success=0
local failed=0
-
+
for vm in "${vms[@]}"; do
echo -e "\n${BLUE}Processing VM: ${vm}${NC}"
-
+
# Configure DNS settings (works even when VM is running)
configure_vm_dns "$vm"
-
+
# Try to configure shared folder (only works when VM is stopped)
if configure_hosts_shared_folder "$vm"; then
- ((success++))
+ ((++success))
else
- ((failed++))
+ ((++failed))
echo -e "${YELLOW}Note: Stop the VM and run this script again to add shared folder${NC}"
fi
+
+ # Inject hosts file directly into VM disk (the actual enforcement)
+ inject_hosts_into_vm_disk "$vm" || true
done
-
+
echo -e "\n${GREEN}Enforcement complete!${NC}"
echo -e "Successfully configured: ${success} VM(s)"
[[ $failed -gt 0 ]] && echo -e "${YELLOW}Needs VM shutdown for full config: ${failed} VM(s)${NC}"
-
+
# Mark that enforcement has been applied
touch "$HOSTS_ENFORCEMENT_MARKER"
}
-# Check if enforcement is needed
+# Check if a single VM has the shared folder configured
+vm_has_shared_folder() {
+ local vm_name="$1"
+ vboxmanage_as_user showvminfo "$vm_name" --machinereadable 2>/dev/null \
+ | grep -q "SharedFolderNameMachineMapping.*=\"${VBOX_SHARED_FOLDER_NAME}\""
+}
+
+# Check if enforcement is applied to ALL registered VMs
check_enforcement_status() {
local -a vms
mapfile -t vms < <(get_all_vms)
-
+
if [[ ${#vms[@]} -eq 0 ]]; then
echo -e "${GREEN}No VMs to enforce.${NC}"
return 0
fi
-
- if [[ ! -f $HOSTS_ENFORCEMENT_MARKER ]]; then
- echo -e "${YELLOW}Hosts enforcement has not been applied to VMs.${NC}"
- return 1
- fi
-
- echo -e "${GREEN}Hosts enforcement marker found.${NC}"
+
+ for vm in "${vms[@]}"; do
+ if ! vm_has_shared_folder "$vm"; then
+ echo -e "${YELLOW}VM '${vm}' is missing hosts enforcement.${NC}"
+ return 1
+ fi
+ done
+
+ echo -e "${GREEN}All ${#vms[@]} VM(s) have hosts enforcement applied.${NC}"
return 0
}
@@ -213,34 +369,39 @@ check_enforcement_status() {
show_status() {
echo -e "${CYAN}VirtualBox Hosts Enforcement Status${NC}"
echo -e "${CYAN}====================================${NC}\n"
-
+
local -a all_vms running_vms
mapfile -t all_vms < <(get_all_vms)
mapfile -t running_vms < <(get_running_vms)
-
+
echo -e "Total VMs: ${#all_vms[@]}"
echo -e "Running VMs: ${#running_vms[@]}"
-
- if [[ -f $HOSTS_ENFORCEMENT_MARKER ]]; then
- echo -e "Enforcement status: ${GREEN}Applied${NC}"
+
+ if check_enforcement_status > /dev/null 2>&1; then
+ echo -e "Enforcement status: ${GREEN}Applied to all VMs${NC}"
else
- echo -e "Enforcement status: ${RED}Not applied${NC}"
+ echo -e "Enforcement status: ${RED}Not fully applied${NC}"
fi
-
+
echo -e "\n${CYAN}VMs:${NC}"
for vm in "${all_vms[@]}"; do
- local running=""
+ local flags=""
if printf '%s\n' "${running_vms[@]}" | grep -qx "$vm"; then
- running=" ${GREEN}[RUNNING]${NC}"
+ flags+=" ${GREEN}[RUNNING]${NC}"
fi
- echo -e " - ${vm}${running}"
+ if vm_has_shared_folder "$vm"; then
+ flags+=" ${GREEN}[ENFORCED]${NC}"
+ else
+ flags+=" ${RED}[NOT ENFORCED]${NC}"
+ fi
+ echo -e " - ${vm}${flags}"
done
}
# Main function
main() {
local action="${1:-enforce}"
-
+
case "$action" in
enforce|apply)
enforce_all_vms
diff --git a/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt b/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt
index b95aae1..d0ffe2e 100644
--- a/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt
+++ b/linux_configuration/scripts/utils/android_guardian/blocked_apps.txt
@@ -5,19 +5,54 @@
# ===== Food Delivery Apps =====
# Uber Eats
com.ubercab.eats
+com.ubercab
# Glovo
com.glovo
+com.glovo.courier
# Wolt
com.wolt.android
-# Bolt Food
+# Bolt Food / Bolt
ee.mtakso.food
+ee.mtakso
# Pyszne.pl / Takeaway
com.takeaway.android
com.pyszne.pl
+com.takeaway.android.pl
+
+# Jush (Polish quick delivery)
+pl.jush.app
+pl.jush.android
+
+# Delio (Polish delivery)
+pl.delio.app
+pl.delio.android
+com.delio.app
+
+# Lisek (Polish quick delivery)
+pl.lisek.app
+com.lisek.app
+
+# Stava (Polish delivery)
+pl.stava.app
+
+# Barbora (Polish grocery delivery)
+pl.barbora.app
+com.barbora.pl
+
+# Frisco (Polish grocery delivery)
+pl.frisco.app
+com.frisco.android
+
+# Szama (Polish food delivery)
+pl.szama.app
+
+# Biedronka online grocery
+pl.biedronka.app
+com.jeronimo.biedronka
# DoorDash
com.dd.doordash
@@ -41,11 +76,28 @@ com.seamless.consumer
# Foodpanda
com.global.foodpanda.android
+# Getir (quick delivery)
+com.getir
+com.getir.express
+
+# Flink
+com.flink.android
+com.flink.consumer
+
+# GoPuff
+com.gopuff.android
+
+# Instacart
+com.instacart.client
+
+# Gorillas
+com.gorillas.consumer
+
# ===== Browsers (to prevent bypassing blocks) =====
-# Firefox
-org.mozilla.firefox
-org.mozilla.firefox_beta
-org.mozilla.fenix
+# Firefox (allowed - hosts blocking is sufficient)
+# org.mozilla.firefox
+# org.mozilla.firefox_beta
+# org.mozilla.fenix
# Chrome (comment out if needed for some functionality)
# com.android.chrome
@@ -83,6 +135,37 @@ org.torproject.torbrowser
com.google.android.youtube
com.vanced.android.youtube
app.revanced.android.youtube
+app.revanced.android.apps.youtube.music
+
+# ===== Alternative YouTube Frontend Apps =====
+# NewPipe
+org.schabi.newpipe
+org.schabi.newpipelegacy
+
+# LibreTube
+com.github.libretube
+
+# SkyTube
+free.rm.skytube.oss
+free.rm.skytube.extra
+
+# Clipious (Invidious client)
+com.github.lamarios.clipious
+
+# Hyperion (YouTube client)
+us.spotco.hyperion
+
+# GrayJay
+com.futo.platformplayer
+
+# PipePipe
+InfinityLoop1309.NewPipeEnhanced
+
+# Materialious
+com.materialious.app
+
+# VueTube
+app.vuetube.app
# ===== Fast Food Apps =====
# McDonald's
diff --git a/linux_configuration/scripts/utils/android_guardian/service.sh b/linux_configuration/scripts/utils/android_guardian/service.sh
index 8729326..8861ae4 100755
--- a/linux_configuration/scripts/utils/android_guardian/service.sh
+++ b/linux_configuration/scripts/utils/android_guardian/service.sh
@@ -28,6 +28,12 @@ log() {
log "=== Android Guardian starting ==="
+# Enable wireless ADB on boot (persistent port 5555)
+setprop service.adb.tcp.port 5555
+stop adbd
+start adbd
+log "Wireless ADB enabled on port 5555"
+
# Function to check if guardian is enabled (via ADB control, not Magisk UI)
is_enabled() {
[ "$(cat "$CONTROL_FILE" 2> /dev/null)" = "ENABLED" ]
diff --git a/linux_configuration/scripts/utils/update_android_hosts.sh b/linux_configuration/scripts/utils/update_android_hosts.sh
index 47f7f6c..d53e763 100755
--- a/linux_configuration/scripts/utils/update_android_hosts.sh
+++ b/linux_configuration/scripts/utils/update_android_hosts.sh
@@ -54,7 +54,7 @@ Commands:
block-app Add an app to block list
unblock-app Remove an app from block list
list-blocked Show blocked apps list
-
+
pair Pair with device over WiFi (Android 11+, no USB needed)
connect Connect to already-paired device over WiFi
disconnect Disconnect wireless ADB
@@ -335,6 +335,64 @@ build_module() {
0.0.0.0 i9.ytimg.com
0.0.0.0 googlevideo.com
+# Alternative YouTube Frontends (Invidious, Piped, etc.)
+0.0.0.0 invidious.io
+0.0.0.0 www.invidious.io
+0.0.0.0 invidio.us
+0.0.0.0 vid.puffyan.us
+0.0.0.0 invidious.snopyta.org
+0.0.0.0 yewtu.be
+0.0.0.0 invidious.kavin.rocks
+0.0.0.0 inv.riverside.rocks
+0.0.0.0 invidious.namazso.eu
+0.0.0.0 invidious.nerdvpn.de
+0.0.0.0 invidious.projectsegfau.lt
+0.0.0.0 invidious.slipfox.xyz
+0.0.0.0 invidious.privacydev.net
+0.0.0.0 invidious.perennialte.ch
+0.0.0.0 invidious.protokoll-11.de
+0.0.0.0 invidious.einfachzocken.eu
+0.0.0.0 invidious.fdn.fr
+0.0.0.0 inv.in.projectsegfau.lt
+0.0.0.0 invidious.tiekoetter.com
+0.0.0.0 invidious.lunar.icu
+0.0.0.0 iv.ggtyler.dev
+0.0.0.0 iv.melmac.space
+0.0.0.0 piped.video
+0.0.0.0 www.piped.video
+0.0.0.0 piped.kavin.rocks
+0.0.0.0 piped.mha.fi
+0.0.0.0 piped.mint.lgbt
+0.0.0.0 piped.projectsegfau.lt
+0.0.0.0 piped.privacydev.net
+0.0.0.0 piped.smnz.de
+0.0.0.0 piped.adminforge.de
+0.0.0.0 watch.whatever.social
+0.0.0.0 piped.lunar.icu
+0.0.0.0 viewtube.io
+0.0.0.0 www.viewtube.io
+0.0.0.0 freetube.io
+0.0.0.0 www.freetube.io
+0.0.0.0 tubo.media
+0.0.0.0 www.tubo.media
+0.0.0.0 materialious.nadeko.net
+0.0.0.0 clipious.org
+0.0.0.0 www.clipious.org
+0.0.0.0 newpipe.net
+0.0.0.0 www.newpipe.net
+0.0.0.0 newpipe.schabi.org
+0.0.0.0 grayjay.app
+0.0.0.0 www.grayjay.app
+0.0.0.0 libretube.dev
+0.0.0.0 www.libretube.dev
+0.0.0.0 hyperion.deishelon.com
+0.0.0.0 inv.n8pjl.ca
+0.0.0.0 inv.zzls.xyz
+0.0.0.0 inv.tux.pizza
+0.0.0.0 invidious.incogniweb.net
+0.0.0.0 invidious.drgns.space
+0.0.0.0 invidious.io.lol
+
# Discord (media only - voice chat allowed)
0.0.0.0 cdn.discordapp.com
0.0.0.0 media.discordapp.net
@@ -344,26 +402,149 @@ build_module() {
0.0.0.0 giphy.com
# Food Delivery Services
+# Polish services
0.0.0.0 pyszne.pl
0.0.0.0 www.pyszne.pl
+0.0.0.0 m.pyszne.pl
+0.0.0.0 api.pyszne.pl
+0.0.0.0 app.pyszne.pl
0.0.0.0 glovo.com
0.0.0.0 www.glovo.com
+0.0.0.0 m.glovo.com
+0.0.0.0 api.glovo.com
+0.0.0.0 glovoapp.com
+0.0.0.0 www.glovoapp.com
0.0.0.0 bolt.eu
+:: bolt.eu
+0.0.0.0 www.bolt.eu
+:: www.bolt.eu
0.0.0.0 food.bolt.eu
+:: food.bolt.eu
+0.0.0.0 m.bolt.eu
+:: m.bolt.eu
+0.0.0.0 api.bolt.eu
+:: api.bolt.eu
+0.0.0.0 node.bolt.eu
+:: node.bolt.eu
+0.0.0.0 gw.bolt.eu
+:: gw.bolt.eu
+0.0.0.0 client-api.bolt.eu
+:: client-api.bolt.eu
+0.0.0.0 auth.bolt.eu
+:: auth.bolt.eu
+0.0.0.0 cdn.bolt.eu
+:: cdn.bolt.eu
+0.0.0.0 images.bolt.eu
+:: images.bolt.eu
+0.0.0.0 static.bolt.eu
+:: static.bolt.eu
+0.0.0.0 assets.bolt.eu
+:: assets.bolt.eu
+0.0.0.0 fleet.bolt.eu
+:: fleet.bolt.eu
+0.0.0.0 user.bolt.eu
+:: user.bolt.eu
+0.0.0.0 courier.bolt.eu
+:: courier.bolt.eu
+0.0.0.0 rider.bolt.eu
+:: rider.bolt.eu
+0.0.0.0 restaurant.bolt.eu
+:: restaurant.bolt.eu
+0.0.0.0 partner-food.bolt.eu
+:: partner-food.bolt.eu
0.0.0.0 wolt.com
0.0.0.0 www.wolt.com
+0.0.0.0 m.wolt.com
+0.0.0.0 api.wolt.com
+0.0.0.0 restaurant-api.wolt.com
+0.0.0.0 consumer-api.wolt.com
+0.0.0.0 woltwojta.pl
+0.0.0.0 www.woltwojta.pl
+0.0.0.0 jush.pl
+0.0.0.0 www.jush.pl
+0.0.0.0 m.jush.pl
+0.0.0.0 api.jush.pl
+0.0.0.0 delio.pl
+0.0.0.0 www.delio.pl
+0.0.0.0 m.delio.pl
+0.0.0.0 api.delio.pl
+0.0.0.0 delio.com
+0.0.0.0 www.delio.com
+0.0.0.0 delio.com.pl
+0.0.0.0 www.delio.com.pl
+0.0.0.0 api.delio.com.pl
+0.0.0.0 lisek.app
+0.0.0.0 www.lisek.app
+0.0.0.0 api.lisek.app
+0.0.0.0 stava.app
+0.0.0.0 www.stava.app
+0.0.0.0 api.stava.app
+0.0.0.0 barbora.pl
+0.0.0.0 www.barbora.pl
+0.0.0.0 api.barbora.pl
+0.0.0.0 frisco.pl
+0.0.0.0 www.frisco.pl
+0.0.0.0 api.frisco.pl
+0.0.0.0 szama.pl
+0.0.0.0 www.szama.pl
+0.0.0.0 api.szama.pl
+0.0.0.0 auchandirect.pl
+0.0.0.0 www.auchandirect.pl
+0.0.0.0 zakupy.biedronka.pl
+0.0.0.0 ezakupy.tesco.pl
+0.0.0.0 www.ezakupy.tesco.pl
+# International services
0.0.0.0 ubereats.com
0.0.0.0 www.ubereats.com
+0.0.0.0 m.ubereats.com
+0.0.0.0 api.ubereats.com
+0.0.0.0 uber.com
+0.0.0.0 www.uber.com
+0.0.0.0 m.uber.com
+0.0.0.0 api.uber.com
+0.0.0.0 login.uber.com
+0.0.0.0 auth.uber.com
0.0.0.0 deliveroo.com
0.0.0.0 www.deliveroo.com
+0.0.0.0 m.deliveroo.com
+0.0.0.0 api.deliveroo.com
+0.0.0.0 deliveroo.co.uk
+0.0.0.0 www.deliveroo.co.uk
0.0.0.0 foodpanda.com
0.0.0.0 www.foodpanda.com
+0.0.0.0 m.foodpanda.com
+0.0.0.0 api.foodpanda.com
0.0.0.0 grubhub.com
0.0.0.0 www.grubhub.com
+0.0.0.0 m.grubhub.com
+0.0.0.0 api.grubhub.com
0.0.0.0 doordash.com
0.0.0.0 www.doordash.com
+0.0.0.0 m.doordash.com
+0.0.0.0 api.doordash.com
0.0.0.0 justeat.com
0.0.0.0 www.justeat.com
+0.0.0.0 m.justeat.com
+0.0.0.0 justeat.co.uk
+0.0.0.0 www.justeat.co.uk
+0.0.0.0 postmates.com
+0.0.0.0 www.postmates.com
+0.0.0.0 seamless.com
+0.0.0.0 www.seamless.com
+0.0.0.0 getir.com
+0.0.0.0 www.getir.com
+0.0.0.0 api.getir.com
+0.0.0.0 flink.com
+0.0.0.0 www.flink.com
+0.0.0.0 gorillas.io
+0.0.0.0 www.gorillas.io
+0.0.0.0 gopuff.com
+0.0.0.0 www.gopuff.com
+0.0.0.0 instacart.com
+0.0.0.0 www.instacart.com
+0.0.0.0 takeaway.com
+0.0.0.0 www.takeaway.com
+0.0.0.0 api.takeaway.com
# Fast Food
0.0.0.0 mcdonalds.com
diff --git a/pomodoro_app/.gitignore b/pomodoro_app/.gitignore
new file mode 100644
index 0000000..3820a95
--- /dev/null
+++ b/pomodoro_app/.gitignore
@@ -0,0 +1,45 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/pomodoro_app/.metadata b/pomodoro_app/.metadata
new file mode 100644
index 0000000..2ef18ea
--- /dev/null
+++ b/pomodoro_app/.metadata
@@ -0,0 +1,33 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+ base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+ - platform: android
+ create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+ base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+ - platform: linux
+ create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+ base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/pomodoro_app/README.md b/pomodoro_app/README.md
new file mode 100644
index 0000000..9511899
--- /dev/null
+++ b/pomodoro_app/README.md
@@ -0,0 +1,17 @@
+# pomodoro_app
+
+A new Flutter project.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
+- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
diff --git a/pomodoro_app/analysis_options.yaml b/pomodoro_app/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/pomodoro_app/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/pomodoro_app/android/.gitignore b/pomodoro_app/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/pomodoro_app/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/pomodoro_app/android/app/build.gradle.kts b/pomodoro_app/android/app/build.gradle.kts
new file mode 100644
index 0000000..c47e490
--- /dev/null
+++ b/pomodoro_app/android/app/build.gradle.kts
@@ -0,0 +1,44 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "com.kuhy.pomodoro_app"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "com.kuhy.pomodoro_app"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/pomodoro_app/android/app/src/debug/AndroidManifest.xml b/pomodoro_app/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/pomodoro_app/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/pomodoro_app/android/app/src/main/AndroidManifest.xml b/pomodoro_app/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..05764e4
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pomodoro_app/android/app/src/main/kotlin/com/kuhy/pomodoro_app/MainActivity.kt b/pomodoro_app/android/app/src/main/kotlin/com/kuhy/pomodoro_app/MainActivity.kt
new file mode 100644
index 0000000..fb4a8a4
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/kotlin/com/kuhy/pomodoro_app/MainActivity.kt
@@ -0,0 +1,43 @@
+package com.kuhy.pomodoro_app
+
+import android.content.Context
+import android.net.wifi.WifiManager
+import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
+
+class MainActivity : FlutterActivity() {
+ private var multicastLock: WifiManager.MulticastLock? = null
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+
+ MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ "pomodoro_multicast_lock"
+ ).setMethodCallHandler { call, result ->
+ when (call.method) {
+ "acquire" -> {
+ val wifi = applicationContext
+ .getSystemService(Context.WIFI_SERVICE) as WifiManager
+ multicastLock = wifi.createMulticastLock("pomodoro_sync")
+ multicastLock?.setReferenceCounted(true)
+ multicastLock?.acquire()
+ result.success(true)
+ }
+ "release" -> {
+ multicastLock?.release()
+ multicastLock = null
+ result.success(true)
+ }
+ else -> result.notImplemented()
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ multicastLock?.release()
+ multicastLock = null
+ super.onDestroy()
+ }
+}
diff --git a/pomodoro_app/android/app/src/main/res/drawable-v21/launch_background.xml b/pomodoro_app/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/pomodoro_app/android/app/src/main/res/drawable/launch_background.xml b/pomodoro_app/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/pomodoro_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/pomodoro_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/pomodoro_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/pomodoro_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/pomodoro_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/pomodoro_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/pomodoro_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/pomodoro_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/pomodoro_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/pomodoro_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/pomodoro_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/pomodoro_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/pomodoro_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/pomodoro_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/pomodoro_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/pomodoro_app/android/app/src/main/res/values-night/styles.xml b/pomodoro_app/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/pomodoro_app/android/app/src/main/res/values/styles.xml b/pomodoro_app/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/pomodoro_app/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/pomodoro_app/android/app/src/profile/AndroidManifest.xml b/pomodoro_app/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/pomodoro_app/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/pomodoro_app/android/build.gradle.kts b/pomodoro_app/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/pomodoro_app/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/pomodoro_app/android/gradle.properties b/pomodoro_app/android/gradle.properties
new file mode 100644
index 0000000..fbee1d8
--- /dev/null
+++ b/pomodoro_app/android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
diff --git a/pomodoro_app/android/gradle/wrapper/gradle-wrapper.properties b/pomodoro_app/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e4ef43f
--- /dev/null
+++ b/pomodoro_app/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/pomodoro_app/android/settings.gradle.kts b/pomodoro_app/android/settings.gradle.kts
new file mode 100644
index 0000000..ca7fe06
--- /dev/null
+++ b/pomodoro_app/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")
diff --git a/pomodoro_app/linux/.gitignore b/pomodoro_app/linux/.gitignore
new file mode 100644
index 0000000..d3896c9
--- /dev/null
+++ b/pomodoro_app/linux/.gitignore
@@ -0,0 +1 @@
+flutter/ephemeral
diff --git a/pomodoro_app/linux/CMakeLists.txt b/pomodoro_app/linux/CMakeLists.txt
new file mode 100644
index 0000000..7b63cc8
--- /dev/null
+++ b/pomodoro_app/linux/CMakeLists.txt
@@ -0,0 +1,128 @@
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.13)
+project(runner LANGUAGES CXX)
+
+# The name of the executable created for the application. Change this to change
+# the on-disk name of your application.
+set(BINARY_NAME "pomodoro_app")
+# The unique GTK application identifier for this application. See:
+# https://wiki.gnome.org/HowDoI/ChooseApplicationID
+set(APPLICATION_ID "com.kuhy.pomodoro_app")
+
+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
+# versions of CMake.
+cmake_policy(SET CMP0063 NEW)
+
+# Load bundled libraries from the lib/ directory relative to the binary.
+set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
+
+# Root filesystem for cross-building.
+if(FLUTTER_TARGET_PLATFORM_SYSROOT)
+ set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+ set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+endif()
+
+# Define build configuration options.
+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE "Debug" CACHE
+ STRING "Flutter build mode" FORCE)
+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
+ "Debug" "Profile" "Release")
+endif()
+
+# Compilation settings that should be applied to most targets.
+#
+# Be cautious about adding new options here, as plugins use this function by
+# default. In most cases, you should add new options to specific targets instead
+# of modifying this function.
+function(APPLY_STANDARD_SETTINGS TARGET)
+ target_compile_features(${TARGET} PUBLIC cxx_std_14)
+ target_compile_options(${TARGET} PRIVATE -Wall -Werror)
+ target_compile_options(${TARGET} PRIVATE "$<$>:-O3>")
+ target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>")
+endfunction()
+
+# Flutter library and tool build rules.
+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
+add_subdirectory(${FLUTTER_MANAGED_DIR})
+
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+
+# Application build; see runner/CMakeLists.txt.
+add_subdirectory("runner")
+
+# Run the Flutter tool portions of the build. This must not be removed.
+add_dependencies(${BINARY_NAME} flutter_assemble)
+
+# Only the install-generated bundle's copy of the executable will launch
+# correctly, since the resources must in the right relative locations. To avoid
+# people trying to run the unbundled copy, put it in a subdirectory instead of
+# the default top-level location.
+set_target_properties(${BINARY_NAME}
+ PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
+)
+
+
+# Generated plugin build rules, which manage building the plugins and adding
+# them to the application.
+include(flutter/generated_plugins.cmake)
+
+
+# === Installation ===
+# By default, "installing" just makes a relocatable bundle in the build
+# directory.
+set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
+endif()
+
+# Start with a clean build bundle directory every time.
+install(CODE "
+ file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
+ " COMPONENT Runtime)
+
+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
+
+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
+ install(FILES "${bundled_library}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endforeach(bundled_library)
+
+# Copy the native assets provided by the build.dart from all packages.
+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+# Fully re-copy the assets directory on each build to avoid having stale files
+# from a previous install.
+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
+install(CODE "
+ file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
+ " COMPONENT Runtime)
+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
+ DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
+
+# Install the AOT library on non-Debug builds only.
+if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
+ install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endif()
diff --git a/pomodoro_app/linux/flutter/CMakeLists.txt b/pomodoro_app/linux/flutter/CMakeLists.txt
new file mode 100644
index 0000000..d5bd016
--- /dev/null
+++ b/pomodoro_app/linux/flutter/CMakeLists.txt
@@ -0,0 +1,88 @@
+# This file controls Flutter-level build steps. It should not be edited.
+cmake_minimum_required(VERSION 3.10)
+
+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
+
+# Configuration provided via flutter tool.
+include(${EPHEMERAL_DIR}/generated_config.cmake)
+
+# TODO: Move the rest of this into files in ephemeral. See
+# https://github.com/flutter/flutter/issues/57146.
+
+# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
+# which isn't available in 3.10.
+function(list_prepend LIST_NAME PREFIX)
+ set(NEW_LIST "")
+ foreach(element ${${LIST_NAME}})
+ list(APPEND NEW_LIST "${PREFIX}${element}")
+ endforeach(element)
+ set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
+endfunction()
+
+# === Flutter Library ===
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
+pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
+
+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
+
+# Published to parent scope for install step.
+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
+set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
+
+list(APPEND FLUTTER_LIBRARY_HEADERS
+ "fl_basic_message_channel.h"
+ "fl_binary_codec.h"
+ "fl_binary_messenger.h"
+ "fl_dart_project.h"
+ "fl_engine.h"
+ "fl_json_message_codec.h"
+ "fl_json_method_codec.h"
+ "fl_message_codec.h"
+ "fl_method_call.h"
+ "fl_method_channel.h"
+ "fl_method_codec.h"
+ "fl_method_response.h"
+ "fl_plugin_registrar.h"
+ "fl_plugin_registry.h"
+ "fl_standard_message_codec.h"
+ "fl_standard_method_codec.h"
+ "fl_string_codec.h"
+ "fl_value.h"
+ "fl_view.h"
+ "flutter_linux.h"
+)
+list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
+add_library(flutter INTERFACE)
+target_include_directories(flutter INTERFACE
+ "${EPHEMERAL_DIR}"
+)
+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
+target_link_libraries(flutter INTERFACE
+ PkgConfig::GTK
+ PkgConfig::GLIB
+ PkgConfig::GIO
+)
+add_dependencies(flutter flutter_assemble)
+
+# === Flutter tool backend ===
+# _phony_ is a non-existent file to force this command to run every time,
+# since currently there's no way to get a full input/output list from the
+# flutter tool.
+add_custom_command(
+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
+ ${CMAKE_CURRENT_BINARY_DIR}/_phony_
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${FLUTTER_TOOL_ENVIRONMENT}
+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
+ ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
+ VERBATIM
+)
+add_custom_target(flutter_assemble DEPENDS
+ "${FLUTTER_LIBRARY}"
+ ${FLUTTER_LIBRARY_HEADERS}
+)
diff --git a/pomodoro_app/linux/flutter/generated_plugin_registrant.cc b/pomodoro_app/linux/flutter/generated_plugin_registrant.cc
new file mode 100644
index 0000000..e71a16d
--- /dev/null
+++ b/pomodoro_app/linux/flutter/generated_plugin_registrant.cc
@@ -0,0 +1,11 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#include "generated_plugin_registrant.h"
+
+
+void fl_register_plugins(FlPluginRegistry* registry) {
+}
diff --git a/pomodoro_app/linux/flutter/generated_plugin_registrant.h b/pomodoro_app/linux/flutter/generated_plugin_registrant.h
new file mode 100644
index 0000000..e0f0a47
--- /dev/null
+++ b/pomodoro_app/linux/flutter/generated_plugin_registrant.h
@@ -0,0 +1,15 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GENERATED_PLUGIN_REGISTRANT_
+#define GENERATED_PLUGIN_REGISTRANT_
+
+#include
+
+// Registers Flutter plugins.
+void fl_register_plugins(FlPluginRegistry* registry);
+
+#endif // GENERATED_PLUGIN_REGISTRANT_
diff --git a/pomodoro_app/linux/flutter/generated_plugins.cmake b/pomodoro_app/linux/flutter/generated_plugins.cmake
new file mode 100644
index 0000000..2e1de87
--- /dev/null
+++ b/pomodoro_app/linux/flutter/generated_plugins.cmake
@@ -0,0 +1,23 @@
+#
+# Generated file, do not edit.
+#
+
+list(APPEND FLUTTER_PLUGIN_LIST
+)
+
+list(APPEND FLUTTER_FFI_PLUGIN_LIST
+)
+
+set(PLUGIN_BUNDLED_LIBRARIES)
+
+foreach(plugin ${FLUTTER_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
+ target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES $)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
+endforeach(plugin)
+
+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
+endforeach(ffi_plugin)
diff --git a/pomodoro_app/linux/runner/CMakeLists.txt b/pomodoro_app/linux/runner/CMakeLists.txt
new file mode 100644
index 0000000..e97dabc
--- /dev/null
+++ b/pomodoro_app/linux/runner/CMakeLists.txt
@@ -0,0 +1,26 @@
+cmake_minimum_required(VERSION 3.13)
+project(runner LANGUAGES CXX)
+
+# Define the application target. To change its name, change BINARY_NAME in the
+# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
+# work.
+#
+# Any new source files that you add to the application should be added here.
+add_executable(${BINARY_NAME}
+ "main.cc"
+ "my_application.cc"
+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
+)
+
+# Apply the standard set of build settings. This can be removed for applications
+# that need different build settings.
+apply_standard_settings(${BINARY_NAME})
+
+# Add preprocessor definitions for the application ID.
+add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
+
+# Add dependency libraries. Add any application-specific dependencies here.
+target_link_libraries(${BINARY_NAME} PRIVATE flutter)
+target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
+
+target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
diff --git a/pomodoro_app/linux/runner/main.cc b/pomodoro_app/linux/runner/main.cc
new file mode 100644
index 0000000..e7c5c54
--- /dev/null
+++ b/pomodoro_app/linux/runner/main.cc
@@ -0,0 +1,6 @@
+#include "my_application.h"
+
+int main(int argc, char** argv) {
+ g_autoptr(MyApplication) app = my_application_new();
+ return g_application_run(G_APPLICATION(app), argc, argv);
+}
diff --git a/pomodoro_app/linux/runner/my_application.cc b/pomodoro_app/linux/runner/my_application.cc
new file mode 100644
index 0000000..c9ab02e
--- /dev/null
+++ b/pomodoro_app/linux/runner/my_application.cc
@@ -0,0 +1,148 @@
+#include "my_application.h"
+
+#include
+#ifdef GDK_WINDOWING_X11
+#include
+#endif
+
+#include "flutter/generated_plugin_registrant.h"
+
+struct _MyApplication {
+ GtkApplication parent_instance;
+ char** dart_entrypoint_arguments;
+};
+
+G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
+
+// Called when first Flutter frame received.
+static void first_frame_cb(MyApplication* self, FlView* view) {
+ gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
+}
+
+// Implements GApplication::activate.
+static void my_application_activate(GApplication* application) {
+ MyApplication* self = MY_APPLICATION(application);
+ GtkWindow* window =
+ GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
+
+ // Use a header bar when running in GNOME as this is the common style used
+ // by applications and is the setup most users will be using (e.g. Ubuntu
+ // desktop).
+ // If running on X and not using GNOME then just use a traditional title bar
+ // in case the window manager does more exotic layout, e.g. tiling.
+ // If running on Wayland assume the header bar will work (may need changing
+ // if future cases occur).
+ gboolean use_header_bar = TRUE;
+#ifdef GDK_WINDOWING_X11
+ GdkScreen* screen = gtk_window_get_screen(window);
+ if (GDK_IS_X11_SCREEN(screen)) {
+ const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
+ if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
+ use_header_bar = FALSE;
+ }
+ }
+#endif
+ if (use_header_bar) {
+ GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
+ gtk_widget_show(GTK_WIDGET(header_bar));
+ gtk_header_bar_set_title(header_bar, "pomodoro_app");
+ gtk_header_bar_set_show_close_button(header_bar, TRUE);
+ gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
+ } else {
+ gtk_window_set_title(window, "pomodoro_app");
+ }
+
+ gtk_window_set_default_size(window, 1280, 720);
+
+ g_autoptr(FlDartProject) project = fl_dart_project_new();
+ fl_dart_project_set_dart_entrypoint_arguments(
+ project, self->dart_entrypoint_arguments);
+
+ FlView* view = fl_view_new(project);
+ GdkRGBA background_color;
+ // Background defaults to black, override it here if necessary, e.g. #00000000
+ // for transparent.
+ gdk_rgba_parse(&background_color, "#000000");
+ fl_view_set_background_color(view, &background_color);
+ gtk_widget_show(GTK_WIDGET(view));
+ gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
+
+ // Show the window when Flutter renders.
+ // Requires the view to be realized so we can start rendering.
+ g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
+ self);
+ gtk_widget_realize(GTK_WIDGET(view));
+
+ fl_register_plugins(FL_PLUGIN_REGISTRY(view));
+
+ gtk_widget_grab_focus(GTK_WIDGET(view));
+}
+
+// Implements GApplication::local_command_line.
+static gboolean my_application_local_command_line(GApplication* application,
+ gchar*** arguments,
+ int* exit_status) {
+ MyApplication* self = MY_APPLICATION(application);
+ // Strip out the first argument as it is the binary name.
+ self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
+
+ g_autoptr(GError) error = nullptr;
+ if (!g_application_register(application, nullptr, &error)) {
+ g_warning("Failed to register: %s", error->message);
+ *exit_status = 1;
+ return TRUE;
+ }
+
+ g_application_activate(application);
+ *exit_status = 0;
+
+ return TRUE;
+}
+
+// Implements GApplication::startup.
+static void my_application_startup(GApplication* application) {
+ // MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application startup.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
+}
+
+// Implements GApplication::shutdown.
+static void my_application_shutdown(GApplication* application) {
+ // MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application shutdown.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
+}
+
+// Implements GObject::dispose.
+static void my_application_dispose(GObject* object) {
+ MyApplication* self = MY_APPLICATION(object);
+ g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
+ G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
+}
+
+static void my_application_class_init(MyApplicationClass* klass) {
+ G_APPLICATION_CLASS(klass)->activate = my_application_activate;
+ G_APPLICATION_CLASS(klass)->local_command_line =
+ my_application_local_command_line;
+ G_APPLICATION_CLASS(klass)->startup = my_application_startup;
+ G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
+ G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
+}
+
+static void my_application_init(MyApplication* self) {}
+
+MyApplication* my_application_new() {
+ // Set the program name to the application ID, which helps various systems
+ // like GTK and desktop environments map this running application to its
+ // corresponding .desktop file. This ensures better integration by allowing
+ // the application to be recognized beyond its binary name.
+ g_set_prgname(APPLICATION_ID);
+
+ return MY_APPLICATION(g_object_new(my_application_get_type(),
+ "application-id", APPLICATION_ID, "flags",
+ G_APPLICATION_NON_UNIQUE, nullptr));
+}
diff --git a/pomodoro_app/linux/runner/my_application.h b/pomodoro_app/linux/runner/my_application.h
new file mode 100644
index 0000000..db16367
--- /dev/null
+++ b/pomodoro_app/linux/runner/my_application.h
@@ -0,0 +1,21 @@
+#ifndef FLUTTER_MY_APPLICATION_H_
+#define FLUTTER_MY_APPLICATION_H_
+
+#include
+
+G_DECLARE_FINAL_TYPE(MyApplication,
+ my_application,
+ MY,
+ APPLICATION,
+ GtkApplication)
+
+/**
+ * my_application_new:
+ *
+ * Creates a new Flutter-based application.
+ *
+ * Returns: a new #MyApplication.
+ */
+MyApplication* my_application_new();
+
+#endif // FLUTTER_MY_APPLICATION_H_
diff --git a/pomodoro_app/packaging/arch/.gitignore b/pomodoro_app/packaging/arch/.gitignore
new file mode 100644
index 0000000..3a09c6e
--- /dev/null
+++ b/pomodoro_app/packaging/arch/.gitignore
@@ -0,0 +1,2 @@
+pkg/
+*.pkg.tar.zst
diff --git a/pomodoro_app/packaging/arch/PKGBUILD b/pomodoro_app/packaging/arch/PKGBUILD
new file mode 100644
index 0000000..80384af
--- /dev/null
+++ b/pomodoro_app/packaging/arch/PKGBUILD
@@ -0,0 +1,76 @@
+# shellcheck shell=bash
+# shellcheck disable=SC2034,SC2154
+# SC2034: Variables like pkgver, pkgrel etc. are used by makepkg.
+# SC2154: Variables like startdir, pkgdir are provided by makepkg.
+# Maintainer: kuhy
+pkgname=pomodoro-app
+pkgver=1.0.0
+pkgrel=1
+pkgdesc='A Pomodoro timer with LAN sync between devices'
+arch=('x86_64')
+url='https://github.com/kuhy/testsAndMisc'
+license=('MIT')
+depends=(
+ 'gtk3'
+ 'glib2'
+ 'python'
+ 'zlib'
+)
+makedepends=(
+ 'clang'
+ 'cmake'
+ 'ninja'
+ 'pkg-config'
+)
+
+# Flutter must be available on PATH (e.g. via fvm or manual install).
+# Install: https://docs.flutter.dev/get-started/install/linux/desktop
+
+build() {
+ cd "$startdir/../.." || return
+
+ if ! command -v flutter >/dev/null 2>&1; then
+ # Try common fvm location.
+ export PATH="$HOME/fvm/default/bin:$PATH"
+ fi
+
+ flutter build linux --release
+}
+
+package() {
+ cd "$startdir/../.." || return
+
+ local _bundle="build/linux/x64/release/bundle"
+
+ # Install the main binary.
+ install -Dm755 "$_bundle/pomodoro_app" \
+ "$pkgdir/usr/lib/$pkgname/pomodoro_app"
+
+ # Install bundled shared libraries.
+ install -Dm644 "$_bundle/lib/libapp.so" \
+ "$pkgdir/usr/lib/$pkgname/lib/libapp.so"
+ install -Dm644 "$_bundle/lib/libflutter_linux_gtk.so" \
+ "$pkgdir/usr/lib/$pkgname/lib/libflutter_linux_gtk.so"
+
+ # Install data directory.
+ install -Dm644 "$_bundle/data/icudtl.dat" \
+ "$pkgdir/usr/lib/$pkgname/data/icudtl.dat"
+ cp -r "$_bundle/data/flutter_assets" \
+ "$pkgdir/usr/lib/$pkgname/data/flutter_assets"
+
+ # Install launcher script.
+ install -Dm755 "packaging/arch/pomodoro-app.sh" \
+ "$pkgdir/usr/bin/pomodoro-app"
+
+ # Install desktop entry and icon.
+ install -Dm644 "packaging/arch/pomodoro-app.desktop" \
+ "$pkgdir/usr/share/applications/pomodoro-app.desktop"
+ install -Dm644 "packaging/arch/pomodoro-app.svg" \
+ "$pkgdir/usr/share/icons/hicolor/scalable/apps/pomodoro-app.svg"
+
+ # Install wake daemon and systemd user service.
+ install -Dm755 "packaging/arch/pomodoro-wake-daemon.py" \
+ "$pkgdir/usr/bin/pomodoro-wake-daemon"
+ install -Dm644 "packaging/arch/pomodoro-wake-daemon.service" \
+ "$pkgdir/usr/lib/systemd/user/pomodoro-wake-daemon.service"
+}
diff --git a/pomodoro_app/packaging/arch/pomodoro-app.desktop b/pomodoro_app/packaging/arch/pomodoro-app.desktop
new file mode 100644
index 0000000..1891e23
--- /dev/null
+++ b/pomodoro_app/packaging/arch/pomodoro-app.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=Pomodoro Timer
+Comment=Pomodoro timer with LAN sync
+Exec=pomodoro-app
+Icon=pomodoro-app
+Terminal=false
+Categories=Utility;Clock;GTK;
+Keywords=pomodoro;timer;focus;productivity;
diff --git a/pomodoro_app/packaging/arch/pomodoro-app.sh b/pomodoro_app/packaging/arch/pomodoro-app.sh
new file mode 100755
index 0000000..1ed2bef
--- /dev/null
+++ b/pomodoro_app/packaging/arch/pomodoro-app.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec /usr/lib/pomodoro-app/pomodoro_app "$@"
diff --git a/pomodoro_app/packaging/arch/pomodoro-app.svg b/pomodoro_app/packaging/arch/pomodoro-app.svg
new file mode 100644
index 0000000..e75ebe3
--- /dev/null
+++ b/pomodoro_app/packaging/arch/pomodoro-app.svg
@@ -0,0 +1,18 @@
+
diff --git a/pomodoro_app/packaging/arch/pomodoro-wake-daemon.py b/pomodoro_app/packaging/arch/pomodoro-wake-daemon.py
new file mode 100755
index 0000000..8f26365
--- /dev/null
+++ b/pomodoro_app/packaging/arch/pomodoro-wake-daemon.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""Pomodoro wake daemon.
+
+Listens for UDP wake broadcasts from the Pomodoro app and automatically
+launches the app on:
+ - the local desktop (if not already running)
+ - connected Android devices via ADB (if available)
+
+Intended to run as a systemd user service so that opening the app on any
+device opens it everywhere.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import shutil
+import socket
+import subprocess
+import time
+
+WAKE_PORT = 41235
+APP_PROCESS = "pomodoro_app"
+APP_COMMAND = "pomodoro-app"
+ANDROID_PACKAGE = "com.kuhy.pomodoro_app"
+ANDROID_ACTIVITY = ".MainActivity"
+
+# Minimum seconds between consecutive launches to avoid rapid re-triggers.
+LAUNCH_COOLDOWN = 5
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [pomodoro-wake] %(message)s",
+ datefmt="%H:%M:%S",
+)
+log = logging.getLogger(__name__)
+
+
+def is_app_running() -> bool:
+ """Check whether the Pomodoro app is running locally."""
+ pgrep = shutil.which("pgrep")
+ if pgrep is None:
+ return False
+ try:
+ result = subprocess.run(
+ [pgrep, "-f", APP_PROCESS],
+ capture_output=True,
+ check=False,
+ )
+ except FileNotFoundError:
+ return False
+ return result.returncode == 0
+
+
+def launch_local() -> None:
+ """Launch the Pomodoro app on the local desktop."""
+ if is_app_running():
+ log.info("Local app already running, skipping launch")
+ return
+ cmd = shutil.which(APP_COMMAND)
+ if cmd is None:
+ log.warning("%s not found on PATH", APP_COMMAND)
+ return
+ log.info("Launching local app: %s", cmd)
+ subprocess.Popen(
+ [cmd],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+
+
+def get_adb_devices() -> list[str]:
+ """Return list of connected ADB device serial numbers."""
+ adb = shutil.which("adb")
+ if adb is None:
+ return []
+ try:
+ result = subprocess.run(
+ [adb, "devices"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ check=False,
+ )
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ return []
+ devices: list[str] = []
+ for line in result.stdout.strip().splitlines()[1:]:
+ parts = line.split()
+ if len(parts) >= 2 and parts[1] == "device": # noqa: PLR2004
+ devices.append(parts[0])
+ return devices
+
+
+def _launch_on_device(adb: str, serial: str, component: str) -> None:
+ """Launch the Pomodoro app on a single Android device."""
+ log.info("Launching on Android device %s", serial)
+ cmd = [adb, "-s", serial, "shell", "am", "start", "-n", component]
+ try:
+ subprocess.run(cmd, capture_output=True, timeout=10, check=False)
+ except subprocess.TimeoutExpired:
+ log.warning("Timeout launching on %s", serial)
+
+
+def launch_android(devices: list[str]) -> None:
+ """Launch the Pomodoro app on connected Android devices."""
+ adb = shutil.which("adb")
+ if adb is None:
+ return
+ component = f"{ANDROID_PACKAGE}/{ANDROID_ACTIVITY}"
+ for serial in devices:
+ _launch_on_device(adb, serial, component)
+
+
+def _handle_wake(sock: socket.socket, last_launch: float) -> float:
+ """Handle a single wake signal. Returns updated last_launch time."""
+ try:
+ data, addr = sock.recvfrom(4096)
+ except OSError:
+ return last_launch
+ try:
+ msg = json.loads(data)
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ return last_launch
+ if msg.get("action") != "wake":
+ return last_launch
+ device_id = msg.get("deviceId", "unknown")
+ log.info("Received wake from %s (%s)", device_id, addr[0])
+ now = time.monotonic()
+ if now - last_launch < LAUNCH_COOLDOWN:
+ log.info("Cooldown active, skipping launch")
+ return last_launch
+ launch_local()
+ launch_android(get_adb_devices())
+ return now
+
+
+def main() -> None:
+ """Run the wake daemon loop."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.bind(("", WAKE_PORT))
+ log.info("Listening for wake signals on UDP port %d", WAKE_PORT)
+ last_launch = 0.0
+ while True:
+ last_launch = _handle_wake(sock, last_launch)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pomodoro_app/packaging/arch/pomodoro-wake-daemon.service b/pomodoro_app/packaging/arch/pomodoro-wake-daemon.service
new file mode 100644
index 0000000..d59b200
--- /dev/null
+++ b/pomodoro_app/packaging/arch/pomodoro-wake-daemon.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=Pomodoro wake daemon - auto-launches app across devices
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/pomodoro-wake-daemon
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=default.target
diff --git a/pomodoro_app/pubspec.lock b/pomodoro_app/pubspec.lock
new file mode 100644
index 0000000..764bc87
--- /dev/null
+++ b/pomodoro_app/pubspec.lock
@@ -0,0 +1,213 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+ async:
+ dependency: transitive
+ description:
+ name: async
+ sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.13.0"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ characters:
+ dependency: transitive
+ description:
+ name: characters
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ clock:
+ dependency: transitive
+ description:
+ name: clock
+ sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
+ collection:
+ dependency: transitive
+ description:
+ name: collection
+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.19.1"
+ cupertino_icons:
+ dependency: "direct main"
+ description:
+ name: cupertino_icons
+ sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.8"
+ fake_async:
+ dependency: transitive
+ description:
+ name: fake_async
+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.3"
+ flutter:
+ dependency: "direct main"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_lints:
+ dependency: "direct dev"
+ description:
+ name: flutter_lints
+ sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.0"
+ flutter_test:
+ dependency: "direct dev"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
+ url: "https://pub.dev"
+ source: hosted
+ version: "11.0.2"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.10"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
+ lints:
+ dependency: transitive
+ description:
+ name: lints
+ sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.1.0"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.12.18"
+ material_color_utilities:
+ dependency: transitive
+ description:
+ name: material_color_utilities
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.13.0"
+ meta:
+ dependency: transitive
+ description:
+ name: meta
+ sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.17.0"
+ path:
+ dependency: transitive
+ description:
+ name: path
+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.9.1"
+ sky_engine:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ source_span:
+ dependency: transitive
+ description:
+ name: source_span
+ sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.10.2"
+ stack_trace:
+ dependency: transitive
+ description:
+ name: stack_trace
+ sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.12.1"
+ stream_channel:
+ dependency: transitive
+ description:
+ name: stream_channel
+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.4"
+ string_scanner:
+ dependency: transitive
+ description:
+ name: string_scanner
+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ term_glyph:
+ dependency: transitive
+ description:
+ name: term_glyph
+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.2"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.9"
+ vector_math:
+ dependency: transitive
+ description:
+ name: vector_math
+ sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
+ url: "https://pub.dev"
+ source: hosted
+ version: "15.0.2"
+sdks:
+ dart: ">=3.11.0 <4.0.0"
+ flutter: ">=3.18.0-18.0.pre.54"
diff --git a/pomodoro_app/pubspec.yaml b/pomodoro_app/pubspec.yaml
new file mode 100644
index 0000000..e1389cc
--- /dev/null
+++ b/pomodoro_app/pubspec.yaml
@@ -0,0 +1,88 @@
+name: pomodoro_app
+description: "A new Flutter project."
+# The following line prevents the package from being accidentally published to
+# pub.dev using `flutter pub publish`. This is preferred for private packages.
+publish_to: "none" # Remove this line if you wish to publish to pub.dev
+
+# The following defines the version and build number for your application.
+# A version number is three numbers separated by dots, like 1.2.43
+# followed by an optional build number separated by a +.
+# Both the version and the builder number may be overridden in flutter
+# build by specifying --build-name and --build-number, respectively.
+# In Android, build-name is used as versionName while build-number used as versionCode.
+# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
+# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
+# Read more about iOS versioning at
+# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
+# In Windows, build-name is used as the major, minor, and patch parts
+# of the product and file versions while build-number is used as the build suffix.
+version: 1.0.0+1
+
+environment:
+ sdk: ^3.11.0
+
+# Dependencies specify other packages that your package needs in order to work.
+# To automatically upgrade your package dependencies to the latest versions
+# consider running `flutter pub upgrade --major-versions`. Alternatively,
+# dependencies can be manually updated by changing the version numbers below to
+# the latest version available on pub.dev. To see which dependencies have newer
+# versions available, run `flutter pub outdated`.
+dependencies:
+ flutter:
+ sdk: flutter
+
+ # The following adds the Cupertino Icons font to your application.
+ # Use with the CupertinoIcons class for iOS style icons.
+ cupertino_icons: ^1.0.8
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+
+ # The "flutter_lints" package below contains a set of recommended lints to
+ # encourage good coding practices. The lint set provided by the package is
+ # activated in the `analysis_options.yaml` file located at the root of your
+ # package. See that file for information about deactivating specific lint
+ # rules and activating additional ones.
+ flutter_lints: ^6.0.0
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter packages.
+flutter:
+ # The following line ensures that the Material Icons font is
+ # included with your application, so that you can use the icons in
+ # the material Icons class.
+ uses-material-design: true
+
+ # To add assets to your application, add an assets section, like this:
+ # assets:
+ # - images/a_dot_burr.jpeg
+ # - images/a_dot_ham.jpeg
+
+ # An image asset can refer to one or more resolution-specific "variants", see
+ # https://flutter.dev/to/resolution-aware-images
+
+ # For details regarding adding assets from package dependencies, see
+ # https://flutter.dev/to/asset-from-package
+
+ # To add custom fonts to your application, add a fonts section here,
+ # in this "flutter" section. Each entry in this list should have a
+ # "family" key with the font family name, and a "fonts" key with a
+ # list giving the asset and other descriptors for the font. For
+ # example:
+ # fonts:
+ # - family: Schyler
+ # fonts:
+ # - asset: fonts/Schyler-Regular.ttf
+ # - asset: fonts/Schyler-Italic.ttf
+ # style: italic
+ # - family: Trajan Pro
+ # fonts:
+ # - asset: fonts/TrajanPro.ttf
+ # - asset: fonts/TrajanPro_Bold.ttf
+ # weight: 700
+ #
+ # For details regarding fonts from package dependencies,
+ # see https://flutter.dev/to/font-from-package
diff --git a/pomodoro_app/test/models/pomodoro_state_test.dart b/pomodoro_app/test/models/pomodoro_state_test.dart
new file mode 100644
index 0000000..a9ebd92
--- /dev/null
+++ b/pomodoro_app/test/models/pomodoro_state_test.dart
@@ -0,0 +1,129 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pomodoro_app/models/pomodoro_state.dart';
+
+void main() {
+ group('PomodoroMode', () {
+ test('label returns correct strings', () {
+ expect(PomodoroMode.work.label, 'Work');
+ expect(PomodoroMode.shortBreak.label, 'Short Break');
+ expect(PomodoroMode.longBreak.label, 'Long Break');
+ });
+ });
+
+ group('PomodoroState.initial', () {
+ test('creates default state', () {
+ final state = PomodoroState.initial();
+ expect(state.mode, PomodoroMode.work);
+ expect(state.remainingSeconds, 25 * 60);
+ expect(state.totalSeconds, 25 * 60);
+ expect(state.isRunning, false);
+ expect(state.completedPomodoros, 0);
+ expect(state.pomodorosPerCycle, 4);
+ });
+
+ test('creates state with custom durations', () {
+ final state = PomodoroState.initial(
+ workMinutes: 30,
+ shortBreakMinutes: 10,
+ longBreakMinutes: 20,
+ pomodorosPerCycle: 3,
+ );
+ expect(state.remainingSeconds, 30 * 60);
+ expect(state.totalSeconds, 30 * 60);
+ expect(state.pomodorosPerCycle, 3);
+ });
+ });
+
+ group('PomodoroState.progress', () {
+ test('returns 0.0 at start', () {
+ final state = PomodoroState.initial();
+ expect(state.progress, 0.0);
+ });
+
+ test('returns 0.5 at halfway', () {
+ final state = PomodoroState.initial().copyWith(
+ remainingSeconds: 25 * 30, // half of 25*60
+ );
+ expect(state.progress, closeTo(0.5, 0.001));
+ });
+
+ test('returns 1.0 when totalSeconds is 0', () {
+ final state = PomodoroState.initial().copyWith(
+ totalSeconds: 0,
+ remainingSeconds: 0,
+ );
+ expect(state.progress, 1.0);
+ });
+
+ test('returns close to 1.0 at end', () {
+ final state = PomodoroState.initial().copyWith(remainingSeconds: 0);
+ expect(state.progress, 1.0);
+ });
+ });
+
+ group('PomodoroState.formattedTime', () {
+ test('formats full time correctly', () {
+ final state = PomodoroState.initial(); // 25:00
+ expect(state.formattedTime, '25:00');
+ });
+
+ test('formats single-digit minutes with padding', () {
+ final state = PomodoroState.initial().copyWith(remainingSeconds: 5 * 60 + 30);
+ expect(state.formattedTime, '05:30');
+ });
+
+ test('formats zero correctly', () {
+ final state = PomodoroState.initial().copyWith(remainingSeconds: 0);
+ expect(state.formattedTime, '00:00');
+ });
+
+ test('formats seconds with padding', () {
+ final state = PomodoroState.initial().copyWith(remainingSeconds: 60 + 5);
+ expect(state.formattedTime, '01:05');
+ });
+ });
+
+ group('PomodoroState.copyWith', () {
+ test('copies with mode change', () {
+ final original = PomodoroState.initial();
+ final copy = original.copyWith(mode: PomodoroMode.shortBreak);
+ expect(copy.mode, PomodoroMode.shortBreak);
+ expect(copy.remainingSeconds, original.remainingSeconds);
+ });
+
+ test('preserves values when no parameters given', () {
+ final original = PomodoroState.initial();
+ final copy = original.copyWith();
+ expect(copy, original);
+ });
+ });
+
+ group('PomodoroState equality', () {
+ test('equal states are ==', () {
+ final a = PomodoroState.initial();
+ final b = PomodoroState.initial();
+ expect(a, b);
+ expect(a.hashCode, b.hashCode);
+ });
+
+ test('different states are !=', () {
+ final a = PomodoroState.initial();
+ final b = a.copyWith(remainingSeconds: 100);
+ expect(a, isNot(b));
+ });
+
+ test('identical references are ==', () {
+ final a = PomodoroState.initial();
+ // ignore: prefer_const_declarations
+ final b = a;
+ expect(identical(a, b), true);
+ expect(a, b);
+ });
+
+ test('different type is !=', () {
+ final a = PomodoroState.initial();
+ // ignore: unrelated_type_equality_checks
+ expect(a == 'not a state', false);
+ });
+ });
+}
diff --git a/pomodoro_app/test/screens/pomodoro_screen_test.dart b/pomodoro_app/test/screens/pomodoro_screen_test.dart
new file mode 100644
index 0000000..d533b51
--- /dev/null
+++ b/pomodoro_app/test/screens/pomodoro_screen_test.dart
@@ -0,0 +1,178 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pomodoro_app/models/pomodoro_state.dart';
+import 'package:pomodoro_app/screens/pomodoro_screen.dart';
+import 'package:pomodoro_app/services/pomodoro_timer.dart';
+
+/// Controllable fake timer for widget tests.
+class FakeTimerController {
+ void Function(Timer)? _callback;
+ bool _isActive = true;
+
+ void tick() {
+ if (_isActive) {
+ _callback?.call(_FakeTimer(this));
+ }
+ }
+
+ void cancel() {
+ _isActive = false;
+ }
+
+ bool get isActive => _isActive;
+}
+
+class _FakeTimer implements Timer {
+ _FakeTimer(this._controller);
+ final FakeTimerController _controller;
+
+ @override
+ void cancel() => _controller.cancel();
+
+ @override
+ bool get isActive => _controller.isActive;
+
+ @override
+ int get tick => 0;
+}
+
+void main() {
+ late PomodoroTimer timer;
+ late FakeTimerController fakeController;
+
+ Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) {
+ fakeController = FakeTimerController();
+ fakeController._callback = callback;
+ return _FakeTimer(fakeController);
+ }
+
+ setUp(() {
+ timer = PomodoroTimer(
+ workMinutes: 1,
+ shortBreakMinutes: 1,
+ longBreakMinutes: 2,
+ pomodorosPerCycle: 4,
+ timerFactory: fakeTimerFactory,
+ );
+ });
+
+ tearDown(() {
+ timer.dispose();
+ });
+
+ Widget createApp() {
+ return MaterialApp(
+ home: PomodoroScreen(timer: timer),
+ );
+ }
+
+ group('PomodoroScreen', () {
+ testWidgets('shows initial time', (tester) async {
+ await tester.pumpWidget(createApp());
+ expect(find.text('01:00'), findsOneWidget);
+ });
+
+ testWidgets('shows Work label initially', (tester) async {
+ await tester.pumpWidget(createApp());
+ expect(find.text('Work'), findsOneWidget);
+ });
+
+ testWidgets('shows 0 pomodoros completed', (tester) async {
+ await tester.pumpWidget(createApp());
+ expect(find.text('0 pomodoros completed'), findsOneWidget);
+ });
+
+ testWidgets('play button starts timer', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ // Find and tap the play button.
+ final playButton = find.byIcon(Icons.play_arrow);
+ expect(playButton, findsOneWidget);
+ await tester.tap(playButton);
+ await tester.pump();
+
+ // After ticking, time should decrease.
+ fakeController.tick();
+ await tester.pump();
+ expect(find.text('00:59'), findsOneWidget);
+ });
+
+ testWidgets('pause button appears when running', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ await tester.tap(find.byIcon(Icons.play_arrow));
+ await tester.pump();
+
+ expect(find.byIcon(Icons.pause), findsOneWidget);
+ });
+
+ testWidgets('pause button pauses timer', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ // Start.
+ await tester.tap(find.byIcon(Icons.play_arrow));
+ await tester.pump();
+
+ fakeController.tick();
+ await tester.pump();
+
+ // Pause.
+ await tester.tap(find.byIcon(Icons.pause));
+ await tester.pump();
+
+ expect(find.text('00:59'), findsOneWidget);
+ expect(find.byIcon(Icons.play_arrow), findsOneWidget);
+ });
+
+ testWidgets('reset button resets time', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ // Start and tick.
+ await tester.tap(find.byIcon(Icons.play_arrow));
+ await tester.pump();
+ fakeController.tick();
+ fakeController.tick();
+ await tester.pump();
+
+ // Reset.
+ await tester.tap(find.byIcon(Icons.refresh));
+ await tester.pump();
+
+ expect(find.text('01:00'), findsOneWidget);
+ });
+
+ testWidgets('skip button moves to next mode', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ await tester.tap(find.byIcon(Icons.skip_next));
+ await tester.pump();
+
+ expect(find.text('Short Break'), findsOneWidget);
+ });
+
+ testWidgets('shows correct completed count after session', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ // Start and complete a work session.
+ await tester.tap(find.byIcon(Icons.play_arrow));
+ await tester.pump();
+
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ await tester.pump();
+
+ expect(find.text('1 pomodoro completed'), findsOneWidget);
+ });
+
+ testWidgets('has 4 indicator dots', (tester) async {
+ await tester.pumpWidget(createApp());
+
+ // There should be 4 AnimatedContainers for indicators.
+ // We can check that the PomodoroIndicators widget is present.
+ expect(find.text('0 pomodoros completed'), findsOneWidget);
+ });
+ });
+}
diff --git a/pomodoro_app/test/services/pomodoro_timer_test.dart b/pomodoro_app/test/services/pomodoro_timer_test.dart
new file mode 100644
index 0000000..e071bca
--- /dev/null
+++ b/pomodoro_app/test/services/pomodoro_timer_test.dart
@@ -0,0 +1,302 @@
+import 'dart:async';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pomodoro_app/models/pomodoro_state.dart';
+import 'package:pomodoro_app/services/pomodoro_timer.dart';
+
+/// A controllable fake timer for testing.
+class FakeTimerController {
+ void Function(Timer)? _callback;
+ bool _isActive = true;
+
+ void tick() {
+ if (_isActive) {
+ _callback?.call(_FakeTimer(this));
+ }
+ }
+
+ void cancel() {
+ _isActive = false;
+ }
+
+ bool get isActive => _isActive;
+}
+
+class _FakeTimer implements Timer {
+ _FakeTimer(this._controller);
+ final FakeTimerController _controller;
+
+ @override
+ void cancel() => _controller.cancel();
+
+ @override
+ bool get isActive => _controller.isActive;
+
+ @override
+ int get tick => 0;
+}
+
+void main() {
+ late PomodoroTimer timer;
+ late FakeTimerController fakeController;
+
+ Timer fakeTimerFactory(Duration duration, void Function(Timer) callback) {
+ fakeController = FakeTimerController();
+ fakeController._callback = callback;
+ return _FakeTimer(fakeController);
+ }
+
+ setUp(() {
+ timer = PomodoroTimer(
+ workMinutes: 1, // 60 seconds for faster testing
+ shortBreakMinutes: 1,
+ longBreakMinutes: 2,
+ pomodorosPerCycle: 2,
+ timerFactory: fakeTimerFactory,
+ );
+ });
+
+ tearDown(() {
+ timer.dispose();
+ });
+
+ group('Initial state', () {
+ test('starts in work mode', () {
+ expect(timer.state.mode, PomodoroMode.work);
+ });
+
+ test('is not running', () {
+ expect(timer.state.isRunning, false);
+ });
+
+ test('has correct initial time', () {
+ expect(timer.state.remainingSeconds, 60);
+ expect(timer.state.totalSeconds, 60);
+ });
+
+ test('has zero completed pomodoros', () {
+ expect(timer.state.completedPomodoros, 0);
+ });
+ });
+
+ group('start()', () {
+ test('sets isRunning to true', () {
+ timer.start();
+ expect(timer.state.isRunning, true);
+ });
+
+ test('does nothing if already running', () {
+ timer.start();
+ final stateAfterFirstStart = timer.state;
+ timer.start(); // second call
+ expect(timer.state, stateAfterFirstStart);
+ });
+
+ test('notifies listeners', () {
+ var notified = false;
+ timer.addListener(() => notified = true);
+ timer.start();
+ expect(notified, true);
+ });
+ });
+
+ group('pause()', () {
+ test('sets isRunning to false', () {
+ timer.start();
+ timer.pause();
+ expect(timer.state.isRunning, false);
+ });
+
+ test('does nothing if already paused', () {
+ final state = timer.state;
+ timer.pause();
+ expect(timer.state, state);
+ });
+
+ test('preserves remaining time', () {
+ timer.start();
+ fakeController.tick(); // -1s
+ fakeController.tick(); // -1s
+ timer.pause();
+ expect(timer.state.remainingSeconds, 58);
+ });
+ });
+
+ group('Ticking', () {
+ test('decrements remaining seconds', () {
+ timer.start();
+ fakeController.tick();
+ expect(timer.state.remainingSeconds, 59);
+ });
+
+ test('notifies on each tick', () {
+ timer.start();
+ var count = 0;
+ timer.addListener(() => count++);
+ fakeController.tick();
+ fakeController.tick();
+ expect(count, 2);
+ });
+ });
+
+ group('Session completion', () {
+ test('transitions from work to short break', () {
+ timer.start();
+ // Tick down to 1 second, then one more tick completes.
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+ expect(timer.state.isRunning, false);
+ expect(timer.state.completedPomodoros, 1);
+ });
+
+ test('transitions to long break after cycle', () {
+ // Complete 2 pomodoros (pomodorosPerCycle = 2).
+ // First pomodoro.
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+
+ // Skip break.
+ timer.skip();
+ expect(timer.state.mode, PomodoroMode.work);
+
+ // Second pomodoro.
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.mode, PomodoroMode.longBreak);
+ expect(timer.state.completedPomodoros, 2);
+ });
+
+ test('transitions from break to work', () {
+ // Complete a work session.
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+
+ // Complete the break.
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ expect(timer.state.mode, PomodoroMode.work);
+ });
+ });
+
+ group('reset()', () {
+ test('resets to full duration', () {
+ timer.start();
+ fakeController.tick();
+ fakeController.tick();
+ timer.reset();
+ expect(timer.state.remainingSeconds, 60);
+ expect(timer.state.isRunning, false);
+ });
+
+ test('keeps the current mode', () {
+ timer.start();
+ for (var i = 0; i < 60; i++) {
+ fakeController.tick();
+ }
+ // Now in short break mode.
+ timer.reset();
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+ });
+ });
+
+ group('skip()', () {
+ test('skips from work to short break', () {
+ timer.skip();
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+ expect(timer.state.isRunning, false);
+ });
+
+ test('skips from break to work', () {
+ timer.skip(); // work -> short break
+ timer.skip(); // short break -> work
+ expect(timer.state.mode, PomodoroMode.work);
+ });
+
+ test('stops the timer when skipping', () {
+ timer.start();
+ timer.skip();
+ expect(timer.state.isRunning, false);
+ });
+ });
+
+ group('dispose()', () {
+ test('cancels internal timer', () {
+ // Create a separate timer so tearDown does not double-dispose.
+ final disposableTimer = PomodoroTimer(
+ workMinutes: 1,
+ shortBreakMinutes: 1,
+ longBreakMinutes: 2,
+ pomodorosPerCycle: 2,
+ timerFactory: fakeTimerFactory,
+ );
+ disposableTimer.start();
+ disposableTimer.dispose();
+ expect(fakeController.isActive, false);
+ });
+ });
+
+ group('applyRemoteState()', () {
+ test('applies remote state and notifies listeners', () {
+ var notified = false;
+ timer.addListener(() => notified = true);
+
+ final remoteState = PomodoroState(
+ mode: PomodoroMode.shortBreak,
+ remainingSeconds: 200,
+ totalSeconds: 300,
+ isRunning: false,
+ completedPomodoros: 2,
+ pomodorosPerCycle: 4,
+ );
+
+ timer.applyRemoteState(remoteState, 'pause');
+ expect(timer.state.mode, PomodoroMode.shortBreak);
+ expect(timer.state.remainingSeconds, 200);
+ expect(timer.state.completedPomodoros, 2);
+ expect(timer.state.isRunning, false);
+ expect(notified, true);
+ });
+
+ test('starts local ticking when remote state is running', () {
+ final remoteState = PomodoroState(
+ mode: PomodoroMode.work,
+ remainingSeconds: 500,
+ totalSeconds: 600,
+ isRunning: true,
+ completedPomodoros: 0,
+ pomodorosPerCycle: 4,
+ );
+
+ timer.applyRemoteState(remoteState, 'start');
+ expect(timer.state.isRunning, true);
+
+ // The fake timer should have been created; ticking should work.
+ fakeController.tick();
+ expect(timer.state.remainingSeconds, 499);
+ });
+
+ test('stops local ticking when remote state is paused', () {
+ // First start the timer locally.
+ timer.start();
+ fakeController.tick();
+ expect(timer.state.remainingSeconds, 59);
+
+ // Apply remote pause.
+ final remoteState = timer.state.copyWith(isRunning: false);
+ timer.applyRemoteState(remoteState, 'pause');
+ expect(timer.state.isRunning, false);
+ });
+ });
+}
diff --git a/pomodoro_app/test/services/sync_service_test.dart b/pomodoro_app/test/services/sync_service_test.dart
new file mode 100644
index 0000000..05ed281
--- /dev/null
+++ b/pomodoro_app/test/services/sync_service_test.dart
@@ -0,0 +1,260 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pomodoro_app/models/pomodoro_state.dart';
+import 'package:pomodoro_app/services/sync_service.dart';
+
+/// A fake [RawDatagramSocket] that captures sent messages and allows
+/// injecting received messages.
+class FakeDatagramSocket implements RawDatagramSocket {
+ final _controller = StreamController.broadcast();
+ final List<_SentDatagram> sentMessages = [];
+ Datagram? _pendingDatagram;
+
+ @override
+ int send(List buffer, InternetAddress address, int port) {
+ sentMessages.add(_SentDatagram(buffer, address, port));
+ return buffer.length;
+ }
+
+ @override
+ Datagram? receive() => _pendingDatagram;
+
+ /// Simulates receiving a datagram.
+ void injectDatagram(List data, InternetAddress address, int port) {
+ _pendingDatagram = Datagram(
+ data as dynamic,
+ address,
+ port,
+ );
+ _controller.add(RawSocketEvent.read);
+ }
+
+ @override
+ StreamSubscription listen(
+ void Function(RawSocketEvent)? onData, {
+ Function? onError,
+ void Function()? onDone,
+ bool? cancelOnError,
+ }) {
+ return _controller.stream.listen(
+ onData,
+ onError: onError,
+ onDone: onDone,
+ cancelOnError: cancelOnError ?? false,
+ );
+ }
+
+ @override
+ void joinMulticast(InternetAddress group, [NetworkInterface? interface_]) {}
+
+ @override
+ void leaveMulticast(InternetAddress group, [NetworkInterface? interface_]) {}
+
+ @override
+ void close() => _controller.close();
+
+ // Required interface stubs.
+ @override
+ dynamic noSuchMethod(Invocation invocation) => null;
+}
+
+class _SentDatagram {
+ _SentDatagram(this.data, this.address, this.port);
+ final List data;
+ final InternetAddress address;
+ final int port;
+
+ Map get decoded =>
+ jsonDecode(utf8.decode(data)) as Map;
+}
+
+void main() {
+ group('SyncService', () {
+ late FakeDatagramSocket fakeSocket;
+ late SyncService service;
+ PomodoroState? receivedState;
+ String? receivedAction;
+
+ setUp(() async {
+ fakeSocket = FakeDatagramSocket();
+ receivedState = null;
+ receivedAction = null;
+
+ service = SyncService(
+ onStateReceived: (state, action) {
+ receivedState = state;
+ receivedAction = action;
+ },
+ deviceId: 'test-device-1',
+ socketFactory: (host, port) async => fakeSocket,
+ );
+ await service.start();
+ });
+
+ tearDown(() async {
+ await service.dispose();
+ });
+
+ test('is active after start', () {
+ expect(service.isActive, true);
+ });
+
+ test('broadcast sends a message', () {
+ // start() sends a wake message, so clear before testing broadcast.
+ fakeSocket.sentMessages.clear();
+
+ final state = PomodoroState.initial();
+ service.broadcast(state, 'start');
+
+ expect(fakeSocket.sentMessages, hasLength(1));
+ final decoded = fakeSocket.sentMessages.first.decoded;
+ expect(decoded['deviceId'], 'test-device-1');
+ expect(decoded['action'], 'start');
+ expect(decoded['state']['mode'], 'work');
+ expect(decoded['state']['remainingSeconds'], 25 * 60);
+ });
+
+ test('ignores own messages', () async {
+ final state = PomodoroState.initial();
+ final message = jsonEncode({
+ 'deviceId': 'test-device-1', // Same as our device.
+ 'timestamp': DateTime.now().millisecondsSinceEpoch,
+ 'action': 'start',
+ 'state': {
+ 'mode': 'work',
+ 'remainingSeconds': 1500,
+ 'totalSeconds': 1500,
+ 'isRunning': true,
+ 'completedPomodoros': 0,
+ 'pomodorosPerCycle': 4,
+ },
+ });
+
+ fakeSocket.injectDatagram(
+ utf8.encode(message),
+ InternetAddress('192.168.1.100'),
+ 41234,
+ );
+
+ // Allow async processing.
+ await Future.delayed(Duration.zero);
+ expect(receivedState, isNull);
+ expect(receivedAction, isNull);
+ });
+
+ test('processes messages from other devices', () async {
+ final message = jsonEncode({
+ 'deviceId': 'other-device-2',
+ 'timestamp': DateTime.now().millisecondsSinceEpoch,
+ 'action': 'pause',
+ 'state': {
+ 'mode': 'work',
+ 'remainingSeconds': 1200,
+ 'totalSeconds': 1500,
+ 'isRunning': false,
+ 'completedPomodoros': 1,
+ 'pomodorosPerCycle': 4,
+ },
+ });
+
+ fakeSocket.injectDatagram(
+ utf8.encode(message),
+ InternetAddress('192.168.1.101'),
+ 41234,
+ );
+
+ await Future.delayed(Duration.zero);
+ expect(receivedState, isNotNull);
+ expect(receivedAction, 'pause');
+ expect(receivedState!.remainingSeconds, 1200);
+ expect(receivedState!.isRunning, false);
+ expect(receivedState!.completedPomodoros, 1);
+ });
+
+ test('handles malformed messages gracefully', () async {
+ fakeSocket.injectDatagram(
+ utf8.encode('not json at all'),
+ InternetAddress('192.168.1.101'),
+ 41234,
+ );
+
+ await Future.delayed(Duration.zero);
+ // Should not crash, receivedState stays null.
+ expect(receivedState, isNull);
+ });
+
+ test('broadcast does nothing after dispose', () async {
+ await service.dispose();
+ expect(service.isActive, false);
+
+ // Should not throw.
+ service.broadcast(PomodoroState.initial(), 'start');
+ });
+
+ test('heartbeat sends periodic state', () async {
+ final state = PomodoroState.initial();
+ service.startHeartbeat(() => state);
+
+ // Wait for at least one heartbeat interval.
+ // Note: In tests, Timer.periodic fires based on the test framework.
+ // We just verify it doesn't crash and can be stopped.
+ service.stopHeartbeat();
+ });
+ });
+
+ group('SyncService state encoding', () {
+ test('all modes encode and decode correctly', () async {
+ for (final mode in PomodoroMode.values) {
+ final fakeSocket = FakeDatagramSocket();
+ PomodoroState? received;
+
+ final sender = SyncService(
+ onStateReceived: (_, __) {},
+ deviceId: 'sender',
+ socketFactory: (h, p) async => fakeSocket,
+ );
+ await sender.start();
+
+ final receiver = SyncService(
+ onStateReceived: (state, action) => received = state,
+ deviceId: 'receiver',
+ socketFactory: (h, p) async => fakeSocket,
+ );
+ await receiver.start();
+
+ final state = PomodoroState(
+ mode: mode,
+ remainingSeconds: 300,
+ totalSeconds: 600,
+ isRunning: true,
+ completedPomodoros: 3,
+ pomodorosPerCycle: 4,
+ );
+
+ sender.broadcast(state, 'test');
+
+ // Manually decode the sent message and inject it.
+ final sent = fakeSocket.sentMessages.last;
+ fakeSocket.injectDatagram(
+ sent.data,
+ InternetAddress('192.168.1.100'),
+ 41234,
+ );
+
+ await Future.delayed(Duration.zero);
+ expect(received, isNotNull);
+ expect(received!.mode, mode);
+ expect(received!.remainingSeconds, 300);
+ expect(received!.totalSeconds, 600);
+ expect(received!.isRunning, true);
+ expect(received!.completedPomodoros, 3);
+
+ await sender.dispose();
+ await receiver.dispose();
+ }
+ });
+ });
+}
diff --git a/pyproject.toml b/pyproject.toml
index 2ed44e7..ba78de3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,6 +51,7 @@ unfixable = []
"**/tests/**/*.py" = [
"S101", # Allow assert in tests
"PLR2004", # Allow magic values in tests
+ "SLF001", # Allow private member access in tests
]
"**/test_*.py" = [
"S101", # Allow assert in tests
@@ -58,6 +59,7 @@ unfixable = []
"S607", # Allow partial executable path in tests
"PLC0415", # Allow late imports for test isolation
"PLR2004", # Allow magic values in tests
+ "SLF001", # Allow private member access in tests
]
"poker_modifier_app/poker_modifier_app.py" = [
"FBT003", # Boolean positional values in tkinter API calls
diff --git a/python_pkg/screen_locker/screen_lock.py b/python_pkg/screen_locker/screen_lock.py
index bcd046b..8625f90 100755
--- a/python_pkg/screen_locker/screen_lock.py
+++ b/python_pkg/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/python_pkg/screen_locker/tests/test_screen_lock.py b/python_pkg/screen_locker/tests/test_screen_lock.py
index fb117aa..d1c76df 100644
--- a/python_pkg/screen_locker/tests/test_screen_lock.py
+++ b/python_pkg/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,