diet-guard/diet_guard/_gatelock_body.py
Krzysztof kuhy Rudnicki 43e4b4070d Replace PC gate text with glyphs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:35:34 +01:00

247 lines
8.9 KiB
Python

"""The gate's Body tab: calorie goal, weight/body-fat logs + graph, BMI.
A self-contained component, not another link in ``MealGate``'s class chain:
that chain sits at pylint's max-parents cap (see the ``_gatelock_calendar``
notes), and the tab shares no state with the meal form. ``_build_tabs``
constructs one :class:`BodyTab` per monitor; it keeps itself alive through
the Tk callbacks it registers.
Everything shown is informational -- no button here writes the budget. Saving
a weight, body fat, the profile or the goal writes the Body document
(and ``w``, when the weight is the newest), then publishes on a background
thread so a slow network can never freeze a lock screen.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from gatelock import ScrollableSurface
from diet_guard import _gatelock_glyphs as g
from diet_guard._body_fat_store import MAX_BODY_FAT, MIN_BODY_FAT, set_body_fat
from diet_guard._body_prefs import saved_goal, set_goal
from diet_guard._body_store import (
MAX_KG,
MIN_KG,
newest_weight,
set_profile,
set_weight,
)
from diet_guard._body_view import BodyView, budget_or_default, build_view
from diet_guard._daystatus import day_total_kcal
from diet_guard._gatelock_body_glyphs import (
activity_glyphs,
bmi_glyphs,
bmr_glyphs,
bodyfat_glyphs,
ideal_glyphs,
plan_glyphs,
profile_glyphs,
target_glyphs,
)
from diet_guard._gatelock_body_graph import GraphBox, draw_weight_graph, in_range
from diet_guard._gatelock_body_input import (
HEIGHT_CM,
goal_choice,
load_goal,
parse_day,
parse_float,
)
from diet_guard._gatelock_body_vars import BodyCallbacks, BodyVars
from diet_guard._gatelock_body_widgets import GRAPH_H, GRAPH_W, build_body_frame
from diet_guard._gatelock_calendar_types import _COLORS
from diet_guard._phone_weight import update_weight
from diet_guard._state import load_log, now_local
from diet_guard._sync_events import publish_after_log_detached
from diet_guard._week_plan import plan_week
if TYPE_CHECKING:
from datetime import date
import tkinter as tk
from diet_guard._week_plan import WeekPlan
__all__ = ["BodyTab"]
# The day field's expected shape, shown when it does not parse.
_DAY_SHAPE = "0000-00-00"
_DAY_HINT = f"{g.ERROR} {g.DELETE} {_DAY_SHAPE}"
class BodyTab:
"""One monitor's Body tab."""
def __init__(self, notebook: tk.Misc) -> None:
"""Build the tab into ``notebook`` and render it once."""
self._budget = budget_or_default()
self._days: int | None = 90
self._goal = saved_goal()
self.vars = BodyVars()
callbacks = BodyCallbacks(
save_profile=self.save_profile,
save_weight=self.save_weight,
delete_weight=self.delete_weight,
save_body_fat=self.save_body_fat,
delete_body_fat=self.delete_body_fat,
goal_changed=self.goal_changed,
set_range=self.set_range,
recalc=self.refresh,
)
view = build_view(now_local().date())
surface = ScrollableSurface(notebook, _COLORS)
widgets = build_body_frame(
surface.content,
self.vars,
callbacks,
profile_first=not view.profile.complete,
)
widgets.frame.pack(fill="both", expand=True)
surface.finalize()
self.container = surface.container
self._canvas = widgets.canvas
self._load_inputs(view)
self.refresh()
def _load_inputs(self, view: BodyView) -> None:
"""Seed the input fields and table choice from what is stored."""
prof, logs = self.vars.profile, self.vars.logs
prof.birth.set(view.profile.birth or "")
height = view.profile.height_cm
prof.height.set(f"{height:g}" if height else "")
prof.sex.set(view.profile.sex or "")
today = now_local().date().isoformat()
logs.weight_day.set(today)
logs.fat_day.set(today)
self.vars.plan.target.set(str(self._budget))
load_goal(self.vars.goal, self._goal)
def refresh(self) -> None:
"""Recompute every block and redraw the graph from local state."""
today = now_local().date()
view = build_view(today, self._goal)
target = None if view.numbers is None else view.numbers.target
kcal = None if target is None else target.kcal
self.vars.goal.answer.set("" if kcal is None else f"→ {kcal} kcal")
texts = {
"summary": profile_glyphs(view, today),
"table": target_glyphs(view),
"bodyfat": bodyfat_glyphs(view),
"bmi": bmi_glyphs(view),
"ideal": ideal_glyphs(view),
"bmr": bmr_glyphs(view),
"workouts": activity_glyphs(view),
"plan": plan_glyphs(self._plan(today)),
}
for name, lines in texts.items():
self.vars.blocks[name].set("\n".join(lines))
self._draw(view, today)
def _plan(self, today: date) -> WeekPlan:
log = load_log()
typed = {
index: int(value)
for index, var in enumerate(self.vars.plan.days)
if (value := parse_float(var.get())) is not None
}
target = parse_float(self.vars.plan.target.get())
return plan_week(
today,
int(target) if target else self._budget,
{day: day_total_kcal(log, day) for day in log},
typed,
)
def _draw(self, view: BodyView, today: date) -> None:
fat = self.vars.logs.graph.get() == "fat"
values = view.body_fats if fat else view.weights
healthy = None if fat or view.numbers is None else view.numbers.healthy
box = GraphBox(GRAPH_W, GRAPH_H)
draw_weight_graph(
self._canvas, in_range(values, today, self._days), box, healthy
)
def set_range(self, days: int | None) -> None:
"""Show the last ``days`` days of the graph (None = all)."""
self._days = days
self.refresh()
def _saved(self, message: str) -> None:
self.vars.status.set(message)
# Saved here, but the publish failed: the reason goes to the log only.
publish_after_log_detached(
lambda _why: self.vars.status.set(f"{g.OK} {g.ERROR} {g.SYNC}")
)
self.refresh()
def goal_changed(self) -> None:
"""Re-answer and remember (synced) the goal the pickers now show."""
self._goal = goal_choice(self.vars.goal, self._goal)
load_goal(self.vars.goal, self._goal)
set_goal(self._goal)
self._saved(g.OK)
def save_profile(self) -> None:
"""Validate and store birth date, height and sex."""
prof = self.vars.profile
birth, height = parse_day(prof.birth.get()), parse_float(prof.height.get())
sex = prof.sex.get()
if (
birth is None
or height is None
or not HEIGHT_CM[0] <= height <= HEIGHT_CM[1]
or sex not in {"m", "f"}
):
self.vars.status.set(f"{g.ERROR} {g.BIRTH} cm {g.MALE}{g.FEMALE}")
return
set_profile(birth=birth, height_cm=height, sex=sex)
self._saved(g.OK)
def save_weight(self) -> None:
"""Validate and store the typed weigh-in."""
kg = parse_float(self.vars.logs.weight.get())
day = parse_day(self.vars.logs.weight_day.get())
if kg is None or day is None or not MIN_KG <= kg <= MAX_KG:
self.vars.status.set(
f"{g.ERROR} {MIN_KG:g}{g.DASH}{MAX_KG:g} kg {_DAY_SHAPE}"
)
return
self._store_weight(day, kg)
self._saved(f"{g.OK} {kg:g} kg {day}")
def delete_weight(self) -> None:
"""Delete the weigh-in for the typed day."""
day = parse_day(self.vars.logs.weight_day.get())
if day is None:
self.vars.status.set(_DAY_HINT)
return
self._store_weight(day, None)
self._saved(f"{g.DELETE} {day}")
@staticmethod
def _store_weight(day: str, kg: float | None) -> None:
set_weight(day, kg)
newest = newest_weight()
if newest is not None and day >= newest[0]:
update_weight(newest[1])
def save_body_fat(self) -> None:
"""Validate and store the typed body-fat reading."""
pct = parse_float(self.vars.logs.body_fat.get())
day = parse_day(self.vars.logs.fat_day.get())
if pct is None or day is None or not MIN_BODY_FAT <= pct <= MAX_BODY_FAT:
limits = f"{MIN_BODY_FAT:g}{g.DASH}{MAX_BODY_FAT:g}%"
self.vars.status.set(f"{g.ERROR} {limits} {_DAY_SHAPE}")
return
set_body_fat(day, pct)
self._saved(f"{g.OK} {pct:g}% {day}")
def delete_body_fat(self) -> None:
"""Delete the body-fat reading for the typed day."""
day = parse_day(self.vars.logs.fat_day.get())
if day is None:
self.vars.status.set(_DAY_HINT)
return
set_body_fat(day, None)
self._saved(f"{g.DELETE} {day}")