/// Multi-select sheet for adding catalog products to a shopping list. library; import 'package:flutter/material.dart'; import '../models/product.dart'; import 'product_image.dart'; class AddListItemSelection { const AddListItemSelection({required this.name, this.productId}); final String name; final int? productId; } Future?> showAddListItemsSheet( BuildContext context, { required List products, }) { return showModalBottomSheet>( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) => _AddListItemsSheet(products: products), ); } class _AddListItemsSheet extends StatefulWidget { const _AddListItemsSheet({required this.products}); final List products; @override State<_AddListItemsSheet> createState() => _AddListItemsSheetState(); } class _AddListItemsSheetState extends State<_AddListItemsSheet> { final TextEditingController _controller = TextEditingController(); final Set _selectedProductIds = {}; String? _error; @override void dispose() { _controller.dispose(); super.dispose(); } List get _matches { final query = _controller.text.trim().toLowerCase(); if (query.isEmpty) return widget.products; return widget.products .where((product) => product.name.toLowerCase().contains(query)) .toList(); } int get _addCount { var count = _selectedProductIds.length; final typed = _controller.text.trim(); if (typed.isEmpty) return count; final selectedNames = { for (final product in widget.products) if (_selectedProductIds.contains(product.id)) product.name.toLowerCase(), }; if (!selectedNames.contains(typed.toLowerCase())) count++; return count; } void _toggleProduct(Product product) { setState(() { if (_selectedProductIds.contains(product.id)) { _selectedProductIds.remove(product.id); } else { _selectedProductIds.add(product.id); } _error = null; }); } void _add() { final selections = [ for (final product in widget.products) if (_selectedProductIds.contains(product.id)) AddListItemSelection(name: product.name, productId: product.id), ]; final typed = _controller.text.trim(); if (typed.isNotEmpty && !selections.any( (item) => item.name.toLowerCase() == typed.toLowerCase(), )) { selections.add(AddListItemSelection(name: typed)); } if (selections.isEmpty) { setState(() => _error = 'Select products or enter an item name.'); return; } Navigator.of(context).pop(selections); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final bottom = MediaQuery.viewInsetsOf(context).bottom; final matches = _matches; final addCount = _addCount; return Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), child: SizedBox( height: MediaQuery.sizeOf(context).height * 0.72, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Add items', style: theme.textTheme.titleLarge), const SizedBox(height: 12), TextField( controller: _controller, autofocus: true, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration( labelText: 'Search or type a new item', ), onChanged: (_) => setState(() => _error = null), ), if (_error != null) ...[ const SizedBox(height: 8), Text(_error!, style: TextStyle(color: theme.colorScheme.error)), ], const SizedBox(height: 8), Text('From products', style: theme.textTheme.labelMedium), const SizedBox(height: 4), Expanded( child: matches.isEmpty ? Center( child: Text( widget.products.isEmpty ? 'No products in your catalog yet.' : 'No products match this search.', style: theme.textTheme.bodyMedium, ), ) : ListView.builder( itemCount: matches.length, itemBuilder: (context, index) { final product = matches[index]; final selected = _selectedProductIds.contains( product.id, ); return ListTile( contentPadding: EdgeInsets.zero, leading: Checkbox( value: selected, onChanged: (_) => _toggleProduct(product), ), title: Text(product.name), trailing: ProductImage( name: product.name, path: product.imagePath, size: 32, ), onTap: () => _toggleProduct(product), ); }, ), ), FilledButton( onPressed: addCount == 0 ? null : _add, child: Text(addCount == 1 ? 'Add 1 item' : 'Add $addCount items'), ), ], ), ), ); } }