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:
@@ -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))));
|
||||
|
||||
@@ -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>))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
247
client/lib/features/menu/plan_products_sheet.dart
Normal file
247
client/lib/features/menu/plan_products_sheet.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user