diet-guard/app/lib/widgets/slot_selector_row.dart
Krzysztof kuhy Rudnicki cbbebee79b Pin meal pills to one row; drop the "Logged" restatement
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
2026-08-16 14:18:39 +02:00

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),
);
}),
],
),
);
}
}