import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import '../../../pantry/domain/entities/pantry_category.dart'; import '../../../pantry/presentation/controllers/pantry_inventory_controller.dart'; import '../../domain/entities/food_product.dart'; import '../../domain/entities/meal_type.dart'; import '../controllers/food_search_controller.dart'; import '../controllers/nutrition_controller.dart'; /// What the search flow should do with a selected product. enum FoodSearchPreset { /// Emphasize logging the product to the daily food diary. log, /// Emphasize adding the product to pantry stock (routed from the pantry /// screen's "Scan Item" action). pantry, } /// Parses the `?preset=` route parameter; unknown values fall back to /// [FoodSearchPreset.log]. FoodSearchPreset foodSearchPresetFrom(String? raw) => raw == 'pantry' ? FoodSearchPreset.pantry : FoodSearchPreset.log; /// Whether this device can run the camera barcode scanner. /// /// The camera path is limited to iOS/Android; Web and desktop platforms /// (Windows/macOS/Linux) fall back to a manual UPC entry dialog. bool get supportsCameraScanner => !kIsWeb && (Platform.isAndroid || Platform.isIOS); /// Nutritional product search: debounced free-text queries backed by the /// local cache / Open Food Facts / USDA hierarchy plus UPC/EAN barcode /// scanning (camera on mobile, manual entry on desktop/web). /// /// Selecting a product opens an action sheet with full macros, a serving /// multiplier, and "Log to Diary" / "Add to Pantry" actions. class FoodSearchScreen extends ConsumerStatefulWidget { const FoodSearchScreen({super.key, this.preset = FoodSearchPreset.log}); /// Route preset (see [foodSearchPresetFrom]). final FoodSearchPreset preset; @override ConsumerState createState() => _FoodSearchScreenState(); } class _FoodSearchScreenState extends ConsumerState { final TextEditingController _searchController = TextEditingController(); @override void dispose() { _searchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final state = ref.watch(foodSearchControllerProvider); return Scaffold( appBar: AppBar( title: Text( widget.preset == FoodSearchPreset.pantry ? 'Scan for Pantry' : 'Search Foods', ), actions: [ IconButton( tooltip: supportsCameraScanner ? 'Scan barcode' : 'Enter barcode manually', icon: const Icon(Icons.qr_code_scanner), onPressed: _startBarcodeCapture, ), ], ), body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: TextField( controller: _searchController, autofocus: true, textInputAction: TextInputAction.search, decoration: InputDecoration( hintText: 'Search by name, brand, or barcode…', prefixIcon: const Icon(Icons.search), suffixIcon: state.query.isEmpty ? null : IconButton( tooltip: 'Clear search', icon: const Icon(Icons.clear), onPressed: _clearSearch, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), isDense: true, ), onChanged: (value) { ref .read(foodSearchControllerProvider.notifier) .onQueryChanged(value); // Rebuild so the clear button appears/disappears. setState(() {}); }, ), ), if (state.isSearching) const LinearProgressIndicator(), Expanded(child: _buildResults(state)), ], ), ), ), ); } // --------------------------------------------------------------------------- // Results // --------------------------------------------------------------------------- Widget _buildResults(FoodSearchState state) { if (state.errorMessage != null && state.products.isEmpty) { return _StatusView( icon: Icons.error_outline, iconColor: Theme.of(context).colorScheme.error, title: 'Search unavailable', message: state.errorMessage!, actionLabel: 'Retry', onAction: () => ref.read(foodSearchControllerProvider.notifier).retry(), ); } if (state.products.isEmpty) { if (!state.hasSearched && state.query.isEmpty) { return const _StatusView( icon: Icons.search, title: 'Find any food', message: 'Search by name or brand, or scan a UPC/EAN barcode ' 'with the camera button above.', ); } if (!state.isSearching && state.hasSearched) { return const _StatusView( icon: Icons.no_food, title: 'No products found', message: 'Try a different spelling, add a brand name, or scan ' 'the barcode instead.', ); } return const SizedBox.shrink(); } return ListView.separated( padding: const EdgeInsets.symmetric(vertical: 8), itemCount: state.products.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) => _ProductTile( product: state.products[index], onTap: () => _showProductActions(state.products[index]), ), ); } // --------------------------------------------------------------------------- // Barcode capture // --------------------------------------------------------------------------- void _clearSearch() { _searchController.clear(); ref.read(foodSearchControllerProvider.notifier).clear(); setState(() {}); } /// Mobile opens the camera scanner; desktop/web fall back to a manual UPC /// dialog with an explanatory notification. Future _startBarcodeCapture() async { if (!mounted) return; if (supportsCameraScanner) { final barcode = await _showScannerSheet(); if (barcode == null || !mounted) return; _searchController.text = barcode; await ref.read(foodSearchControllerProvider.notifier).lookupBarcode( barcode, ); setState(() {}); return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux ? 'Camera scanning is unavailable on desktop. ' 'Enter the UPC/EAN manually.' : 'Camera scanning is unavailable in this build. ' 'Enter the UPC/EAN manually.', ), duration: const Duration(seconds: 3), ), ); final barcode = await _showManualBarcodeDialog(); if (barcode == null || !mounted) return; _searchController.text = barcode; await ref.read(foodSearchControllerProvider.notifier).lookupBarcode( barcode, ); setState(() {}); } /// Full-height modal sheet hosting the live camera scanner; resolves with /// the first detected UPC/EAN raw value. Future _showScannerSheet() => showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => const _BarcodeScannerSheet(), ); /// Desktop/web fallback: a numeric UPC/EAN entry dialog. Future _showManualBarcodeDialog() { final controller = TextEditingController(); return showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Enter barcode'), content: TextField( controller: controller, autofocus: true, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'UPC / EAN', hintText: 'e.g. 012345678905', prefixIcon: Icon(Icons.qr_code), ), onSubmitted: (value) { final trimmed = value.trim(); Navigator.of(dialogContext).pop(trimmed.isEmpty ? null : trimmed); }, ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { final trimmed = controller.text.trim(); Navigator.of(dialogContext) .pop(trimmed.isEmpty ? null : trimmed); }, child: const Text('Look Up'), ), ], ), ).then((value) { controller.dispose(); return value; }); } // --------------------------------------------------------------------------- // Product action sheet // --------------------------------------------------------------------------- /// Shows the macro breakdown + serving multiplier sheet with "Log to /// Diary" and "Add to Pantry" actions. void _showProductActions(FoodProduct product) { showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (sheetContext) => _ProductActionsSheet( product: product, preset: widget.preset, ), ); } } /// Result row: thumbnail, name, brand, calories, and a protein badge. class _ProductTile extends StatelessWidget { const _ProductTile({required this.product, required this.onTap}); final FoodProduct product; final VoidCallback onTap; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return ListTile( leading: _Thumbnail(imageUrl: product.imageUrl), title: Text( product.productName, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( [ if (product.brand != null) product.brand!, '${product.calories.round()} kcal / serving', ].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: scheme.secondaryContainer, borderRadius: BorderRadius.circular(999), ), child: Text( '${product.proteinGrams.toStringAsFixed(1)}g', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: scheme.onSecondaryContainer, fontWeight: FontWeight.w600, ), ), ), onTap: onTap, ); } } /// Product thumbnail with graceful placeholder / error fallback. class _Thumbnail extends StatelessWidget { const _Thumbnail({this.imageUrl}); final String? imageUrl; @override Widget build(BuildContext context) { final url = imageUrl; if (url == null || url.isEmpty) { return const CircleAvatar(child: Icon(Icons.fastfood_outlined)); } return ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network( url, width: 44, height: 44, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const CircleAvatar( radius: 22, child: Icon(Icons.fastfood_outlined), ), loadingBuilder: (context, child, progress) { if (progress == null) return child; return const SizedBox( width: 44, height: 44, child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); }, ), ); } } /// Centered empty/error placeholder with an optional action button. class _StatusView extends StatelessWidget { const _StatusView({ required this.icon, required this.title, required this.message, this.iconColor, this.actionLabel, this.onAction, }); final IconData icon; final Color? iconColor; final String title; final String message; final String? actionLabel; final VoidCallback? onAction; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 48, color: iconColor), const SizedBox(height: 12), Text(title, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 4), Text(message, textAlign: TextAlign.center), if (actionLabel != null && onAction != null) ...[ const SizedBox(height: 16), FilledButton.tonal( onPressed: onAction, child: Text(actionLabel!), ), ], ], ), ), ); } } /// Modal sheet hosting the live [MobileScanner] camera preview. /// /// Only ever instantiated on platforms where [supportsCameraScanner] is /// true; desktop/web builds reach the manual entry dialog instead, so the /// method-channel camera never initializes on unsupported runtimes. class _BarcodeScannerSheet extends StatefulWidget { const _BarcodeScannerSheet(); @override State<_BarcodeScannerSheet> createState() => _BarcodeScannerSheetState(); } class _BarcodeScannerSheetState extends State<_BarcodeScannerSheet> { final MobileScannerController _controller = MobileScannerController( detectionSpeed: DetectionSpeed.noDuplicates, formats: const [ BarcodeFormat.upcA, BarcodeFormat.upcE, BarcodeFormat.ean8, BarcodeFormat.ean13, ], ); bool _handled = false; String? _error; @override void dispose() { _controller.dispose(); super.dispose(); } void _onDetect(BarcodeCapture capture) { if (_handled) return; final rawValue = capture.barcodes .map((barcode) => barcode.rawValue) .firstWhere((value) => value != null && value.isNotEmpty, orElse: () => null); if (rawValue == null) return; _handled = true; Navigator.of(context).pop(rawValue); } @override Widget build(BuildContext context) { return SizedBox( height: MediaQuery.of(context).size.height * 0.75, child: Column( children: [ Padding( padding: const EdgeInsets.all(12), child: Text( 'Point the camera at a UPC/EAN barcode', style: Theme.of(context).textTheme.titleMedium, ), ), if (_error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( _error!, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), Expanded( child: ClipRRect( borderRadius: const BorderRadius.vertical( top: Radius.circular(12), ), child: MobileScanner( controller: _controller, onDetect: _onDetect, errorBuilder: (context, error, child) { // Surface permission/hardware failures instead of a black // preview; the user can still enter the code manually. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() => _error = _describe(error)); }); return Center( child: Padding( padding: const EdgeInsets.all(16), child: Text( _describe(error), textAlign: TextAlign.center, ), ), ); }, ), ), ), ], ), ); } String _describe(MobileScannerException error) => switch (error.errorCode) { MobileScannerErrorCode.permissionDenied => 'Camera permission denied. Grant camera access in system settings ' 'or enter the barcode manually.', MobileScannerErrorCode.unsupported => 'This device cannot scan barcodes with the camera.', _ => 'Camera unavailable (${error.errorCode.name}).', }; } /// Serving-size multipliers offered by the action sheet. const List kServingMultipliers = [0.5, 1, 1.5, 2]; /// Action sheet for a resolved [FoodProduct]: full macro breakdown scaled by /// the selected serving multiplier, plus "Log to Diary" and "Add to Pantry". class _ProductActionsSheet extends ConsumerStatefulWidget { const _ProductActionsSheet({required this.product, required this.preset}); final FoodProduct product; /// Decides which action is visually primary. final FoodSearchPreset preset; @override ConsumerState<_ProductActionsSheet> createState() => _ProductActionsSheetState(); } class _ProductActionsSheetState extends ConsumerState<_ProductActionsSheet> { double _multiplier = 1; bool _busy = false; FoodProduct get product => widget.product; double _scaled(double value) => value * _multiplier; Future _logToDiary() async { if (_busy) return; setState(() => _busy = true); final logged = await ref.read(nutritionControllerProvider.notifier).logFood( mealType: _inferMealType(), description: _description(), proteinGrams: _scaled(product.proteinGrams), calories: _scaled(product.calories).round(), carbsGrams: _scaled(product.carbsGrams), fatGrams: _scaled(product.fatGrams), ); if (!mounted) return; setState(() => _busy = false); Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( logged ? 'Logged ${_description()} to your diary.' : 'Could not log this product.', ), ), ); } Future _addToPantry() async { if (_busy) return; setState(() => _busy = true); final added = await ref.read(pantryInventoryControllerProvider.notifier).addItem( name: product.productName, quantityOnHand: _scaled(product.servingSizeGrams), unit: product.servingSizeUnit, category: PantryCategory.infer(product.productName), ); if (!mounted) return; setState(() => _busy = false); Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( added ? 'Added ${_description()} to your pantry.' : 'Could not add this product to the pantry.', ), ), ); } /// `Greek Yogurt, Plain ×1.5` — the multiplier suffix is omitted at 1x. String _description() => _multiplier == 1 ? product.productName : '${product.productName} ×$_multiplier'; /// Best-effort meal slot from the current time of day. MealType _inferMealType() { final hour = DateTime.now().hour; if (hour < 10) return MealType.breakfast; if (hour < 15) return MealType.lunch; if (hour < 21) return MealType.dinner; return MealType.snack; } @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; final pantryPrimary = widget.preset == FoodSearchPreset.pantry; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(product.productName, style: textTheme.titleLarge), if (product.brand != null) ...[ const SizedBox(height: 2), Text(product.brand!, style: textTheme.bodyMedium), ], const SizedBox(height: 12), _MacroGrid(product: product, multiplier: _multiplier), const SizedBox(height: 16), Text('Servings', style: textTheme.titleSmall), const SizedBox(height: 8), SegmentedButton( segments: [ for (final multiplier in kServingMultipliers) ButtonSegment( value: multiplier, label: Text('${multiplier}x'), ), ], selected: {_multiplier}, onSelectionChanged: (selection) => setState(() => _multiplier = selection.first), ), const SizedBox(height: 20), if (_busy) const Center(child: CircularProgressIndicator()) else ...[ FilledButton.icon( onPressed: pantryPrimary ? _addToPantry : _logToDiary, icon: Icon( pantryPrimary ? Icons.kitchen_outlined : Icons.restaurant_menu, ), label: Text( pantryPrimary ? 'Add to Pantry' : 'Log to Diary', ), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: pantryPrimary ? _logToDiary : _addToPantry, icon: Icon( pantryPrimary ? Icons.restaurant_menu : Icons.kitchen_outlined, ), label: Text( pantryPrimary ? 'Log to Diary' : 'Add to Pantry', ), ), ], ], ), ), ); } } /// Two-column macro grid (calories, protein, carbs, fat, fiber, sodium) /// scaled by the serving multiplier. class _MacroGrid extends StatelessWidget { const _MacroGrid({required this.product, required this.multiplier}); final FoodProduct product; final double multiplier; @override Widget build(BuildContext context) { final entries = >[ MapEntry( 'Calories', '${(product.calories * multiplier).round()} kcal', ), MapEntry( 'Protein', '${(product.proteinGrams * multiplier).toStringAsFixed(1)} g', ), MapEntry( 'Carbs', '${(product.carbsGrams * multiplier).toStringAsFixed(1)} g', ), MapEntry( 'Fat', '${(product.fatGrams * multiplier).toStringAsFixed(1)} g', ), if (product.fiberGrams != null) MapEntry( 'Fiber', '${(product.fiberGrams! * multiplier).toStringAsFixed(1)} g', ), if (product.sodiumMg != null) MapEntry( 'Sodium', '${(product.sodiumMg! * multiplier).round()} mg', ), ]; return Card( margin: EdgeInsets.zero, elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant), ), child: Padding( padding: const EdgeInsets.all(12), child: Column( children: [ for (var i = 0; i < entries.length; i += 2) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Expanded(child: _MacroCell(entry: entries[i])), if (i + 1 < entries.length) Expanded(child: _MacroCell(entry: entries[i + 1])) else const Expanded(child: SizedBox()), ], ), ), ], ), ), ); } } /// Single macro cell: label above, value below. class _MacroCell extends StatelessWidget { const _MacroCell({required this.entry}); final MapEntry entry; @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(entry.key, style: textTheme.labelMedium), Text(entry.value, style: textTheme.titleSmall), ], ); } }