import 'package:flutter/material.dart'; import '../models/bean.dart'; import '../models/machine.dart'; import '../models/recipe.dart'; class SearchableSelection extends StatefulWidget { final List items; final String title; final String Function(T) displayText; final void Function(T) onItemSelected; final VoidCallback onAddCustom; final String? searchHint; const SearchableSelection({ super.key, required this.items, required this.title, required this.displayText, required this.onItemSelected, required this.onAddCustom, this.searchHint, }); @override State> createState() => _SearchableSelectionState(); } class _SearchableSelectionState extends State> { final TextEditingController _searchController = TextEditingController(); List _filteredItems = []; @override void initState() { super.initState(); _filteredItems = widget.items; _searchController.addListener(_filterItems); } @override void dispose() { _searchController.dispose(); super.dispose(); } void _filterItems() { final query = _searchController.text.toLowerCase(); setState(() { if (query.isEmpty) { _filteredItems = widget.items; } else { _filteredItems = widget.items.where((item) { final displayText = widget.displayText(item).toLowerCase(); return displayText.contains(query); }).toList(); } }); } Widget _buildItemTile(T item) { if (item is Bean) { return _buildBeanTile(item as Bean); } else if (item is Machine) { return _buildMachineTile(item as Machine); } else if (item is Recipe) { return _buildRecipeTile(item as Recipe); } return ListTile( title: Text(widget.displayText(item)), onTap: () => widget.onItemSelected(item), ); } Widget _buildBeanTile(Bean bean) { return Card( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), child: ListTile( leading: CircleAvatar( backgroundColor: _getRoastColor(bean.roastLevel), child: Text( bean.roastLevel.name[0].toUpperCase(), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ), ), title: Text( bean.name, style: const TextStyle(fontWeight: FontWeight.w600), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${bean.varietal} • ${bean.processingMethod}'), Text( 'Origin: ${bean.originCountry?.continent ?? 'Unknown'}', style: TextStyle(color: Colors.grey[600]), ), if (bean.tastingNotes.isNotEmpty) Wrap( spacing: 4, children: bean.tastingNotes.take(3).map((note) => Chip( label: Text( _formatTastingNote(note), style: const TextStyle(fontSize: 10), ), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, )).toList(), ), ], ), trailing: bean.preferred ? const Icon(Icons.star, color: Colors.amber) : null, onTap: () => widget.onItemSelected(bean as T), ), ); } Widget _buildMachineTile(Machine machine) { IconData icon; switch (machine.type) { case MachineType.espresso: icon = Icons.coffee_maker; break; case MachineType.frenchPress: icon = Icons.coffee; break; case MachineType.grinder: icon = Icons.settings; break; case MachineType.drip: icon = Icons.water_drop; break; default: icon = Icons.coffee_maker; } return Card( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), child: ListTile( leading: CircleAvatar( backgroundColor: Theme.of(context).primaryColor, child: Icon(icon, color: Colors.white), ), title: Text( '${machine.manufacturer} ${machine.model}', style: const TextStyle(fontWeight: FontWeight.w600), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Type: ${_formatMachineType(machine.type)}'), Text('Year: ${machine.year}'), if (machine.details.isNotEmpty) Text( machine.details, style: TextStyle(color: Colors.grey[600]), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), onTap: () => widget.onItemSelected(machine as T), ), ); } Widget _buildRecipeTile(Recipe recipe) { IconData icon; switch (recipe.brewMethod) { case BrewMethod.espresso: icon = Icons.coffee_maker; break; case BrewMethod.pourOver: icon = Icons.water_drop; break; case BrewMethod.frenchPress: icon = Icons.coffee; break; case BrewMethod.drip: icon = Icons.opacity; break; } return Card( margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), child: ListTile( leading: CircleAvatar( backgroundColor: _getServingTempColor(recipe.servingTemp), child: Icon(icon, color: Colors.white), ), title: Text( recipe.name, style: const TextStyle(fontWeight: FontWeight.w600), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Method: ${_formatBrewMethod(recipe.brewMethod)}'), Text('Ratio: ${recipe.coffeeAmount}g coffee : ${recipe.waterAmount}ml water'), Text('Brew time: ${_formatBrewTime(recipe.brewTime)}'), if (recipe.milkType != null) Text('Milk: ${_formatMilkType(recipe.milkType!)}'), ], ), onTap: () => widget.onItemSelected(recipe as T), ), ); } Color _getRoastColor(RoastLevel level) { switch (level) { case RoastLevel.light: return Colors.brown[300]!; case RoastLevel.medium: return Colors.brown[600]!; case RoastLevel.mediumLight: return Colors.brown[400]!; case RoastLevel.mediumDark: return Colors.brown[700]!; case RoastLevel.dark: return Colors.brown[900]!; } } Color _getServingTempColor(ServingTemp temp) { switch (temp) { case ServingTemp.hot: return Colors.red[400]!; case ServingTemp.cold: return Colors.blue[400]!; case ServingTemp.iced: return Colors.lightBlue[300]!; } } String _formatTastingNote(TastingNotes note) { switch (note) { case TastingNotes.stoneFruit: return 'Stone Fruit'; case TastingNotes.driedFruit: return 'Dried Fruit'; case TastingNotes.tropical: return 'Tropical'; case TastingNotes.brownSugar: return 'Brown Sugar'; default: return note.name[0].toUpperCase() + note.name.substring(1); } } String _formatMachineType(MachineType type) { switch (type) { case MachineType.frenchPress: return 'French Press'; case MachineType.coldBrew: return 'Cold Brew'; case MachineType.espressoPod: return 'Espresso Pod'; default: return type.name[0].toUpperCase() + type.name.substring(1); } } String _formatBrewMethod(BrewMethod method) { switch (method) { case BrewMethod.frenchPress: return 'French Press'; case BrewMethod.pourOver: return 'Pour Over'; default: return method.name[0].toUpperCase() + method.name.substring(1); } } String _formatMilkType(MilkType type) { return type.name[0].toUpperCase() + type.name.substring(1); } String _formatBrewTime(int seconds) { final minutes = seconds ~/ 60; final remainingSeconds = seconds % 60; if (minutes > 0) { return '${minutes}m ${remainingSeconds}s'; } return '${seconds}s'; } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), actions: [ IconButton( icon: const Icon(Icons.add), onPressed: widget.onAddCustom, tooltip: 'Add Custom', ), ], ), body: Column( children: [ Padding( padding: const EdgeInsets.all(16.0), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: widget.searchHint ?? 'Search ${widget.title.toLowerCase()}...', prefixIcon: const Icon(Icons.search), suffixIcon: _searchController.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); _filterItems(); }, ) : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), ), ), if (_filteredItems.isEmpty) Expanded( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.search_off, size: 64, color: Colors.grey[400], ), const SizedBox(height: 16), Text( 'No items found', style: Theme.of(context).textTheme.headlineSmall?.copyWith( color: Colors.grey[600], ), ), const SizedBox(height: 8), Text( 'Try adjusting your search or add a custom item', style: TextStyle(color: Colors.grey[500]), ), const SizedBox(height: 16), ElevatedButton.icon( onPressed: widget.onAddCustom, icon: const Icon(Icons.add), label: const Text('Add Custom'), ), ], ), ), ) else Expanded( child: ListView.builder( itemCount: _filteredItems.length, itemBuilder: (context, index) { return _buildItemTile(_filteredItems[index]); }, ), ), ], ), ); } }