mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 14:07:40 +01:00
Sixth batch of the 250-line sweep. All six gate sources named for this batch are now under cap, plus _gate_layout_probe.py, which this batch's import rewiring pulled into the changeset (over-cap count 38 -> 31). _gatelock_ui.py 477 -> 174 (palette/leaf-helper hub) _gate_layout_probe.py 346 -> 196 _gatelock_groups.py 308 -> 137 _gatelock_calendar_ui 295 -> 77 _gatelock_calendar.py 286 -> 210 _gatelock.py 263 -> 238 _gatelock_mealflow.py 259 -> 207 New siblings: _gatelock_fields (input widgets), _gatelock_layout (assembly), _gatelock_widgetgroups (per-widget mirroring), _gatelock_calendar_types (bundles/palette), _gatelock_calendar_widgets (History builders), _gatelock_dashboard and _gatelock_budgetedit (mixins), _gatelock_lockfile (the stdlib-only flock), _gate_focus_probe and _gate_measurement. The load-bearing part is the test seam, not the line counts. `_gate_fixtures._GATE_TK_MODULES` patches `tk` on a hand-listed tuple, and that list fails OPEN: a widget builder split into a new sibling that nobody adds to it builds *real* Tk widgets against fake parents. Neither existing detector sees that -- check_patch_targets.py only validates paths that still resolve, and the ~8s runtime only catches network calls. It bit twice during this split (real StringVars from make_calendar_vars returned MagicMocks instead of the asserted text), so test_gate_tk_modules.py now asserts the invariant directly: every module binding `tk` is in the patch set or named in _NO_WIDGETS with a reason. It caught a pre-existing gap too -- _gatelock_support was never in the set. Same fail-open shape drove two other choices: `escape_tab_trap` is a call-time indirection so the keyboard test's patch of `_escape_text_tab_trap` still bites, and the flock pair moved with its conftest patch target updated in the same edit (that one fails closed). build_layout is imported from _gatelock_layout directly rather than re-exported lazily, which avoids both a cycle and an import-outside-toplevel suppression. Verified: 703 Python tests / 100% branch coverage, the 5 layout-fits tests under xvfb, 555 Flutter tests (unchanged), flutter analyze clean, check_patch_targets 0, `sync` 0 (403 entries), pre-commit clean, and `gate --demo` renders both tabs with live dashboard data. ~/.local/share/diet_guard byte-identical across the batch (gate timer paused, restored after). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
208 lines
8.2 KiB
Python
208 lines
8.2 KiB
Python
"""Submit/record flow for the MealGate gate.
|
|
|
|
Split out of :mod:`._gatelock` to keep that module under the repo's 250-line
|
|
limit. ``_GateMealFlow`` extends
|
|
:class:`~diet_guard._gatelock_dashboard._GateDashboard` (itself over
|
|
:class:`~diet_guard._gatelock_nutrition._GateNutrition`) with the
|
|
submit/lookup/log flow and the per-slot input reset. The read-only
|
|
calorie/macro dashboard it calls into lives in :mod:`._gatelock_dashboard`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from diet_guard._foodbank import remember_food
|
|
from diet_guard._gate import due_slots
|
|
from diet_guard._gatelock_dashboard import _GateDashboard
|
|
from diet_guard._gatelock_ui import ERR, FG, UNIT_GRAMS
|
|
from diet_guard._resolve import lookup_candidates
|
|
from diet_guard._slots import slot_label
|
|
from diet_guard._state import log_meal
|
|
from diet_guard._sync import pull_shared_log
|
|
|
|
if TYPE_CHECKING:
|
|
from diet_guard._estimator import Nutrition
|
|
|
|
# How long the "unlocking..." confirmation lingers before the window tears down.
|
|
_UNLOCK_DELAY_MS = 1200
|
|
|
|
|
|
class _GateMealFlow(_GateDashboard):
|
|
"""Submit/lookup/log flow for a logged food."""
|
|
|
|
# -- slot walk ------------------------------------------------------------
|
|
|
|
def _clear_inputs(self) -> None:
|
|
"""Empty the food fields, picker, preview, and basis for a new slot."""
|
|
self._set_desc("")
|
|
self._widgets.amount_entry.delete(0, tk.END)
|
|
self._vars.unit.set(UNIT_GRAMS)
|
|
self._relabel_basis()
|
|
self._reset_per_default()
|
|
for entry in self._macro_entries():
|
|
entry.delete(0, tk.END)
|
|
self._widgets.suggestion_box.delete(0, tk.END)
|
|
self._state.suggestions = []
|
|
self._state.source = "manual"
|
|
self._state.last_reference = None
|
|
self._vars.preview.set("")
|
|
self._refresh_projection()
|
|
|
|
# -- behaviour ------------------------------------------------------------
|
|
|
|
def _set_status(self, text: str, *, error: bool = False) -> None:
|
|
"""Update the status line, red for errors."""
|
|
self._vars.status.set(text)
|
|
self._widgets.status_label.config(fg=ERR if error else FG)
|
|
|
|
def _on_return(self, _event: tk.Event[tk.Misc]) -> None:
|
|
"""Handle the Enter key in any entry field."""
|
|
self._on_submit()
|
|
|
|
def _on_submit(self) -> None:
|
|
"""Validate, then look the food up, or log it."""
|
|
description = self._get_desc()
|
|
if not description:
|
|
self._set_status("Type what you ate first.", error=True)
|
|
self._widgets.desc_text.focus_set()
|
|
return
|
|
|
|
values = self._macro_values()
|
|
if values is None:
|
|
self._set_status("Macros must be numbers.", error=True)
|
|
self._widgets.macros.kcal.focus_set()
|
|
return
|
|
|
|
if values[0] is None:
|
|
self._begin_lookup(description)
|
|
return
|
|
nutrition = self._current_nutrition()
|
|
if nutrition is None:
|
|
self._set_status("Enter the calories, then submit.", error=True)
|
|
self._widgets.macros.kcal.focus_set()
|
|
return
|
|
self._record(description, nutrition)
|
|
|
|
def _begin_lookup(self, description: str) -> None:
|
|
"""Step 1: look the food up, fill the label fields, offer alternatives.
|
|
|
|
Nothing is logged here -- the user must see and confirm the filled
|
|
values (a second submit) before they are recorded. The food is looked
|
|
up at its natural basis (per 100 g / serving); the amount eaten scales
|
|
it, so the lookup never bakes in a portion.
|
|
"""
|
|
self._set_status("looking up…")
|
|
self.root.update_idletasks()
|
|
candidates = lookup_candidates(description)
|
|
if not candidates:
|
|
self._set_status(
|
|
"Couldn't look that up. Enter the calories yourself, then submit.",
|
|
error=True,
|
|
)
|
|
self._widgets.macros.kcal.focus_set()
|
|
return
|
|
self._show_candidates(candidates)
|
|
self._apply_reference(candidates[0][1])
|
|
source = candidates[0][1].source
|
|
tail = (
|
|
"Review, or pick another below, then submit to log."
|
|
if len(candidates) > 1
|
|
else "Review the values, then submit to log."
|
|
)
|
|
self._set_status(f"Filled from {source}. {tail}")
|
|
|
|
def _record(self, description: str, nutrition: Nutrition) -> None:
|
|
"""Log and bank a single food for the current slot, then advance."""
|
|
log_meal(description, nutrition, self._slot_for_log())
|
|
remember_food(description, nutrition)
|
|
self._finish_slot(f"{nutrition.kcal:g} kcal ({nutrition.source})")
|
|
|
|
def _slot_for_log(self) -> int | None:
|
|
"""Return the slot to tag a log with -- None in demo (satisfies no slot).
|
|
|
|
A synthetic demo slot must never satisfy a real checkpoint, so demo logs
|
|
are slot-less: they still bank the food and update the dashboard, but do
|
|
not silently stop the production gate from firing.
|
|
"""
|
|
return None if self.demo_mode else self._pending[0]
|
|
|
|
def _finish_slot(self, summary: str) -> None:
|
|
"""Advance past the current slot after something was logged for it.
|
|
|
|
Args:
|
|
summary: A short description of what was logged (calories and
|
|
source), shown in the confirmation line.
|
|
"""
|
|
slot = self._pending[0]
|
|
self._pending.pop(0)
|
|
self._refresh_dashboard()
|
|
logged = f"Logged {slot_label(slot)}: {summary}"
|
|
if not self._pending:
|
|
self._unlock(logged)
|
|
return
|
|
self._clear_inputs()
|
|
self._refresh_slot_header()
|
|
self._set_status(f"{logged} — next meal, please.")
|
|
self._widgets.desc_text.focus_set()
|
|
|
|
def _unlock(self, logged: str) -> None:
|
|
"""Confirm the final log and tear the window down.
|
|
|
|
Teardown is scheduled *before* the budget is looked up, so a corrupt
|
|
budget file (which raises) can never re-trap the user at unlock time.
|
|
"""
|
|
self._set_status(f"{logged} — all meals logged, unlocking…")
|
|
self.root.after(_UNLOCK_DELAY_MS, self.close)
|
|
|
|
# -- manual sync ------------------------------------------------------------
|
|
|
|
def _on_fetch_sync(self) -> None:
|
|
"""Pull the shared log on demand and unlock any slots it now satisfies.
|
|
|
|
For a meal already logged on another device (typically the phone) that
|
|
has not yet propagated here: rather than re-entering it to unlock, the
|
|
user pulls it in. The pull blocks the window briefly (bounded by the
|
|
sync timeout) and fails closed -- a failure leaves the lock untouched.
|
|
"""
|
|
if self.demo_mode:
|
|
self._set_status("Fetch from sync is only available on the real lock.")
|
|
return
|
|
self._set_status("Fetching from sync…")
|
|
self.root.update_idletasks()
|
|
reason = pull_shared_log()
|
|
if reason is not None:
|
|
self._set_status(f"{reason} — still locked.", error=True)
|
|
return
|
|
self._reconcile_after_fetch()
|
|
|
|
def _reconcile_after_fetch(self) -> None:
|
|
"""Drop pending slots a freshly pulled meal now covers; unlock if none."""
|
|
still_due = set(due_slots())
|
|
satisfied_slots = [slot for slot in self._pending if slot not in still_due]
|
|
self._refresh_dashboard()
|
|
if not satisfied_slots:
|
|
self._set_status("No new meals found in sync.")
|
|
return
|
|
self._pending = [slot for slot in self._pending if slot in still_due]
|
|
if not self._pending:
|
|
self._unlock("Synced from another device")
|
|
return
|
|
self._clear_inputs()
|
|
self._refresh_slot_header()
|
|
count = len(satisfied_slots)
|
|
meal_word = "meal" if count == 1 else "meals"
|
|
self._set_status(f"Pulled {count} {meal_word} — next meal, please.")
|
|
self._widgets.desc_text.focus_set()
|
|
|
|
def on_callback_error(self) -> None:
|
|
"""Surface an unexpected callback error without dropping the grab."""
|
|
self._set_status(
|
|
"Something went wrong. Enter the calories, then submit again.",
|
|
error=True,
|
|
)
|
|
with contextlib.suppress(tk.TclError):
|
|
self._widgets.macros.kcal.focus_set()
|