diet-guard/app/lib/widgets/slot_selector_row.dart
Krzysztof kuhy Rudnicki f83938bfe3 app: minute-of-day slots (phase 2 of 3: every app consumer)
- FoodEntry.slot -> slotMinute: read via entrySlotMinute, written via
  slotFields (whole-hour stays byte-identical, else slot_min), kept by
  copyWithDeleted and the edit screen, so the phone never republishes a
  stripped copy of a PC entry. Out-of-range peer values echo, never throw.
- Satisfaction is nearest-slot snapping (satisfiedSlots) everywhere:
  loggedSlotsToday, due check, kuchnia occupancy, future-day logged set
  (that day's schedule), slot selector. Schedule edits no longer un-log.
- Future pre-log builds DateTime from slot ~/ 60, slot % 60.
- Schedule history + sync go through scheduleToWire/scheduleFromWire.
- Notifications: ids are slot minutes; syncToSlots cancels whatever the
  platform reports active (getActiveNotifications, reminder channel) that
  is not due, covering legacy hour ids; full 0..1439 sweep only if the
  lookup throws.
- Settings: 15-minute dropdowns + "Custom..." (24h showTimePicker), off-grid
  values always selectable, counts capped by grid capacity; editor moved
  to widgets/meal_schedule_editor.dart + meal_time_dropdown.dart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
2026-10-10 19:55:26 +02:00

119 lines
5.0 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/meal_schedule.dart';
import 'package:diet_guard_app/models/slot.dart';
import 'package:diet_guard_app/services/meal_schedule_service.dart';
import 'package:diet_guard_app/ui/theme.dart';
import 'package:flutter/material.dart';
/// One row of [ChoiceChip]s, one per today's slot (labelled `HH:MM`). 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,
this.schedule,
super.key,
});
/// Reference time used to decide which slots are due.
final DateTime now;
/// The schedule whose slots to show; defaults to the one in force today.
///
/// Passed explicitly for a future day so the chips and [loggedSlots] (which
/// the caller snapped against that day's schedule) can never disagree.
final MealSchedule? schedule;
/// Slot minutes already satisfied by the day's log (`satisfiedSlots`).
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 minute. 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 schedule = this.schedule ?? MealScheduleService.current;
final elapsed = elapsedSlots(now, schedule).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(schedule).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),
);
}),
],
),
);
}
}