mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 14:07:40 +01:00
Three independent cleanups ahead of the configurable meal schedule.
Pills: the row was a `Wrap`, so pills dropped onto a second line as soon as
they outgrew the width -- which they do even at four. Measured: a logged chip
is ~124px at Material defaults, so four need 522px against ~330px of phone
width. Now a `FittedBox(scaleDown)` over a `Row`, which scales the row down
uniformly, plus compact chip density so there is less to scale away.
A `Row` of `Expanded` children was the obvious alternative and is wrong: it
imposes a tight width, and a Chip narrower than avatar+padding+label clips its
label rather than shrinking -- measured at 10px per label, about one character,
while throwing no overflow error. The new regression test therefore asserts
both a shared baseline and a legible label width; it was confirmed to fail on
the old `Wrap` ("chip wrapped to a second line at 320.0") rather than passing
vacuously.
Card: the `Logged "<meal>".` line restated the description the user had just
typed. Removed, along with `TodayProgress.justLogged`, which nothing else read.
The kcal position, macros and streak stay.
refactor_claude_todo.md: the 250-line file-splitting refactor it tracked is
done -- `scripts/check_file_length.sh --all` exits 0 and an independent sweep
finds nothing over the cap. DESIGN_AUDIT_TODO.md is a separate, still-open
effort and stays.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
109 lines
4.5 KiB
Dart
109 lines
4.5 KiB
Dart
/// A single row that both shows today's slot status (logged/due/upcoming)
|
|
/// and lets the user pick which slot they're logging for, replacing what
|
|
/// used to be three separate stacked elements.
|
|
library;
|
|
|
|
import 'package:diet_guard_app/models/slot.dart';
|
|
import 'package:diet_guard_app/ui/theme.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
/// One row of [ChoiceChip]s, one per today's slot hour. Each chip is
|
|
/// simultaneously selectable (tap to log for that slot) and status-colored
|
|
/// (green+check = logged, red = due, grey = upcoming), so no separate status
|
|
/// bar or caption text is needed.
|
|
///
|
|
/// There used to be a fifth "Snack" chip that selected no slot at all,
|
|
/// removed 2026-08-14. Entries with a null slot still exist in storage and
|
|
/// still arrive over sync, so the nullable types below are deliberate -- but
|
|
/// this widget no longer produces one.
|
|
class SlotSelectorRow extends StatelessWidget {
|
|
/// Creates a [SlotSelectorRow].
|
|
const SlotSelectorRow({
|
|
required this.now,
|
|
required this.loggedSlots,
|
|
required this.selectedSlot,
|
|
required this.onSlotSelected,
|
|
super.key,
|
|
});
|
|
|
|
/// Reference time used to decide which slots are due.
|
|
final DateTime now;
|
|
|
|
/// Slot hours already satisfied by today's log.
|
|
final Set<int> loggedSlots;
|
|
|
|
/// The slot currently chosen to log for. Nullable for historical reasons
|
|
/// (see the class doc); no chip in this row selects null any more.
|
|
final int? selectedSlot;
|
|
|
|
/// Called with the tapped slot's hour. Never called with null.
|
|
final ValueChanged<int?> onSlotSelected;
|
|
|
|
@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 elapsed = elapsedSlots(now).toSet();
|
|
// One row, always. A Wrap dropped the later pills onto a second line as
|
|
// soon as the row outgrew the width, which it does even at four: a logged
|
|
// chip measures ~124px at Material defaults, so four already need 522px
|
|
// against ~330px of phone width.
|
|
//
|
|
// FittedBox scales the whole row down uniformly instead. A Row of
|
|
// Expanded children also avoids the wrap, but imposes a *tight* width on
|
|
// each chip, and a Chip narrower than avatar+padding+label clips its label
|
|
// rather than shrinking -- measured at 10px per label, about one
|
|
// character, and it throws no overflow error while doing it.
|
|
return FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
alignment: Alignment.centerLeft,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
spacing: AppSpacing.xs + 2,
|
|
children: [
|
|
...daySlots().map((slot) {
|
|
final isLogged = loggedSlots.contains(slot);
|
|
final isDue = !isLogged && elapsed.contains(slot);
|
|
final color = isLogged
|
|
? statusColors.success
|
|
: isDue
|
|
? scheme.error
|
|
: scheme.onSurfaceVariant;
|
|
final isSelected = selectedSlot == slot;
|
|
return ChoiceChip(
|
|
label: Text(slotLabel(slot)),
|
|
selected: isSelected,
|
|
// Icon reads lighter than the label text (rule 28): reduced
|
|
// opacity instead of the label's full-strength color.
|
|
avatar: isLogged
|
|
? Icon(
|
|
Icons.check,
|
|
size: 14,
|
|
color: color.withValues(alpha: 0.72),
|
|
)
|
|
: null,
|
|
backgroundColor: color.withValues(alpha: 0.15),
|
|
selectedColor: color.withValues(alpha: 0.35),
|
|
labelStyle: TextStyle(color: color),
|
|
side: BorderSide(
|
|
width: isSelected ? 2 : 1,
|
|
color: isSelected ? color : color.withValues(alpha: 0.4),
|
|
),
|
|
// Trim the Material defaults so the row needs less scaling down:
|
|
// every pixel saved here is legibility kept at six meals.
|
|
visualDensity: VisualDensity.compact,
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
onSelected: (_) => onSlotSelected(slot),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|