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 createState() => _FoodSearchSheetState(); } class _FoodSearchSheetState extends ConsumerState { 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 _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( 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( 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( 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( 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 = [ 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: null, onTap: onTap, ); } factory _FoodTile.fromRecent({ required RecentDiaryItem recentItem, required VoidCallback onTap, }) { final calories = recentItem.displayCalories; final parts = [ 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 _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 Row( children: [ for (final quickValue in [0.5, 1.0, 1.5, 2.0]) Padding( padding: const EdgeInsets.only(right: 8), child: 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), ), ], ), ); } }