feat: add product selection step before meal planning

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>
This commit is contained in:
dbastrikin
2026-03-23 16:07:28 +02:00
parent b6c75a3488
commit b38190ff5b
33 changed files with 1007 additions and 77 deletions

View File

@@ -19,6 +19,7 @@ import '../diary/food_search_sheet.dart';
import '../menu/menu_provider.dart';
import '../menu/plan_date_picker_sheet.dart';
import '../menu/plan_menu_sheet.dart';
import '../menu/plan_products_sheet.dart';
import '../profile/profile_provider.dart';
import '../scan/dish_result_screen.dart';
import '../scan/recognition_service.dart';
@@ -1597,19 +1598,33 @@ class _FutureDayPlanButton extends ConsumerWidget {
void _openPlanSheet(BuildContext context, WidgetRef ref) {
final defaultStart = DateTime.parse(dateString);
// Step 1: product selection
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => PlanMenuSheet(
onModeSelected: (mode) {
builder: (_) => PlanProductsSheet(
onContinue: (selectedProductIds) {
// Step 2: planning mode selection
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => PlanDatePickerSheet(
mode: mode,
defaultStart: defaultStart,
builder: (_) => PlanMenuSheet(
selectedProductIds: selectedProductIds,
onModeSelected: (mode, productIds) {
// Step 3: date / meal type selection
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => PlanDatePickerSheet(
mode: mode,
defaultStart: defaultStart,
selectedProductIds: productIds,
),
);
},
),
);
},

View File

@@ -192,11 +192,13 @@ class PlanMenuService {
Future<void> generateForDates({
required List<String> dates,
required List<String> mealTypes,
List<String> productIds = const [],
}) async {
final menuService = _ref.read(menuServiceProvider);
final plans = await menuService.generateForDates(
dates: dates,
mealTypes: mealTypes,
productIds: productIds,
);
for (final plan in plans) {
_ref.invalidate(menuProvider(isoWeekString(DateTime.parse(plan.weekStart))));

View File

@@ -27,15 +27,19 @@ class MenuService {
}
/// Generates meals for specific [dates] (YYYY-MM-DD) and [mealTypes].
/// When [productIds] is non-empty, only those products are passed to AI.
/// Returns the updated MenuPlan for each affected week.
Future<List<MenuPlan>> generateForDates({
required List<String> dates,
required List<String> mealTypes,
List<String> productIds = const [],
}) async {
final data = await _client.post('/ai/generate-menu', data: {
final body = <String, dynamic>{
'dates': dates,
'meal_types': mealTypes,
});
};
if (productIds.isNotEmpty) body['product_ids'] = productIds;
final data = await _client.post('/ai/generate-menu', data: body);
final plans = data['plans'] as List<dynamic>;
return plans
.map((planJson) => MenuPlan.fromJson(planJson as Map<String, dynamic>))

View File

@@ -16,6 +16,7 @@ class PlanDatePickerSheet extends ConsumerStatefulWidget {
super.key,
required this.mode,
required this.defaultStart,
required this.selectedProductIds,
});
final PlanMode mode;
@@ -24,6 +25,10 @@ class PlanDatePickerSheet extends ConsumerStatefulWidget {
/// 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();
@@ -107,6 +112,7 @@ class _PlanDatePickerSheetState extends ConsumerState<PlanDatePickerSheet> {
await ref.read(planMenuServiceProvider).generateForDates(
dates: dates,
mealTypes: mealTypes,
productIds: widget.selectedProductIds,
);
if (mounted) {
Navigator.pop(context);

View File

@@ -5,11 +5,17 @@ import 'package:food_ai/l10n/app_localizations.dart';
enum PlanMode { singleMeal, singleDay, days, week }
/// Bottom sheet that lets the user choose a planning horizon.
/// Closes itself and calls [onModeSelected] with the chosen mode.
/// Closes itself and calls [onModeSelected] with the chosen mode and the
/// product IDs selected in the previous step (may be empty).
class PlanMenuSheet extends StatelessWidget {
const PlanMenuSheet({super.key, required this.onModeSelected});
const PlanMenuSheet({
super.key,
required this.onModeSelected,
required this.selectedProductIds,
});
final void Function(PlanMode mode) onModeSelected;
final void Function(PlanMode mode, List<String> productIds) onModeSelected;
final List<String> selectedProductIds;
@override
Widget build(BuildContext context) {
@@ -61,7 +67,7 @@ class PlanMenuSheet extends StatelessWidget {
void _select(BuildContext context, PlanMode mode) {
Navigator.pop(context);
onModeSelected(mode);
onModeSelected(mode, selectedProductIds);
}
}

View File

@@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:food_ai/l10n/app_localizations.dart';
import '../../shared/models/user_product.dart';
import '../products/user_product_provider.dart';
/// Step-1 bottom sheet for the meal planning flow.
///
/// Shows the user's current products as a multi-select checklist so they
/// can choose which products the AI should consider when generating the menu.
/// All products are selected by default. The user may deselect individual
/// items or skip the step entirely.
///
/// Calls [onContinue] with the list of selected product IDs (empty list
/// means "plan without products").
class PlanProductsSheet extends ConsumerStatefulWidget {
const PlanProductsSheet({super.key, required this.onContinue});
final void Function(List<String> selectedProductIds) onContinue;
@override
ConsumerState<PlanProductsSheet> createState() => _PlanProductsSheetState();
}
class _PlanProductsSheetState extends ConsumerState<PlanProductsSheet> {
// IDs of products the user has checked. Null means "not yet initialised"
// (we wait for the first data frame to select all by default).
Set<String>? _selected;
void _initSelected(List<UserProduct> products) {
if (_selected == null) {
_selected = {for (final product in products) product.id};
}
}
void _toggleAll(List<UserProduct> products) {
setState(() {
if (_selected!.length == products.length) {
_selected = {};
} else {
_selected = {for (final product in products) product.id};
}
});
}
void _toggleProduct(String productId) {
setState(() {
if (_selected!.contains(productId)) {
_selected = Set.of(_selected!)..remove(productId);
} else {
_selected = Set.of(_selected!)..add(productId);
}
});
}
void _continue() {
Navigator.pop(context);
widget.onContinue(_selected?.toList() ?? []);
}
void _skip() {
Navigator.pop(context);
widget.onContinue([]);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final productsAsync = ref.watch(userProductsProvider);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.planProductsTitle,
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
l10n.planProductsSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
productsAsync.when(
loading: () => const _LoadingSkeleton(),
error: (_, __) => _EmptyState(onSkip: _skip, onAddProducts: () {
Navigator.pop(context);
context.push('/products');
}),
data: (products) {
if (products.isEmpty) {
return _EmptyState(
onSkip: _skip,
onAddProducts: () {
Navigator.pop(context);
context.push('/products');
},
);
}
_initSelected(products);
final allSelected = _selected!.length == products.length;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Select all / deselect all chip
Align(
alignment: Alignment.centerLeft,
child: ActionChip(
label: Text(
allSelected
? l10n.planProductsDeselectAll
: l10n.planProductsSelectAll,
),
onPressed: () => _toggleAll(products),
),
),
const SizedBox(height: 8),
// Product list (scrollable, capped at ~4 items before scrolling)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: ListView.builder(
shrinkWrap: true,
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
final isChecked =
_selected!.contains(product.id);
return CheckboxListTile(
value: isChecked,
onChanged: (_) => _toggleProduct(product.id),
title: Text(product.name),
subtitle: Text(
'${product.quantity.toStringAsFixed(product.quantity.truncateToDouble() == product.quantity ? 0 : 1)} ${product.unit}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
controlAffinity: ListTileControlAffinity.leading,
dense: true,
);
},
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _continue,
child: Text(l10n.planProductsContinue),
),
TextButton(
onPressed: _skip,
child: Text(l10n.planProductsSkip),
),
],
);
},
),
],
),
),
);
}
}
class _LoadingSkeleton extends StatelessWidget {
const _LoadingSkeleton();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final skeletonColor = theme.colorScheme.surfaceContainerHighest;
return Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(
3,
(index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Container(
height: 48,
decoration: BoxDecoration(
color: skeletonColor,
borderRadius: BorderRadius.circular(8),
),
),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState({required this.onSkip, required this.onAddProducts});
final VoidCallback onSkip;
final VoidCallback onAddProducts;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.kitchen_outlined,
size: 56,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
l10n.planProductsEmpty,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.planProductsEmptyMessage,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
FilledButton(
onPressed: onAddProducts,
child: Text(l10n.planProductsAddProducts),
),
TextButton(
onPressed: onSkip,
child: Text(l10n.planProductsSkipNoProducts),
),
],
);
}
}