mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
Next to the one-tap "I'm sick" (unchanged: keeps nothing, unlocks), a small secondary link lays sickdays 0.3.0's embeddable KeepPanel over every surface, in-process so it gets input under the lock's grab. Closing the overlay reveals the untouched screen underneath, so no countdown or phone check is restarted or skipped. After the panel's own write + sync: sick with the workout lock standing down -> sick floor, decision, close (shared with the one-tap path, no second mark or push); sick with the workout kept -> sick floor and a note, lock stays; cleared / unchanged / cancel -> the panel just closes. Tests stub KeepPanel and redirect resolve_paths and sync_quietly in every sickdays.* module, sickdays.gui._panel included. The fit check measures the panel on a throwaway pool. Pins bumped to sickdays-v0.3.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
211 lines
8.7 KiB
Python
211 lines
8.7 KiB
Python
"""UI flow methods mixin for the screen locker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker import _manual_workout
|
|
from screen_locker._source_health import collect_source_findings, explain_findings
|
|
from screen_locker._ui_flows_lockout import LockoutFlowMixin
|
|
from screen_locker._ui_flows_sick import SickDayFlowMixin
|
|
from screen_locker._ui_flows_sick_panel import SickPanelFlowMixin
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
|
|
class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
|
|
"""Mixin providing UI flow logic for the screen locker.
|
|
|
|
The sick-day and lockout/penalty flows live in sibling modules and are
|
|
composed in here, so ``ScreenLocker``'s own mixin list stays as it was.
|
|
"""
|
|
|
|
def paint_phone_check(self) -> None:
|
|
"""Paint the "checking phone" screen, without starting the check.
|
|
|
|
Split from :meth:`_start_phone_check` so the fit check
|
|
(``scripts/verify_screen_fits.py``) can measure this screen without
|
|
starting a background thread that talks to a phone over adb.
|
|
"""
|
|
self.clear_container()
|
|
self._label(
|
|
"Checking phone...",
|
|
role="display",
|
|
color=self._colors.palette.warning,
|
|
pad="lg",
|
|
)
|
|
self._text("Looking for today's workout in StrongLifts...", role="body")
|
|
|
|
def _start_phone_check(self) -> None:
|
|
"""Check phone for today's workout immediately at startup."""
|
|
self.paint_phone_check()
|
|
executor = ThreadPoolExecutor(max_workers=1)
|
|
self._phone_future = executor.submit(self._verify_phone_workout)
|
|
executor.shutdown(wait=False)
|
|
self._poll_phone_check()
|
|
|
|
def _poll_phone_check(self) -> None:
|
|
"""Poll background phone check and route to result handler when done."""
|
|
if self._phone_future is not None and self._phone_future.done():
|
|
status, message = self._phone_future.result()
|
|
self._handle_startup_phone_result(status, message)
|
|
else:
|
|
self.root.after(500, self._poll_phone_check)
|
|
|
|
def _show_retry_and_sick(self, message: str) -> None:
|
|
"""Show TRY AGAIN, I'm sick and (budget allowing) the manual log."""
|
|
self.clear_container()
|
|
self._label(
|
|
"No Workout Found",
|
|
role="display",
|
|
color=self._colors.palette.danger,
|
|
pad="md",
|
|
)
|
|
self._text(message, color=self._colors.palette.warning)
|
|
# Never accuse the user of skipping a workout the machine simply could
|
|
# not see. On 2026-08-24 this screen said "No Workout Found" after a
|
|
# 1h57m session, because Firebase was unreadable and the GitHub mirror
|
|
# had been stale for nine days -- neither of which was shown here.
|
|
diagnosis = explain_findings(collect_source_findings())
|
|
if diagnosis:
|
|
self._text(diagnosis, color=self._colors.palette.muted)
|
|
self._text(
|
|
_manual_workout.budget_summary(self.log_file),
|
|
color=self._colors.palette.muted,
|
|
)
|
|
frame = self._button_row()
|
|
self._button(
|
|
frame,
|
|
"TRY AGAIN",
|
|
bg=self._colors.palette.accent,
|
|
command=self._start_phone_check,
|
|
width=12,
|
|
).pack(side="left", padx=self._colors.space("sm"))
|
|
self._sick_button(frame)
|
|
if self._mw_budget_closed():
|
|
self._text(
|
|
"Manual-workout budget exhausted. No manual-log option available.",
|
|
color=self._colors.palette.danger,
|
|
)
|
|
else:
|
|
self._button(
|
|
frame,
|
|
"Log Manual Workout",
|
|
bg=self._colors.palette.accent,
|
|
command=self._show_manual_workout_form,
|
|
width=16,
|
|
).pack(side="left", padx=self._colors.space("sm"))
|
|
|
|
def _on_manual_workout_saved(self, entry: dict) -> None:
|
|
"""Show confirmation and unlock after a manual-workout entry is built."""
|
|
self.workout_data = entry
|
|
self.clear_container()
|
|
self._label(
|
|
"✓ Manual Workout Logged!",
|
|
role="display",
|
|
scale=1.3,
|
|
color=self._colors.palette.success,
|
|
pad="lg",
|
|
)
|
|
self._text(
|
|
entry.get("source", ""), role="subtitle", color=self._colors.palette.success
|
|
)
|
|
self._text("Unlocking...", role="body", color=self._colors.palette.muted)
|
|
unlock_delay = 1500 if self.demo_mode else 2000
|
|
self.root.after(unlock_delay, self.unlock_screen)
|
|
|
|
def _on_manual_workout_cancelled(self) -> None:
|
|
"""Return to the retry screen when the manual-workout form is cancelled."""
|
|
self._start_phone_check()
|
|
|
|
def _handle_startup_phone_result(self, status: str, message: str) -> None:
|
|
"""Route to appropriate screen based on startup phone check result."""
|
|
if status == "verified":
|
|
self.workout_data["type"] = "phone_verified"
|
|
self.workout_data["source"] = message
|
|
self.clear_container()
|
|
self._label(
|
|
"✓ Workout Verified!",
|
|
role="display",
|
|
scale=1.3,
|
|
color=self._colors.palette.success,
|
|
pad="lg",
|
|
)
|
|
self._text(message, role="subtitle", color=self._colors.palette.success)
|
|
self._text("Unlocking...", role="body", color=self._colors.palette.muted)
|
|
unlock_delay = 1500 if self.demo_mode else 2000
|
|
self.root.after(unlock_delay, self.unlock_screen)
|
|
elif status == "too_short":
|
|
self._show_retry_and_sick(
|
|
f"❌ {message}\n\n"
|
|
"Your workout was too short!\n"
|
|
"Actually do the full workout, don't just\n"
|
|
"spam through the exercises.",
|
|
)
|
|
elif status == "clock_tampered":
|
|
self._show_retry_and_sick(
|
|
f"❌ {message}\n\n"
|
|
"System clock appears to be manipulated.\n"
|
|
"Fix your system time and try again.",
|
|
)
|
|
elif status in ("stale", "no_exercises", "not_verified"):
|
|
# Try RunnerUp before showing failure — user may have run instead of lifted.
|
|
self._start_runnerup_fallback(
|
|
lambda: self._show_retry_and_sick(
|
|
f"❌ {message}\n\n"
|
|
"Neither StrongLifts nor RunnerUp found a workout today.\n"
|
|
"Go do your workout first!",
|
|
)
|
|
)
|
|
else:
|
|
# no_phone or error — try RunnerUp first, then penalty timer.
|
|
self._start_runnerup_fallback(lambda: self._show_phone_penalty(message))
|
|
|
|
def _start_runnerup_fallback(self, on_failure: Callable[[], None]) -> None:
|
|
"""Check RunnerUp as fallback after phone check fails.
|
|
|
|
Shows a waiting screen, runs the check in a background thread, then
|
|
either unlocks (run verified) or calls ``on_failure``.
|
|
"""
|
|
self.clear_container()
|
|
self._label(
|
|
"Checking RunnerUp...",
|
|
role="display",
|
|
color=self._colors.palette.warning,
|
|
pad="lg",
|
|
)
|
|
self._text("Looking for today's run in RunnerUp...", role="body")
|
|
executor = ThreadPoolExecutor(max_workers=1)
|
|
self._runnerup_future = executor.submit(self._verify_runnerup_workout)
|
|
executor.shutdown(wait=False)
|
|
self._runnerup_on_failure = on_failure
|
|
self._poll_runnerup_fallback()
|
|
|
|
def _poll_runnerup_fallback(self) -> None:
|
|
"""Poll the RunnerUp background check and route to result handler."""
|
|
if self._runnerup_future is not None and self._runnerup_future.done():
|
|
status, message = self._runnerup_future.result()
|
|
if status == "verified":
|
|
self.workout_data["type"] = "runnerup_verified"
|
|
self.workout_data["source"] = message
|
|
self.clear_container()
|
|
self._label(
|
|
"✓ Run Verified!",
|
|
role="display",
|
|
scale=1.3,
|
|
color=self._colors.palette.success,
|
|
pad="lg",
|
|
)
|
|
self._text(message, role="subtitle", color=self._colors.palette.success)
|
|
self._text(
|
|
"Unlocking...", role="body", color=self._colors.palette.muted
|
|
)
|
|
unlock_delay = 1500 if self.demo_mode else 2000
|
|
self.root.after(unlock_delay, self.unlock_screen)
|
|
else:
|
|
self._runnerup_on_failure()
|
|
else:
|
|
self.root.after(500, self._poll_runnerup_fallback)
|