mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
176 lines
6.7 KiB
Python
176 lines
6.7 KiB
Python
"""The lock screen's "Fill all" button: today's catering into every empty slot.
|
|
|
|
Two clicks, never one. The first fetches the day's delivery off the Tk thread
|
|
and shows exactly what would be written; the button relabels to "Confirm (N)".
|
|
Only the second click writes. A delivered meal is not an eaten meal, so the
|
|
user sees the dishes and the slots before anything lands -- and no modal
|
|
dialog asks instead, because a pop-up over a fullscreen grab risks stealing the
|
|
very keyboard grab the lock depends on.
|
|
|
|
This is *composed* into the gate rather than mixed in: pylint's
|
|
``max-parents`` already counts every class in the gate's MRO, so a new mixin
|
|
fails the hook, and module functions reaching into the gate trip ``SLF001``.
|
|
The gate hands this object the few callables it needs instead.
|
|
|
|
Which slots get filled -- every slot today, skipping any that already holds an
|
|
entry -- is :func:`diet_guard._kuchnia_log.fill_plan`, shared with
|
|
``diet-guard kuchnia --log`` so the CLI and the gate cannot disagree.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from importlib import import_module
|
|
import queue
|
|
from typing import TYPE_CHECKING, NamedTuple
|
|
|
|
from diet_guard import _gatelock_glyphs as g
|
|
from diet_guard._gatelock_fetch import FETCH_POLL_MS
|
|
from diet_guard._gatelock_kuchnia import start_delivery_fetch
|
|
from diet_guard._kuchnia_log import fill_plan, log_dishes
|
|
from diet_guard._kuchnia_spread import assign_slots
|
|
from diet_guard._meal_schedule_store import current_schedule
|
|
from diet_guard._slots import day_slots, slot_label
|
|
from diet_guard._state import now_local
|
|
|
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
from collections.abc import Callable, Sequence
|
|
from datetime import date
|
|
import tkinter as tk
|
|
|
|
from diet_guard._gatelock_kuchnia import DeliveryResult, RefreshFn
|
|
from diet_guard._kuchnia_parse import Dish
|
|
from diet_guard._kuchnia_spread import SlottedDish
|
|
|
|
#: The button's resting label; also what it returns to after each attempt.
|
|
FILL_LABEL = f"{g.FILL_ALL} {g.DELIVERY}"
|
|
|
|
#: ``(unlock reason, nothing-new status)`` for the gate's post-write reconcile:
|
|
#: a fill that covered only later slots must not report "found in sync" --
|
|
#: catering logged, and the food field is what the current slot still needs.
|
|
RECONCILE_LABELS = (
|
|
f"{g.OK} {g.DELIVERY}",
|
|
f"{g.OK} {g.DELIVERY} {g.FOOD}",
|
|
)
|
|
|
|
#: "Every slot already has a meal": nothing left for the fill to take.
|
|
_NOTHING_TO_FILL = f"{g.OK} {g.CLOCK}"
|
|
|
|
|
|
def _refresh_delivery(day: date) -> tuple[Sequence[Dish], str | None]:
|
|
"""Fetch ``day``'s dishes, importing the HTTP stack only when clicked.
|
|
|
|
``_kuchnia_import`` reaches ``requests``; the gate's frequent not-due tick
|
|
imports this module and must not pay for it.
|
|
"""
|
|
refresh: RefreshFn = import_module("diet_guard._kuchnia_import").refresh_delivery
|
|
return refresh(day)
|
|
|
|
|
|
class FillHooks(NamedTuple):
|
|
"""What the flow calls back into the gate."""
|
|
|
|
#: The gate's status-line writer.
|
|
set_status: Callable[..., None]
|
|
#: Called after a write, so the gate drops the slots it now covers and
|
|
#: unlocks when none remain.
|
|
on_logged: Callable[[], None]
|
|
|
|
|
|
class FillAllFlow:
|
|
"""Drives one gate's "Fill all" button through fetch, confirm and log."""
|
|
|
|
def __init__(
|
|
self,
|
|
root: tk.Misc,
|
|
label: tk.StringVar,
|
|
hooks: FillHooks,
|
|
*,
|
|
demo_mode: bool,
|
|
) -> None:
|
|
"""Bind the flow to a gate.
|
|
|
|
Args:
|
|
root: Schedules the poll on the Tk thread.
|
|
label: The button's text, shared by its copy on every monitor.
|
|
hooks: The gate callbacks the flow drives.
|
|
demo_mode: A demo window refuses: it must never write real
|
|
entries from a delivery note.
|
|
"""
|
|
self._root = root
|
|
self._label = label
|
|
self._demo_mode = demo_mode
|
|
self._set_status = hooks.set_status
|
|
self._on_logged = hooks.on_logged
|
|
self._result: queue.Queue[DeliveryResult] | None = None
|
|
self._plan: list[SlottedDish] = []
|
|
self._plan_day: date | None = None
|
|
|
|
def click(self) -> None:
|
|
"""First click fetches and proposes; a second click confirms."""
|
|
if self._demo_mode:
|
|
self._set_status(f"{g.DEMO_ONLY} {g.FILL_ALL}")
|
|
return
|
|
if self._result is not None:
|
|
return # a fetch is in flight; a double-click must not start two
|
|
today = now_local().date()
|
|
if self._plan and self._plan_day == today:
|
|
self._confirm()
|
|
return
|
|
# No plan, or one proposed before midnight: fetch (again). The day is
|
|
# pinned here, not when the result lands, so a fetch still in flight
|
|
# at midnight can never confirm yesterday's menu into today.
|
|
self._reset()
|
|
self._plan_day = today
|
|
self._set_status(f"{g.BUSY} {g.DELIVERY}")
|
|
self._result = start_delivery_fetch(_refresh_delivery, today)
|
|
self._root.after(FETCH_POLL_MS, self._poll)
|
|
|
|
def _poll(self) -> None:
|
|
"""On the Tk thread: turn the fetched dishes into a proposal."""
|
|
result = self._result
|
|
if result is None:
|
|
return
|
|
try:
|
|
outcome = result.get_nowait()
|
|
except queue.Empty:
|
|
self._root.after(FETCH_POLL_MS, self._poll)
|
|
return
|
|
self._result = None
|
|
if outcome.reason is not None:
|
|
self._set_status(f"{g.ERROR} {g.DELIVERY}", error=True)
|
|
return
|
|
if not outcome.dishes:
|
|
self._set_status(f"{g.EMPTY} {g.DELIVERY}")
|
|
return
|
|
plan = fill_plan(assign_slots(outcome.dishes, day_slots(current_schedule())))
|
|
if not plan:
|
|
self._set_status(_NOTHING_TO_FILL)
|
|
return
|
|
self._plan = plan
|
|
self._label.set(f"{g.OK} {len(plan)}")
|
|
total = sum(item.dish.kcal for item in plan)
|
|
# The proposal: slot time and dish name (catering content) per row.
|
|
listing = " ".join(
|
|
f"{slot_label(item.slot)} {item.dish.name}" for item in plan
|
|
)
|
|
self._set_status(f"{g.DELIVERY} {len(plan)} {total:g} kcal {listing}")
|
|
|
|
def _confirm(self) -> None:
|
|
"""Write the proposed dishes into the slots still empty right now."""
|
|
# Re-checked at write time: a meal typed in between the two clicks
|
|
# has taken its slot, and must keep it.
|
|
plan = fill_plan(self._plan)
|
|
self._reset()
|
|
if not plan:
|
|
self._set_status(_NOTHING_TO_FILL)
|
|
return
|
|
written = log_dishes(plan)
|
|
self._set_status(f"{g.OK} {g.DELIVERY} {len(written)}")
|
|
self._on_logged()
|
|
|
|
def _reset(self) -> None:
|
|
"""Forget any proposal and restore the button's label."""
|
|
self._plan = []
|
|
self._plan_day = None
|
|
self._label.set(FILL_LABEL)
|