Inserts a new PlanProductsSheet as step 1 of the planning flow. Users see their current products as a multi-select checklist (all selected by default) before choosing the planning mode and dates. - Empty state explains the benefit and offers "Add products" CTA while always allowing "Plan without products" to skip - Selected product IDs flow through PlanMenuSheet → PlanDatePickerSheet → MenuService.generateForDates → backend - Backend: added ProductIDs field to generate-menu request body; uses ListForPromptByIDs when set, ListForPrompt otherwise - Backend: added Repository.ListForPromptByIDs (filtered SQL query) - All 12 ARB locale files updated with planProducts* keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
604 lines
20 KiB
Dart
604 lines
20 KiB
Dart
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,
|
||
required this.selectedProductIds,
|
||
});
|
||
|
||
final PlanMode mode;
|
||
|
||
/// First date to pre-select. Typically tomorrow or the day after the last
|
||
/// planned date.
|
||
final DateTime defaultStart;
|
||
|
||
/// Product IDs selected in the previous step. Empty list means the AI
|
||
/// will use all of the user's products (default behaviour).
|
||
final List<String> selectedProductIds;
|
||
|
||
@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,
|
||
productIds: widget.selectedProductIds,
|
||
);
|
||
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(),
|
||
);
|
||
}
|
||
}
|