371 lines
11 KiB
Dart
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]);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|