Files
food-ai/client/lib/features/menu/plan_date_picker_sheet.dart
dbastrikin 9a6b7800a3 fix: unify date limits, fix ISO week calculation, refactor home screen plan button
- Fix _isoWeek: correct Sunday shift (-3 instead of +4), use floor+1 formula,
  match jan1 timezone to input — planned meals now appear correctly for UTC+ users
- Add kPlanningHorizonDays=28 / kMenuPastWeeks=8 constants; apply to home date
  strip, plan picker (strip + calendar), and menu screen prev/next navigation
- Menu screen week nav: disable arrows at min/max limits using compareTo
- Home screen: replace _GenerateActionCard/_WeekPlannedChip conditional with
  always-visible _FutureDayPlanButton(dateString); show _DayPlannedChip only
  when the specific day has planned meals; remove standalone _PlanMenuButton
- _FutureDayPlanButton uses selected date as defaultStart instead of lastPlanned+1
- Rename weekPlannedLabel -> dayPlannedLabel across all 12 locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 23:25:46 +02:00

598 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:food_ai/l10n/app_localizations.dart';
import 'package:intl/intl.dart';
import '../../shared/constants/date_limits.dart';
import '../../shared/models/meal_type.dart';
import '../profile/profile_provider.dart';
import 'menu_provider.dart';
import 'plan_menu_sheet.dart';
/// Bottom sheet that collects the user's date (or date range) and meal type
/// choices, then triggers partial menu generation.
class PlanDatePickerSheet extends ConsumerStatefulWidget {
const PlanDatePickerSheet({
super.key,
required this.mode,
required this.defaultStart,
});
final PlanMode mode;
/// First date to pre-select. Typically tomorrow or the day after the last
/// planned date.
final DateTime defaultStart;
@override
ConsumerState<PlanDatePickerSheet> createState() =>
_PlanDatePickerSheetState();
}
class _PlanDatePickerSheetState extends ConsumerState<PlanDatePickerSheet> {
late DateTime _selectedDate;
late DateTime _rangeStart;
late DateTime _rangeEnd;
bool _rangeSelectingEnd = false;
// For singleMeal mode: selected meal type
String? _selectedMealType;
bool _loading = false;
@override
void initState() {
super.initState();
_selectedDate = widget.defaultStart;
_rangeStart = widget.defaultStart;
final windowDays = widget.mode == PlanMode.week ? 6 : 2;
_rangeEnd = widget.defaultStart.add(Duration(days: windowDays));
}
List<String> get _userMealTypes {
final profile = ref.read(profileProvider).valueOrNull;
return profile?.mealTypes ?? const ['breakfast', 'lunch', 'dinner'];
}
// ── Helpers ────────────────────────────────────────────────────────────────
String _formatDate(DateTime date) {
final locale = Localizations.localeOf(context).toLanguageTag();
return DateFormat('d MMMM', locale).format(date);
}
List<String> _buildDateList() {
if (widget.mode == PlanMode.singleMeal ||
widget.mode == PlanMode.singleDay) {
return [_formatApiDate(_selectedDate)];
}
final dates = <String>[];
var current = _rangeStart;
while (!current.isAfter(_rangeEnd)) {
dates.add(_formatApiDate(current));
current = current.add(const Duration(days: 1));
}
return dates;
}
List<String> _buildMealTypeList() {
if (widget.mode == PlanMode.singleMeal) {
return _selectedMealType != null ? [_selectedMealType!] : [];
}
return _userMealTypes;
}
String _formatApiDate(DateTime date) =>
'${date.year}-${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}';
bool get _canSubmit {
if (widget.mode == PlanMode.singleMeal && _selectedMealType == null) {
return false;
}
if ((widget.mode == PlanMode.days || widget.mode == PlanMode.week) &&
!_rangeEnd.isAfter(_rangeStart.subtract(const Duration(days: 1)))) {
return false;
}
return !_loading;
}
Future<void> _submit() async {
final dates = _buildDateList();
final mealTypes = _buildMealTypeList();
if (dates.isEmpty || mealTypes.isEmpty) return;
setState(() => _loading = true);
try {
await ref.read(planMenuServiceProvider).generateForDates(
dates: dates,
mealTypes: mealTypes,
);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.planSuccess),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
// ── Range picker interactions ──────────────────────────────────────────────
void _onDayTapped(DateTime date) {
if (date.isBefore(DateTime.now().subtract(const Duration(days: 1)))) return;
if (widget.mode == PlanMode.week) {
// Sliding 7-day window anchored to the tapped day.
setState(() {
_rangeStart = date;
_rangeEnd = date.add(const Duration(days: 6));
_rangeSelectingEnd = false;
});
return;
}
// days mode: first tap = start, second tap = end.
if (!_rangeSelectingEnd) {
setState(() {
_rangeStart = date;
_rangeEnd = date.add(const Duration(days: 2));
_rangeSelectingEnd = true;
});
} else {
if (date.isBefore(_rangeStart)) {
setState(() {
_rangeStart = date;
_rangeSelectingEnd = true;
});
} else {
setState(() {
_rangeEnd = date;
_rangeSelectingEnd = false;
});
}
}
}
// ── Build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Title
Text(
_sheetTitle(l10n),
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// Date selector
if (widget.mode == PlanMode.singleMeal ||
widget.mode == PlanMode.singleDay)
_DateStripSelector(
selected: _selectedDate,
onSelected: (date) =>
setState(() => _selectedDate = date),
)
else
_CalendarRangePicker(
rangeStart: _rangeStart,
rangeEnd: _rangeEnd,
onDayTapped: _onDayTapped,
),
const SizedBox(height: 16),
// Meal type selector (only for singleMeal mode)
if (widget.mode == PlanMode.singleMeal) ...[
Text(l10n.planSelectMealType,
style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
_MealTypeChips(
mealTypeIds: _userMealTypes,
selected: _selectedMealType,
onSelected: (id) =>
setState(() => _selectedMealType = id),
),
const SizedBox(height: 16),
],
// Summary line for range modes
if (widget.mode == PlanMode.days ||
widget.mode == PlanMode.week)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'${_formatDate(_rangeStart)} ${_formatDate(_rangeEnd)}'
' (${_rangeEnd.difference(_rangeStart).inDays + 1})',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
),
// Generate button
FilledButton(
onPressed: _canSubmit ? _submit : null,
child: _loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(l10n.planGenerateButton),
),
],
),
),
),
),
);
}
String _sheetTitle(AppLocalizations l10n) => switch (widget.mode) {
PlanMode.singleMeal => l10n.planOptionSingleMeal,
PlanMode.singleDay => l10n.planOptionDay,
PlanMode.days => l10n.planOptionDays,
PlanMode.week => l10n.planOptionWeek,
};
}
// ── Date strip (horizontal scroll) ────────────────────────────────────────────
class _DateStripSelector extends StatefulWidget {
const _DateStripSelector({
required this.selected,
required this.onSelected,
});
final DateTime selected;
final void Function(DateTime) onSelected;
@override
State<_DateStripSelector> createState() => _DateStripSelectorState();
}
class _DateStripSelectorState extends State<_DateStripSelector> {
late final ScrollController _scrollController;
// Show upcoming days up to the planning horizon (today excluded, starts tomorrow).
static const _futureDays = kPlanningHorizonDays;
static const _itemWidth = 64.0;
DateTime get _tomorrow =>
DateTime.now().add(const Duration(days: 1));
List<DateTime> get _dates => List.generate(
_futureDays, (index) => _tomorrow.add(Duration(days: index)));
@override
void initState() {
super.initState();
_scrollController = ScrollController();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
bool _isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
return SizedBox(
height: 72,
child: ListView.separated(
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: _dates.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (context, index) {
final date = _dates[index];
final isSelected = _isSameDay(date, widget.selected);
return GestureDetector(
onTap: () => widget.onSelected(date),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: _itemWidth,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
DateFormat('E', locale).format(date),
style: theme.textTheme.labelSmall?.copyWith(
color: isSelected
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
'${date.day}',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: isSelected
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface,
),
),
Text(
DateFormat('MMM', locale).format(date),
style: theme.textTheme.labelSmall?.copyWith(
color: isSelected
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
},
),
);
}
}
// ── Calendar range picker ──────────────────────────────────────────────────────
class _CalendarRangePicker extends StatefulWidget {
const _CalendarRangePicker({
required this.rangeStart,
required this.rangeEnd,
required this.onDayTapped,
});
final DateTime rangeStart;
final DateTime rangeEnd;
final void Function(DateTime) onDayTapped;
@override
State<_CalendarRangePicker> createState() => _CalendarRangePickerState();
}
class _CalendarRangePickerState extends State<_CalendarRangePicker> {
late DateTime _displayMonth;
@override
void initState() {
super.initState();
_displayMonth =
DateTime(widget.rangeStart.year, widget.rangeStart.month);
}
void _prevMonth() {
final now = DateTime.now();
if (_displayMonth.year == now.year && _displayMonth.month == now.month) {
return; // don't go into the past
}
setState(() {
_displayMonth =
DateTime(_displayMonth.year, _displayMonth.month - 1);
});
}
void _nextMonth() {
final horizon = DateTime.now().add(const Duration(days: kPlanningHorizonDays));
final limitMonth = DateTime(horizon.year, horizon.month);
if (!DateTime(_displayMonth.year, _displayMonth.month).isBefore(limitMonth)) return;
setState(() {
_displayMonth =
DateTime(_displayMonth.year, _displayMonth.month + 1);
});
}
bool _isBeyondHorizon(DateTime date) {
final today = DateTime.now();
final horizon = DateTime(today.year, today.month, today.day)
.add(const Duration(days: kPlanningHorizonDays));
return date.isAfter(horizon);
}
bool _isInRange(DateTime date) {
final dayOnly = DateTime(date.year, date.month, date.day);
final start =
DateTime(widget.rangeStart.year, widget.rangeStart.month, widget.rangeStart.day);
final end =
DateTime(widget.rangeEnd.year, widget.rangeEnd.month, widget.rangeEnd.day);
return !dayOnly.isBefore(start) && !dayOnly.isAfter(end);
}
bool _isRangeStart(DateTime date) =>
date.year == widget.rangeStart.year &&
date.month == widget.rangeStart.month &&
date.day == widget.rangeStart.day;
bool _isRangeEnd(DateTime date) =>
date.year == widget.rangeEnd.year &&
date.month == widget.rangeEnd.month &&
date.day == widget.rangeEnd.day;
bool _isPast(DateTime date) {
final today = DateTime.now();
return date.isBefore(DateTime(today.year, today.month, today.day));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final locale = Localizations.localeOf(context).toLanguageTag();
final monthLabel =
DateFormat('MMMM yyyy', locale).format(_displayMonth);
// Build the grid: first day of the month offset + days in month.
final firstDay = DateTime(_displayMonth.year, _displayMonth.month, 1);
// ISO weekday: Mon=1, Sun=7; leading empty cells before day 1.
final leadingBlanks = (firstDay.weekday - 1) % 7;
final daysInMonth =
DateUtils.getDaysInMonth(_displayMonth.year, _displayMonth.month);
final totalCells = leadingBlanks + daysInMonth;
return Column(
children: [
// Month navigation
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _prevMonth,
),
Text(monthLabel, style: theme.textTheme.titleMedium),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _nextMonth,
),
],
),
// Day-of-week header
Row(
children: ['M', 'T', 'W', 'T', 'F', 'S', 'S']
.map(
(dayLabel) => Expanded(
child: Center(
child: Text(
dayLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
)
.toList(),
),
const SizedBox(height: 4),
// Calendar grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
childAspectRatio: 1,
),
itemCount: totalCells,
itemBuilder: (context, index) {
if (index < leadingBlanks) {
return const SizedBox.shrink();
}
final dayNumber = index - leadingBlanks + 1;
final date = DateTime(
_displayMonth.year, _displayMonth.month, dayNumber);
final inRange = _isInRange(date);
final isStart = _isRangeStart(date);
final isEnd = _isRangeEnd(date);
final isPast = _isPast(date);
final isBeyond = _isBeyondHorizon(date);
final isDisabled = isPast || isBeyond;
Color bgColor = Colors.transparent;
Color textColor = theme.colorScheme.onSurface;
if (isDisabled) {
// ignore: deprecated_member_use
textColor = theme.colorScheme.onSurface.withOpacity(0.3);
} else if (isStart || isEnd) {
bgColor = theme.colorScheme.primary;
textColor = theme.colorScheme.onPrimary;
} else if (inRange) {
bgColor = theme.colorScheme.primaryContainer;
textColor = theme.colorScheme.onPrimaryContainer;
}
return GestureDetector(
onTap: isDisabled ? null : () => widget.onDayTapped(date),
child: Container(
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
'$dayNumber',
style: theme.textTheme.bodySmall?.copyWith(
color: textColor,
fontWeight:
(isStart || isEnd) ? FontWeight.bold : FontWeight.normal,
),
),
),
);
},
),
],
);
}
}
// ── Meal type chips ────────────────────────────────────────────────────────────
class _MealTypeChips extends StatelessWidget {
const _MealTypeChips({
required this.mealTypeIds,
required this.selected,
required this.onSelected,
});
final List<String> mealTypeIds;
final String? selected;
final void Function(String) onSelected;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Wrap(
spacing: 8,
runSpacing: 6,
children: mealTypeIds.map((mealTypeId) {
final option = mealTypeById(mealTypeId);
final label =
'${option?.emoji ?? ''} ${mealTypeLabel(mealTypeId, l10n)}'.trim();
return ChoiceChip(
label: Text(label),
selected: selected == mealTypeId,
onSelected: (_) => onSelected(mealTypeId),
);
}).toList(),
);
}
}