fixes for existing scripts and pomodoro with local sync

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-02-14 18:42:20 +01:00
parent 4f37aad321
commit 20337f07eb
59 changed files with 3612 additions and 1361 deletions

3
.fvmrc Normal file
View File

@ -0,0 +1,3 @@
{
"flutter": "stable"
}

3
.gitignore vendored
View File

@ -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/

View File

@ -2,5 +2,6 @@
"files.associations": {
"*.py": "python",
"stdio.h": "c"
}
},
"dart.flutterSdkPath": ".fvm/versions/stable"
}

View File

@ -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

View File

@ -56,4 +56,7 @@ youtube
# Chrome/Chromium variants
google-chrome
chromium
ungoogled-chromium
ungoogled-chromium
# VirtualBox (can bypass /etc/hosts restrictions)
virtualbox
vbox

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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" ]

View File

@ -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

45
pomodoro_app/.gitignore vendored Normal file
View File

@ -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

33
pomodoro_app/.metadata Normal file
View File

@ -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'

17
pomodoro_app/README.md Normal file
View File

@ -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.

View File

@ -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

14
pomodoro_app/android/.gitignore vendored Normal file
View File

@ -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

View File

@ -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 = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,49 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application
android:label="pomodoro_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -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()
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@ -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

View File

@ -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")

1
pomodoro_app/linux/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
flutter/ephemeral

View File

@ -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 "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>: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()

View File

@ -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}
)

View File

@ -0,0 +1,11 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
void fl_register_plugins(FlPluginRegistry* registry) {
}

View File

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@ -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 $<TARGET_FILE:${plugin}_plugin>)
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)

View File

@ -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}")

View File

@ -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);
}

View File

@ -0,0 +1,148 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#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));
}

View File

@ -0,0 +1,21 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
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_

View File

@ -0,0 +1,2 @@
pkg/
*.pkg.tar.zst

View File

@ -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"
}

View File

@ -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;

View File

@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/lib/pomodoro-app/pomodoro_app "$@"

View File

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- Tomato body -->
<ellipse cx="128" cy="148" rx="100" ry="90" fill="#e74c3c"/>
<!-- Highlight -->
<ellipse cx="100" cy="120" rx="30" ry="40" fill="#ec7063" opacity="0.5"/>
<!-- Stem -->
<rect x="120" y="52" width="16" height="24" rx="4" fill="#27ae60"/>
<!-- Leaf -->
<path d="M136 64 Q160 40 156 62 Q150 72 136 68Z" fill="#2ecc71"/>
<!-- Clock face -->
<circle cx="128" cy="152" r="50" fill="none" stroke="#fff" stroke-width="4" opacity="0.85"/>
<!-- Minute hand -->
<line x1="128" y1="152" x2="128" y2="112" stroke="#fff" stroke-width="5" stroke-linecap="round" opacity="0.9"/>
<!-- Hour hand -->
<line x1="128" y1="152" x2="152" y2="140" stroke="#fff" stroke-width="5" stroke-linecap="round" opacity="0.9"/>
<!-- Center dot -->
<circle cx="128" cy="152" r="4" fill="#fff" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

View File

@ -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()

View File

@ -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

213
pomodoro_app/pubspec.lock Normal file
View File

@ -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"

88
pomodoro_app/pubspec.yaml Normal file
View File

@ -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

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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);
});
});
}

View File

@ -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<RawSocketEvent>.broadcast();
final List<_SentDatagram> sentMessages = [];
Datagram? _pendingDatagram;
@override
int send(List<int> 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<int> data, InternetAddress address, int port) {
_pendingDatagram = Datagram(
data as dynamic,
address,
port,
);
_controller.add(RawSocketEvent.read);
}
@override
StreamSubscription<RawSocketEvent> 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<int> data;
final InternetAddress address;
final int port;
Map<String, dynamic> get decoded =>
jsonDecode(utf8.decode(data)) as Map<String, dynamic>;
}
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<void>.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<void>.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<void>.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<void>.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();
}
});
});
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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,