Files
food-ai/client/lib/features/menu/plan_date_picker_sheet.dart
dbastrikin 9580bff54e feat: flexible meal planning wizard — plan 1 meal, 1 day, several days, or a week
Backend:
- migration 005: expand menu_items.meal_type CHECK to all 6 types (second_breakfast, afternoon_snack, snack)
- ai/types.go: add Days and MealTypes to MenuRequest for partial generation
- openai/menu.go: parametrize GenerateMenu — use requested meal types and day count; add caloric fractions for all 6 meal types
- menu/repository.go: add UpsertItemsInTx for partial upsert (preserves existing slots); fix meal_type sort order in GetByWeek
- menu/handler.go: add dates+meal_types path to POST /ai/generate-menu; extract fetchImages/saveRecipes helpers; returns {"plans":[...]} for dates mode; backward-compatible with week mode

Client:
- PlanMenuSheet: bottom sheet with 4 planning horizon options
- PlanDatePickerSheet: adaptive sheet with date strip (single day/meal) or custom CalendarRangePicker (multi-day/week); sliding 7-day window for week mode
- menu_service.dart: add generateForDates
- menu_provider.dart: add PlanMenuService (generates + invalidates week providers), lastPlannedDateProvider
- home_screen.dart: add _PlanMenuButton card below quick actions; opens planning wizard
- l10n: 16 new keys for planning UI across all 12 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 12:10:52 +02:00

585 lines
19 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/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 30 upcoming days (today excluded, starts tomorrow).
static const _futureDays = 30;
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() {
setState(() {
_displayMonth =
DateTime(_displayMonth.year, _displayMonth.month + 1);
});
}
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);
Color bgColor = Colors.transparent;
Color textColor = theme.colorScheme.onSurface;
if (isPast) {
// 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: isPast ? 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(),
);
}
}