Files
food-ai/client/lib/features/diary/food_search_sheet.dart
dbastrikin bf8dce36c5 fix: show dish calories in search and fix portion sheet layout crash
- DishSearchResult now carries calories_per_serving (backend entity + repo
  LEFT JOIN recipes / MIN / GROUP BY; Flutter model + fromJson)
- _FoodTile.fromDish shows kcal/serving subtitle when available
- _DishPortionSheet quick-select buttons: Row → Wrap to avoid
  BoxConstraints infinite-width crash inside DraggableScrollableSheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 16:08:20 +02:00

690 lines
21 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
import '../../l10n/app_localizations.dart';
import '../../shared/models/product.dart';
import 'barcode_scan_screen.dart';
import 'food_search_provider.dart';
import 'food_search_service.dart';
import 'product_portion_sheet.dart';
/// Bottom sheet for searching and selecting food (product or dish) to add to diary.
///
/// When the search query is empty the sheet shows recently used items.
/// When the search query is non-empty it shows product and dish search results.
class FoodSearchSheet extends ConsumerStatefulWidget {
const FoodSearchSheet({
super.key,
required this.mealType,
required this.date,
required this.onAdded,
this.onScanDish,
});
final String mealType;
final String date;
/// Called after any diary entry has been successfully added.
final VoidCallback onAdded;
/// Optional callback to trigger AI dish-from-photo recognition.
/// When null the scan-photo chip is hidden.
final VoidCallback? onScanDish;
@override
ConsumerState<FoodSearchSheet> createState() => _FoodSearchSheetState();
}
class _FoodSearchSheetState extends ConsumerState<FoodSearchSheet> {
final TextEditingController _queryController = TextEditingController();
Timer? _debounce;
String _activeQuery = '';
@override
void initState() {
super.initState();
_queryController.addListener(_onQueryChanged);
}
@override
void dispose() {
_debounce?.cancel();
_queryController.dispose();
super.dispose();
}
void _onQueryChanged() {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
final trimmed = _queryController.text.trim();
if (trimmed != _activeQuery) {
setState(() => _activeQuery = trimmed);
}
});
}
Future<void> _addProductToDiary(
CatalogProduct catalogProduct, double portionGrams) async {
await ref.read(apiClientProvider).post('/diary', data: {
'product_id': catalogProduct.id,
'portion_g': portionGrams,
'meal_type': widget.mealType,
'date': widget.date,
'source': 'search',
});
}
void _openProductPortion(CatalogProduct catalogProduct) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(sheetContext).bottom,
),
child: ProductPortionSheet(
catalogProduct: catalogProduct,
onConfirm: (portionGrams) async {
try {
await _addProductToDiary(catalogProduct, portionGrams);
widget.onAdded();
if (mounted) Navigator.pop(context);
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.addFailed),
),
);
}
}
},
),
),
);
}
void _openDishPortion(DishSearchResult dish) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => _DishPortionSheet(
dishId: dish.id,
dishName: dish.name,
mealType: widget.mealType,
date: widget.date,
onAdded: () {
widget.onAdded();
Navigator.pop(context);
},
),
);
}
void _openRecentItem(RecentDiaryItem recentItem) {
if (recentItem.itemType == 'product' && recentItem.productId != null) {
final catalogProduct = CatalogProduct(
id: recentItem.productId!,
canonicalName: recentItem.name,
categoryName: recentItem.categoryName,
caloriesPer100g: recentItem.caloriesPer100g,
);
_openProductPortion(catalogProduct);
} else if (recentItem.itemType == 'dish' && recentItem.dishId != null) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => _DishPortionSheet(
dishId: recentItem.dishId!,
dishName: recentItem.name,
caloriesPerServing: recentItem.caloriesPerServing,
mealType: widget.mealType,
date: widget.date,
onAdded: () {
widget.onAdded();
Navigator.pop(context);
},
),
);
}
}
void _openBarcodeScanner() {
Navigator.pop(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => BarcodeScanScreen(
mealType: widget.mealType,
date: widget.date,
onAdded: widget.onAdded,
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.92,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (sheetContext, scrollController) {
return Column(
children: [
// Drag handle
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// Search field
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _queryController,
autofocus: true,
decoration: InputDecoration(
hintText: l10n.searchFoodHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _activeQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_queryController.clear();
setState(() => _activeQuery = '');
},
)
: null,
border: const OutlineInputBorder(),
contentPadding:
const EdgeInsets.symmetric(vertical: 12),
),
),
),
// Quick action chips
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Wrap(
spacing: 8,
children: [
if (widget.onScanDish != null)
ActionChip(
avatar:
const Icon(Icons.camera_alt_outlined, size: 18),
label: Text(l10n.scanDishPhoto),
onPressed: () {
Navigator.pop(context);
widget.onScanDish!();
},
),
ActionChip(
avatar: const Icon(Icons.qr_code_scanner, size: 18),
label: Text(l10n.scanBarcode),
onPressed: _openBarcodeScanner,
),
],
),
),
const SizedBox(height: 8),
// Results area
Expanded(
child: _activeQuery.isEmpty
? _RecentSection(
scrollController: scrollController,
onTap: _openRecentItem,
)
: _SearchResults(
query: _activeQuery,
scrollController: scrollController,
onTapProduct: _openProductPortion,
onTapDish: _openDishPortion,
),
),
],
);
},
);
}
}
// ---------------------------------------------------------------------------
// Recently used section
// ---------------------------------------------------------------------------
class _RecentSection extends ConsumerWidget {
const _RecentSection({
required this.scrollController,
required this.onTap,
});
final ScrollController scrollController;
final void Function(RecentDiaryItem) onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final recentState = ref.watch(recentDiaryItemsProvider);
return recentState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const SizedBox.shrink(),
data: (recentItems) {
if (recentItems.isEmpty) return const SizedBox.shrink();
return ListView.builder(
controller: scrollController,
itemCount: recentItems.length + 1, // +1 for header
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
l10n.recentlyUsedLabel,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color:
Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
);
}
final recentItem = recentItems[index - 1];
return _FoodTile.fromRecent(
recentItem: recentItem,
onTap: () => onTap(recentItem),
);
},
);
},
);
}
}
// ---------------------------------------------------------------------------
// Search results section
// ---------------------------------------------------------------------------
class _SearchResults extends ConsumerWidget {
const _SearchResults({
required this.query,
required this.scrollController,
required this.onTapProduct,
required this.onTapDish,
});
final String query;
final ScrollController scrollController;
final void Function(CatalogProduct) onTapProduct;
final void Function(DishSearchResult) onTapDish;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final productsState = ref.watch(productSearchProvider(query));
final dishesState = ref.watch(dishSearchProvider(query));
if (productsState.isLoading && dishesState.isLoading) {
return const Center(child: CircularProgressIndicator());
}
final products = productsState.valueOrNull ?? [];
final dishes = dishesState.valueOrNull ?? [];
if (products.isEmpty && dishes.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
l10n.noResultsForQuery(query),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
);
}
final items = <_ListItem>[];
if (products.isNotEmpty) {
items.add(_SectionHeader(l10n.productsSection));
items.addAll(products.map(_ProductItem.new));
}
if (dishes.isNotEmpty) {
items.add(_SectionHeader(l10n.dishesSection));
items.addAll(dishes.map(_DishItem.new));
}
return ListView.builder(
controller: scrollController,
itemCount: items.length,
itemBuilder: (context, index) {
final listItem = items[index];
if (listItem is _SectionHeader) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
listItem.title,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
);
} else if (listItem is _ProductItem) {
return _FoodTile.fromProduct(
catalogProduct: listItem.catalogProduct,
onTap: () => onTapProduct(listItem.catalogProduct),
);
} else if (listItem is _DishItem) {
return _FoodTile.fromDish(
dish: listItem.dish,
onTap: () => onTapDish(listItem.dish),
);
}
return const SizedBox.shrink();
},
);
}
}
// ── Flat-list item types ───────────────────────────────────────
sealed class _ListItem {}
final class _SectionHeader extends _ListItem {
final String title;
_SectionHeader(this.title);
}
final class _ProductItem extends _ListItem {
final CatalogProduct catalogProduct;
_ProductItem(this.catalogProduct);
}
final class _DishItem extends _ListItem {
final DishSearchResult dish;
_DishItem(this.dish);
}
// ---------------------------------------------------------------------------
// Universal food tile
// ---------------------------------------------------------------------------
class _FoodTile extends StatelessWidget {
const _FoodTile({
required this.leading,
required this.title,
this.subtitle,
required this.onTap,
});
final Widget leading;
final String title;
final String? subtitle;
final VoidCallback onTap;
factory _FoodTile.fromProduct({
required CatalogProduct catalogProduct,
required VoidCallback onTap,
}) {
final calories = catalogProduct.caloriesPer100g;
final parts = <String>[
if (catalogProduct.categoryName != null) catalogProduct.categoryName!,
if (calories != null) '${calories.toInt()} kcal/100g',
];
return _FoodTile(
leading: CircleAvatar(
radius: 20,
backgroundColor: Colors.orange.shade50,
child: const Icon(Icons.fastfood_outlined,
size: 20, color: Colors.orange),
),
title: catalogProduct.displayName,
subtitle: parts.isNotEmpty ? parts.join(' · ') : null,
onTap: onTap,
);
}
factory _FoodTile.fromDish({
required DishSearchResult dish,
required VoidCallback onTap,
}) {
return _FoodTile(
leading: dish.imageUrl != null
? CircleAvatar(
radius: 20,
backgroundImage: NetworkImage(dish.imageUrl!),
)
: CircleAvatar(
radius: 20,
backgroundColor: Colors.green.shade50,
child: const Icon(Icons.restaurant,
size: 20, color: Colors.green),
),
title: dish.name,
subtitle: dish.caloriesPerServing != null
? '${dish.caloriesPerServing!.toInt()} kcal / serving'
: null,
onTap: onTap,
);
}
factory _FoodTile.fromRecent({
required RecentDiaryItem recentItem,
required VoidCallback onTap,
}) {
final calories = recentItem.displayCalories;
final parts = <String>[
if (recentItem.categoryName != null) recentItem.categoryName!,
if (calories != null)
recentItem.itemType == 'product'
? '${calories.toInt()} kcal/100g'
: '${calories.toInt()} kcal/serving',
];
return _FoodTile(
leading: recentItem.imageUrl != null
? CircleAvatar(
radius: 20,
backgroundImage: NetworkImage(recentItem.imageUrl!),
)
: CircleAvatar(
radius: 20,
backgroundColor: recentItem.itemType == 'product'
? Colors.orange.shade50
: Colors.green.shade50,
child: Icon(
recentItem.itemType == 'product'
? Icons.fastfood_outlined
: Icons.restaurant,
size: 20,
color: recentItem.itemType == 'product'
? Colors.orange
: Colors.green,
),
),
title: recentItem.name,
subtitle: parts.isNotEmpty ? parts.join(' · ') : null,
onTap: onTap,
);
}
@override
Widget build(BuildContext context) {
return ListTile(
leading: leading,
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
onTap: onTap,
);
}
}
// ---------------------------------------------------------------------------
// Dish portion sheet
// ---------------------------------------------------------------------------
class _DishPortionSheet extends ConsumerStatefulWidget {
const _DishPortionSheet({
required this.dishId,
required this.dishName,
this.caloriesPerServing,
required this.mealType,
required this.date,
required this.onAdded,
});
final String dishId;
final String dishName;
final double? caloriesPerServing;
final String mealType;
final String date;
final VoidCallback onAdded;
@override
ConsumerState<_DishPortionSheet> createState() => _DishPortionSheetState();
}
class _DishPortionSheetState extends ConsumerState<_DishPortionSheet> {
double _selectedPortions = 1.0;
bool _saving = false;
late final TextEditingController _portionsController =
TextEditingController(text: '1');
@override
void dispose() {
_portionsController.dispose();
super.dispose();
}
void _setPortions(double value) {
setState(() {
_selectedPortions = value;
_portionsController.text = value % 1 == 0
? value.toInt().toString()
: value.toStringAsFixed(1);
});
}
Future<void> _confirm() async {
final parsed = double.tryParse(_portionsController.text);
final portions =
(parsed != null && parsed > 0) ? parsed : _selectedPortions;
setState(() => _saving = true);
try {
await ref.read(apiClientProvider).post('/diary', data: {
'dish_id': widget.dishId,
'portions': portions,
'meal_type': widget.mealType,
'date': widget.date,
'source': 'search',
});
widget.onAdded();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.addFailed),
),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final insets = MediaQuery.viewInsetsOf(context);
return Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + insets.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(widget.dishName, style: theme.textTheme.titleMedium),
if (widget.caloriesPerServing != null)
Text(
'${widget.caloriesPerServing!.toInt()} kcal / serving',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
// Quick-select portion buttons
Wrap(
spacing: 8,
children: [
for (final quickValue in [0.5, 1.0, 1.5, 2.0])
OutlinedButton(
onPressed: () => _setPortions(quickValue),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
side: BorderSide(
color: _selectedPortions == quickValue
? theme.colorScheme.primary
: theme.colorScheme.outline,
width: _selectedPortions == quickValue ? 2 : 1,
),
),
child: Text(quickValue % 1 == 0
? quickValue.toInt().toString()
: quickValue.toStringAsFixed(1)),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _portionsController,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: l10n.servingsLabel,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _confirm,
child: _saving
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.addToDiary),
),
],
),
);
}
}