CoffeeAtHome/lib/components/searchable_selection.dart
2026-03-29 08:13:38 -07:00

371 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import '../models/bean.dart';
import '../models/machine.dart';
import '../models/recipe.dart';
class SearchableSelection<T> extends StatefulWidget {
final List<T> 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<SearchableSelection<T>> createState() => _SearchableSelectionState<T>();
}
class _SearchableSelectionState<T> extends State<SearchableSelection<T>> {
final TextEditingController _searchController = TextEditingController();
List<T> _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]);
},
),
),
],
),
);
}
}