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,