mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 17:33:55 +01:00
The Log Meal tab did not fit this laptop, and had not for some time. Measured
under a real 1366x768 X server: it required 866px inside a 739px content pane
with every field EMPTY, and 938px with a day of meals logged.
Because the content frame was place()-centred it was sheared at BOTH edges at
once -- the "Diet Gate" title and the slot header (which meal you are being
asked to log) off the top, the calorie headline and the whole dashboard off the
bottom -- with no scrollbar anywhere in the package and nothing to indicate
content was missing. This is a hard lock: overrideredirect, a global grab, VT
switching disabled. Unreachable content is unrecoverable.
Two independent causes, both needed fixing:
- Every font size was handed to Tk as *points*. Tk encodes the unit in the
SIGN of the size, and _gatelock_typography carried a comment asserting the
opposite ("Tk has no separate px/pt distinction"). At ~100 DPI that made
every string ~30% oversized. Negative sizes now; the docstring records why.
- There was no scroll container. build_layout now uses gatelock's
ScrollableSurface, which top-anchors, scrolls by keyboard, and scrolls to
follow focus.
Font sizes alone were not enough: they take the layout to 702px, still over the
571px pane at 1024x600. Both fixes are load-bearing.
The grams/items selector was mouse-only. tk.OptionMenu's Menubutton defaults to
takefocus=0, so ::tk::FocusOK rejects it and it never entered the tab ring --
verified by walking the real ring, which ran Entry -> Text -> Listbox -> Button
-> TNotebook and skipped it entirely. Switching units rescales the whole entry,
so a pointerless user could not correct "250 grams" to "3 items" inside a lock
they cannot leave without logging a meal. It is now two Radiobuttons, which are
in the ring by default and take both Space and Return -- and which post nothing,
unlike a menu, whose grab-stealing is why screen-locker bans OptionMenu on lock
surfaces outright.
Also: the focus ring was invisible (Tk's default is 1px BLACK on #211D1B) and
_build_desc actively inverted it by setting highlightbackground, the UNfocused
ring, so the box outlined while idle and went black the moment it took focus.
Enter now activates buttons (X11 Tk binds only <space>). Text widgets no longer
trap Tab. notebook.enable_traversal() is called, so Ctrl+Tab and Ctrl+PageDown
work. wraplength drops 900 -> 640, the line-length cap, which also stops a
label forcing a 900px-wide layout onto a 1024px screen.
The Flutter app had two controls with no keyboard route at all. Its desktop
target is the web build in a Chrome --app window, so a touch gesture is not a
fallback there, it is the only interaction:
- Deleting a history entry was bound solely to onLongPress. Long-press has no
keyboard equivalent whatsoever. It now has a focusable IconButton; the
long-press stays as a redundant pointer shortcut.
- Calendar day cells were a bare GestureDetector, so navigating to
date-filtered history was pointer-only and invisible to a screen reader.
Now Semantics(button: true) over an InkWell.
Plus: the filter sheet built a TextEditingController inside build() with the
caret forced to end-of-text, so every keystroke discarded it and silently undid
mid-string Home/End/arrow editing. And nothing capped width, so on the 1366px
desktop window prose ran ~180 characters per line (twice the readable limit)
and a four-digit kcal field spanned ~1334px. AppWidth adds those caps.
Tests: the render check is a failing test, not a script -- ruff runs with
--unsafe-fixes, whose T201 fix DELETES print statements, which would silently
turn a diagnostic into a no-op. test_gate_layout_fits.py was verified red on
the pre-fix code ("content 866 in viewport 768 -> NO SCROLL VIEWPORT ->
UNREACHABLE CONTENT") and green after. The Flutter suite gains its first
keyboard-reachability assertions; it previously had ~109 tester.tap calls and
zero checks that anything was focusable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
238 lines
8.9 KiB
Dart
238 lines
8.9 KiB
Dart
/// Monthly calendar colored by daily budget-adherence status.
|
|
library;
|
|
|
|
import 'package:diet_guard_app/models/day_status.dart';
|
|
import 'package:diet_guard_app/ui/theme.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// Monthly calendar widget that colors each day by its [DayStatus].
|
|
///
|
|
/// Mirrors the diet_guard gate's calendar tab (`_gatelock_calendar.py` /
|
|
/// `_calendar_view.py`): not-logged renders as a black cell with a visible
|
|
/// outline (so it doesn't vanish into the dark background); a day after
|
|
/// [today] -- nothing has happened yet to judge -- renders neutrally,
|
|
/// never as a false "not logged" black cell.
|
|
class DayStatusCalendar extends StatelessWidget {
|
|
/// Creates a [DayStatusCalendar].
|
|
const DayStatusCalendar({
|
|
required this.statusByDate,
|
|
required this.month,
|
|
required this.today,
|
|
required this.onPrevMonth,
|
|
required this.onNextMonth,
|
|
this.onDaySelected,
|
|
super.key,
|
|
});
|
|
|
|
/// Per-day status, keyed `YYYY-MM-DD`. A past-or-today date absent from
|
|
/// this map is treated as [DayStatus.notLogged].
|
|
final Map<String, DayStatus> statusByDate;
|
|
|
|
/// Only the year and month of this DateTime are used.
|
|
final DateTime month;
|
|
|
|
/// The reference "today"; days after this render neutrally.
|
|
final DateTime today;
|
|
|
|
/// Called when the user taps the previous-month chevron.
|
|
final VoidCallback onPrevMonth;
|
|
|
|
/// Called when the user taps the next-month chevron.
|
|
final VoidCallback onNextMonth;
|
|
|
|
/// Called with a day's date when the user taps that day's cell.
|
|
///
|
|
/// Null disables tapping (cells render but do not respond). A cell
|
|
/// outside the displayed month is never tappable regardless.
|
|
final void Function(DateTime day)? onDaySelected;
|
|
|
|
static const _weekHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
|
|
|
static const _monthNames = [
|
|
'January',
|
|
'February',
|
|
'March',
|
|
'April',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'August',
|
|
'September',
|
|
'October',
|
|
'November',
|
|
'December',
|
|
];
|
|
|
|
String _dateKey(int year, int m, int day) =>
|
|
'$year-${m.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
|
|
|
/// Returns the status to render for [year]/[m]/[day], or null for a
|
|
/// future day (nothing to judge yet).
|
|
DayStatus? _statusFor(int year, int m, int day) {
|
|
final date = DateTime(year, m, day);
|
|
final todayOnly = DateTime(today.year, today.month, today.day);
|
|
if (date.isAfter(todayOnly)) return null;
|
|
return statusByDate[_dateKey(year, m, day)] ?? DayStatus.notLogged;
|
|
}
|
|
|
|
Color _cellColor(
|
|
DayStatus? status,
|
|
ColorScheme scheme,
|
|
AppStatusColors statusColors,
|
|
) => switch (status) {
|
|
// Future day: nothing to judge yet, so it reads as part of the
|
|
// panel rather than an empty/failed slot.
|
|
null => scheme.surfaceContainerHigh,
|
|
// Recessed below the panel (darker than the surrounding
|
|
// ink-raised-1 container) with a visible outline, so it reads as
|
|
// "empty" without vanishing into the background.
|
|
DayStatus.notLogged => scheme.surface,
|
|
DayStatus.red => scheme.error,
|
|
DayStatus.yellow => statusColors.warning,
|
|
DayStatus.green => statusColors.success,
|
|
};
|
|
|
|
Color _textColor(DayStatus? status, ColorScheme scheme) => switch (status) {
|
|
null => scheme.onSurfaceVariant,
|
|
DayStatus.notLogged => scheme.onSurface,
|
|
// Status colors sit at mid brightness, so the near-black `ink` role
|
|
// (not a separate pure black) gives strong, on-palette contrast.
|
|
DayStatus.red || DayStatus.yellow || DayStatus.green => scheme.surface,
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
// A `!` here would crash in any context that doesn't build its theme
|
|
// from buildAppTheme() -- most widget tests just wrap in a bare
|
|
// MaterialApp(home: ...). Falling back to the app's own dark instance
|
|
// matches production exactly when the extension is genuinely absent.
|
|
final statusColors =
|
|
Theme.of(context).extension<AppStatusColors>() ?? AppStatusColors.dark;
|
|
final year = month.year;
|
|
final m = month.month;
|
|
final daysInMonth = DateTime(year, m + 1, 0).day;
|
|
// weekday: 1=Mon..7=Sun -> offset 0..6
|
|
final firstWeekday = DateTime(year, m).weekday - 1;
|
|
final totalCells = firstWeekday + daysInMonth;
|
|
final rows = (totalCells / 7).ceil();
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(AppSpacing.md),
|
|
decoration: BoxDecoration(
|
|
color: scheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(Icons.chevron_left, color: scheme.onSurfaceVariant),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
onPressed: onPrevMonth,
|
|
),
|
|
Text(
|
|
'${_monthNames[m - 1]} $year',
|
|
style: TextStyle(
|
|
color: scheme.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: AppTextSize.body,
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(Icons.chevron_right, color: scheme.onSurfaceVariant),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: EdgeInsets.zero,
|
|
onPressed: onNextMonth,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.xs + 2),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: _weekHeaders
|
|
.map(
|
|
(h) => SizedBox(
|
|
width: 30,
|
|
child: Text(
|
|
h,
|
|
textAlign: TextAlign.center,
|
|
// Two-letter day abbreviations are UI chrome (like a
|
|
// badge/tag), deliberately kept below the 16px body
|
|
// floor rather than forced to grow past their slot.
|
|
style: TextStyle(
|
|
color: scheme.onSurfaceVariant,
|
|
fontSize: AppTextSize.label - 2,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
const SizedBox(height: AppSpacing.xs),
|
|
...List.generate(rows, (row) {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: List.generate(7, (col) {
|
|
final cell = row * 7 + col;
|
|
final day = cell - firstWeekday + 1;
|
|
if (day < 1 || day > daysInMonth) {
|
|
return const SizedBox(width: 30, height: 30);
|
|
}
|
|
final status = _statusFor(year, m, day);
|
|
final cellWidget = Container(
|
|
width: 30,
|
|
height: 30,
|
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: _cellColor(status, scheme, statusColors),
|
|
border: status == DayStatus.notLogged
|
|
? Border.all(color: scheme.outline)
|
|
: null,
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
'$day',
|
|
// A fixed 30px circle can't fit 16px bold digits for
|
|
// two-digit days without overflowing — kept as a
|
|
// deliberate chrome exception (rule 4), not accidental.
|
|
style: TextStyle(
|
|
color: _textColor(status, scheme),
|
|
fontSize: AppTextSize.label - 2,
|
|
fontWeight:
|
|
status != null && status != DayStatus.notLogged
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
);
|
|
if (onDaySelected == null) return cellWidget;
|
|
// InkWell, not a bare GestureDetector: tapping a day navigates
|
|
// to the date-filtered history, and a GestureDetector is not
|
|
// focusable, so that navigation had no keyboard route at all
|
|
// and no semantics for a screen reader. InkWell is a focus
|
|
// stop and is activated by Enter/Space.
|
|
final date = DateTime(year, m, day);
|
|
return Semantics(
|
|
button: true,
|
|
label: 'View $day/$m/$year',
|
|
child: InkWell(
|
|
onTap: () => onDaySelected!(date),
|
|
customBorder: const CircleBorder(),
|
|
child: cellWidget,
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|