mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 15:08:11 +01:00
Four asks, plus the fixes two review passes turned up. Removals (all unused): - "Repeat last meal" button, and the reward/temptation-bundling prompt it was the sole trigger for. Replaced with a post-log progress card showing kcal against budget, macros, and the adherence streak. - The multi-item meal builder, on both the gate and the phone. The stored `components` field stays: nothing writes it now, but historical composite entries and the food bank still read it and it is part of the wire format. - Meal photos entirely: 13 files, `imagePath`, the photo filter, the wrapper's /blobs/ route, image_picker, and the CAMERA permission. Reminder false alarms had three real causes, all fixed: - Logging never reconciled notifications, so a reminder sat on screen up to 15 minutes after the meal was logged -- on the phone itself. - The background check never pulled, so a PC-logged meal nagged forever until the app was opened. Now mirrors _cli_gate._should_lock: cheap local check, network only when a slot looks due, then re-check. - Meals before 08:00 / after 22:00 were attributed to no slot at all. New slot_for_log/slotForLog clamps them to the first/last slot, byte-identical across Python and Dart. Deliberately separate from elapsed_slots, which would otherwise make every slot fall due at 23:00. Budget history, so lowering the budget stops rewriting the past: - Forward-only (effective_from, kcal) entries; each day is judged against the budget in force then. Verified live: setting 2000 left a 36-day adherence streak and the whole calendar pixel-identical. - Rides as hist:<date> fields on the existing budget record. crdt_sync merges per-field over the union of field names and both devices push the merged log, so a device that predates the feature relays them untouched -- no coordinated rollout, no second document. - The seed guard tests emptiness, not file presence: _sync can write an empty history after merging with a pre-feature peer, and a presence check would then never seed again, silently reclassifying every past day. One shared source of truth for every piece of data: - Body weight now travels as its own `weight` CRDT field. Inside `value` it was collateral damage of whole-map LWW -- any phone budget edit deleted it, and the protein target with it. - Both halves of the food bank sync. The curated half (foods added without eating them) was the last device-local state. The derived half merges with count as the clock, so LWW means max-count-wins; the log stays authoritative for membership, because a CRDT union never shrinks and a peer's stale copy would otherwise resurrect a deleted food permanently. - Bank adapters honour tombstones, and Dart parses bank records defensively: a cast failure there is an Error, not an Exception, so one malformed remote record would have broken phone sync permanently. _test_guard.py blocks any pathlib write to real user state while pytest is in the process, whatever conftest is loaded. A throwaway test run outside the package conftest destroyed a real GitHub token during this work; this makes that class of accident impossible rather than merely discouraged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""Fullscreen "log your meals to unlock" gate window for diet_guard.
|
|
|
|
The fullscreen/grab/VT-disable/lifecycle mechanics -- an ``overrideredirect``
|
|
window with a global input grab and disabled VT switching, hardened so a
|
|
grabbed window can never become a trap (VT switching restored on every exit
|
|
path, every callback error swallowed and surfaced) -- now live in the shared
|
|
``gatelock`` package, also used by wake_alarm and screen-locker. ``MealGate``
|
|
owns a :class:`~gatelock.LockWindow` and implements
|
|
:class:`~gatelock.LockWindowHooks`.
|
|
|
|
The window walks the user through each *missing* meal slot in turn (coming home
|
|
at 17:00 backfills 08:00, then 12:00, then 16:00) and dismisses only once every
|
|
elapsed slot carries a logged meal.
|
|
|
|
Resolution is built around one idea: the macro fields plus the "per" field hold
|
|
the food's nutrition *as a reference for some amount*, and how much you ate
|
|
scales that reference into the total that is logged. Measure by **grams** and
|
|
the reference is "per 100 g" off a label; measure by **items** and it is "per 1
|
|
item" (with the piece's approximate weight, which you can correct). Either way
|
|
the total shown in the preview is exactly what gets recorded, and changing how
|
|
much you ate never rewrites the reference fields, so the two cannot desync. As
|
|
you type, the picker offers your banked foods and built-in staples, so a common
|
|
food fills in one click. Leaving the calorie field blank looks the food up
|
|
(food bank, then staples, then Open Food Facts), fills the fields, names the
|
|
source, and offers alternatives. A running dashboard makes the day's calories
|
|
prominent, with macros and the protein target beneath. The unlock condition is
|
|
*logging*, never *estimating correctly*: a manual calorie value always works
|
|
offline, so a dead OFF endpoint can never trap you behind the lock.
|
|
|
|
Building ``MealGate`` spans several sibling modules to keep each under the
|
|
repo's 500-line limit: :mod:`._gatelock_core` provides the shared leaf
|
|
widget/field helpers and state (``_GateCore``, ``_GateState``);
|
|
:mod:`._gatelock_nutrition` provides the reference->total nutrition maths and
|
|
food lookup (``_GateNutrition``); :mod:`._gatelock_mealflow` provides the
|
|
submit/log flow, dashboard, and callback-error handling (``_GateMealFlow``);
|
|
and :mod:`._gatelock_calendar` provides the second ``ttk.Notebook`` tab --
|
|
the budget-adherence calendar, streaks, and YTD tally from
|
|
:mod:`._daystatus`, plus budget editing (``_GateCalendar``). ``MealGate``
|
|
wires these mixins together, owns the ``gatelock.LockWindow``, and handles
|
|
construction, layout, and event binding.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import fcntl
|
|
import sys
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock import (
|
|
RANK_DIET_GUARD,
|
|
Arbiter,
|
|
GateRoot,
|
|
LockConfig,
|
|
LockWindow,
|
|
SurfaceInfo,
|
|
)
|
|
|
|
from diet_guard._constants import GATE_LOCK_FILE
|
|
from diet_guard._gate import due_slots
|
|
from diet_guard._gatelock_calendar import _GateCalendar, make_calendar_vars
|
|
from diet_guard._gatelock_core import _GateState
|
|
from diet_guard._gatelock_groups import GateWidgetsGroup
|
|
from diet_guard._gatelock_ui import (
|
|
GateCallbacks,
|
|
make_vars,
|
|
)
|
|
from diet_guard._slots import current_slot, day_slots
|
|
from diet_guard._state import now_local
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import TextIO
|
|
|
|
|
|
def _assert_not_under_pytest() -> None:
|
|
"""Raise if a real Tk gate is being built inside a pytest run.
|
|
|
|
Defence-in-depth: prevents a real fullscreen window from locking the screen
|
|
when a test forgets to mock ``tk.Tk``. When ``tk`` is mocked the module
|
|
name is no longer ``tkinter``, so genuine mocked tests pass straight through.
|
|
"""
|
|
if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter":
|
|
msg = "SAFETY: MealGate built under pytest with real tkinter (tk.Tk unmocked)"
|
|
raise RuntimeError(msg)
|
|
|
|
|
|
def acquire_gate_lock() -> TextIO | None:
|
|
"""Acquire the gate's single-instance ``flock``.
|
|
|
|
Returns:
|
|
An open file handle that must be kept alive for the gate's lifetime
|
|
(closing it releases the lock), or None if another gate already holds
|
|
it -- in which case the caller must not open a second window.
|
|
"""
|
|
GATE_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
handle = GATE_LOCK_FILE.open("w", encoding="utf-8")
|
|
try:
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
handle.close()
|
|
return None
|
|
return handle
|
|
|
|
|
|
def release_gate_lock(handle: TextIO) -> None:
|
|
"""Release the single-instance lock and close its handle."""
|
|
with contextlib.suppress(OSError):
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
handle.close()
|
|
|
|
|
|
def _pending_slots(*, demo_mode: bool) -> list[int]:
|
|
"""Return the slots the window must collect before it can unlock.
|
|
|
|
In production this is exactly the elapsed-but-unlogged slots. In demo mode
|
|
-- where there may be nothing genuinely due -- fall back to a representative
|
|
slot so the UI is always demonstrable.
|
|
|
|
Args:
|
|
demo_mode: Whether the window is a safe sandbox.
|
|
|
|
Returns:
|
|
The slot hours to collect, ascending.
|
|
"""
|
|
pending = list(due_slots())
|
|
if pending:
|
|
return pending
|
|
if demo_mode:
|
|
return [current_slot(now_local()) or day_slots()[0]]
|
|
return []
|
|
|
|
|
|
class MealGate(_GateCalendar):
|
|
"""A fullscreen lock that dismisses only once every missing slot is logged."""
|
|
|
|
def __init__(self, *, demo_mode: bool = True) -> None:
|
|
"""Build the lock window.
|
|
|
|
Args:
|
|
demo_mode: When True, use a local (not global) input grab and add a
|
|
close button, so the gate can be exercised without locking the
|
|
real session. Production passes False.
|
|
"""
|
|
_assert_not_under_pytest()
|
|
self.demo_mode = demo_mode
|
|
self._pending = _pending_slots(demo_mode=demo_mode)
|
|
# All mutable logical state (provenance, suggestions, meal-in-progress)
|
|
# lives in one bundle; see _GateState for the per-field rationale.
|
|
self._state = _GateState()
|
|
self.root = GateRoot()
|
|
self.root.on_callback_error = self.on_callback_error
|
|
self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else ""))
|
|
# No bg=/fg=/... overrides here: LockConfig's own defaults already
|
|
# are the unified-design-system palette, and _gatelock_ui reads the
|
|
# same LockConfig() rather than a separate hardcoded copy -- see
|
|
# DESIGN_AUDIT_TODO.md for why an override here previously drifted.
|
|
config = LockConfig(
|
|
mode="soft" if demo_mode else "hard",
|
|
app_name="diet_guard",
|
|
rank=RANK_DIET_GUARD,
|
|
)
|
|
# Published here, then owned by LockWindow, which releases it on
|
|
# close -- so deliberately not kept as an attribute.
|
|
arbiter = Arbiter(
|
|
"diet_guard",
|
|
RANK_DIET_GUARD,
|
|
grab=config.resolved_grab(),
|
|
disable_vt=config.resolved_disable_vt(),
|
|
)
|
|
arbiter.publish()
|
|
arbiter.acquire_holder()
|
|
self._lock = LockWindow(self.root, config, hooks=self, arbiter=arbiter)
|
|
self._vars = make_vars(self.root)
|
|
# Built before setup(), which calls build_surface per live output.
|
|
self._cal_vars = make_calendar_vars(self.root)
|
|
self._cal_surfaces = []
|
|
self._widgets = GateWidgetsGroup(self._vars)
|
|
self._callbacks = GateCallbacks(
|
|
on_unit_change=self._on_unit_change,
|
|
on_submit=self._on_submit,
|
|
on_close=self.close,
|
|
on_fetch_sync=self._on_fetch_sync,
|
|
)
|
|
self._build()
|
|
|
|
def build_surface(self, parent: tk.Misc, surface: SurfaceInfo) -> None:
|
|
"""Lay the whole gate out on one monitor.
|
|
|
|
Called once per live output, and again whenever an output comes back.
|
|
Every copy shares one set of GateVars, so a value typed on any screen
|
|
is the same value on all of them and logging works from whichever
|
|
monitor the user is actually looking at.
|
|
"""
|
|
widgets = self._build_tabs(parent, self._callbacks)
|
|
self._widgets.add(widgets, surface.output_name)
|
|
# An output coming back mid-lock gets its bindings too; tk's bind
|
|
# replaces rather than stacks, so re-wiring the others is a no-op.
|
|
self._wire_events()
|
|
|
|
def teardown_surface(self, surface: SurfaceInfo) -> None:
|
|
"""Forget the copy for an output that went dark."""
|
|
self._widgets.discard(surface.output_name)
|
|
self._cal_surfaces = self._cal_surfaces[: len(self._widgets.bundles)]
|
|
|
|
def _build(self) -> None:
|
|
"""Lay out the UI, wire events, seed the first prompt, and grab input."""
|
|
self._lock.setup()
|
|
self._wire_events()
|
|
self._relabel_basis()
|
|
self._refresh_slot_header()
|
|
self._refresh_dashboard()
|
|
self._refresh_projection()
|
|
self._refresh_calendar()
|
|
self._lock.grab_input()
|
|
self._widgets.desc_text.focus_set()
|
|
|
|
def on_focus_ready(self, surface: SurfaceInfo | None) -> None:
|
|
"""Put keyboard focus on the description entry once it is mapped.
|
|
|
|
``surface`` is None when no output is live -- the gate is held with
|
|
nothing to show, so there is nothing to focus.
|
|
"""
|
|
if surface is None:
|
|
return
|
|
self._widgets.desc_text.focus_force()
|
|
|
|
def on_close(self) -> None:
|
|
"""No hardware/state to release; meal-log writes already happened."""
|
|
|
|
def close(self) -> None:
|
|
"""Restore VT switching and destroy the window (no process exit)."""
|
|
self._lock.close()
|
|
|
|
def run(self) -> None:
|
|
"""Run the Tk loop, restoring VT switching on every exit path."""
|
|
self._lock.run()
|
|
|
|
def _wire_events(self) -> None:
|
|
"""Bind the live per-keystroke events to the freshly built widgets.
|
|
|
|
Construction-time commands (button and option-menu) are wired inside
|
|
``build_layout``; the key bindings that drive lookup, scaling, and
|
|
submission are connected here, where the controller methods are in scope.
|
|
"""
|
|
widgets = self._widgets
|
|
widgets.desc_text.bind("<KeyRelease>", self._on_desc_keyrelease)
|
|
widgets.desc_text.bind("<Return>", self._on_desc_return)
|
|
widgets.suggestion_box.bind(
|
|
"<<ListboxSelect>>",
|
|
self._on_suggestion_select,
|
|
)
|
|
for entry in (widgets.amount_entry, widgets.per_entry):
|
|
entry.bind("<KeyRelease>", self._on_amount_change)
|
|
entry.bind("<Return>", self._on_return)
|
|
for entry in self._macro_entries():
|
|
entry.bind("<Return>", self._on_return)
|
|
entry.bind("<KeyRelease>", self._on_macro_edit)
|
|
|
|
def _on_desc_return(self, _event: tk.Event[tk.Misc]) -> str:
|
|
"""Submit on Enter in the description box, suppressing the newline."""
|
|
self._on_submit()
|
|
return "break"
|