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

456 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_state.dart';
class SearchResult {
final String type;
final String id;
final String title;
final String subtitle;
final IconData icon;
final dynamic data;
SearchResult({
required this.type,
required this.id,
required this.title,
required this.subtitle,
required this.icon,
required this.data,
});
}
class GlobalSearchWidget extends StatefulWidget {
final bool isOpen;
final VoidCallback onClose;
final Function(SearchResult) onResultSelected;
const GlobalSearchWidget({
super.key,
required this.isOpen,
required this.onClose,
required this.onResultSelected,
});
@override
State<GlobalSearchWidget> createState() => _GlobalSearchWidgetState();
}
class _GlobalSearchWidgetState extends State<GlobalSearchWidget> {
final TextEditingController _searchController = TextEditingController();
List<SearchResult> _searchResults = [];
bool _isSearching = false;
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _performSearch(String query) {
if (query.isEmpty) {
setState(() {
_searchResults = [];
_isSearching = false;
});
return;
}
setState(() {
_isSearching = true;
});
final appState = Provider.of<AppState>(context, listen: false);
final results = <SearchResult>[];
// Search beans
for (final bean in appState.beans) {
if (_matchesQuery(bean.name, query) ||
_matchesQuery(bean.varietal, query) ||
_matchesQuery(bean.originCountry?.id ?? '', query) ||
_matchesQuery(bean.roastLevel.name, query)) {
results.add(SearchResult(
type: 'Bean',
id: bean.id,
title: bean.name,
subtitle: '${bean.varietal}${bean.originCountry?.id ?? 'Unknown'}',
icon: Icons.coffee,
data: bean,
));
}
}
// Search machines
for (final machine in appState.machines) {
if (_matchesQuery(machine.model, query) ||
_matchesQuery(machine.type.name, query) ||
_matchesQuery(machine.manufacturer, query)) {
results.add(SearchResult(
type: 'Machine',
id: machine.id,
title: machine.model,
subtitle: '${machine.manufacturer}${machine.type.name}',
icon: Icons.kitchen,
data: machine,
));
}
}
// Search recipes
for (final recipe in appState.recipes) {
if (_matchesQuery(recipe.name, query) ||
_matchesQuery(recipe.brewMethod.name, query) ||
_matchesQuery(recipe.instructions, query) ||
_matchesQuery(recipe.notes ?? '', query)) {
results.add(SearchResult(
type: 'Recipe',
id: recipe.id,
title: recipe.name,
subtitle: '${recipe.brewMethod.name}${recipe.grindSize}',
icon: Icons.menu_book,
data: recipe,
));
}
}
// Search journal entries
for (final entry in appState.journalEntries) {
if (_matchesQuery(entry.drink.name, query) ||
_matchesQuery(entry.notes ?? '', query) ||
_matchesQuery(entry.mood ?? '', query) ||
_matchesQuery(entry.weather ?? '', query)) {
results.add(SearchResult(
type: 'Journal',
id: entry.id,
title: entry.drink.name,
subtitle: 'Rating: ${entry.drink.rating}/5 • ${_formatDate(entry.date)}',
icon: Icons.book,
data: entry,
));
}
}
// Sort results by relevance (exact matches first)
results.sort((a, b) {
final aExact = a.title.toLowerCase() == query.toLowerCase();
final bExact = b.title.toLowerCase() == query.toLowerCase();
if (aExact && !bExact) return -1;
if (!aExact && bExact) return 1;
return a.title.compareTo(b.title);
});
setState(() {
_searchResults = results;
_isSearching = false;
});
}
bool _matchesQuery(String text, String query) {
return text.toLowerCase().contains(query.toLowerCase());
}
String _formatDate(DateTime date) {
return '${date.day}/${date.month}/${date.year}';
}
@override
Widget build(BuildContext context) {
if (!widget.isOpen) return const SizedBox.shrink();
return Container(
color: Colors.black.withAlpha((0.6 * 255).toInt()),
child: Center(
child: Container(
width: MediaQuery.of(context).size.width > 600
? 600
: MediaQuery.of(context).size.width - 32,
height: MediaQuery.of(context).size.height > 600
? 600
: MediaQuery.of(context).size.height - 100,
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF2D2D2D),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF3A3A3A)),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha((0.5 * 255).toInt()),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFF3A3A3A)),
),
),
child: Row(
children: [
const Icon(
Icons.search,
color: Color(0xFFD4A574),
size: 24,
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Global Search',
style: TextStyle(
color: Color(0xFFF5F5DC),
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: const Icon(
Icons.close,
color: Color(0xFFF5F5DC),
),
onPressed: widget.onClose,
),
],
),
),
// Search input
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(color: Color(0xFFF5F5DC)),
onChanged: _performSearch,
decoration: InputDecoration(
hintText: 'Search beans, machines, recipes, journal entries...',
hintStyle: const TextStyle(color: Color(0xFFD2B48C)),
prefixIcon: const Icon(
Icons.search,
color: Color(0xFFD4A574),
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(
Icons.clear,
color: Color(0xFFD2B48C),
),
onPressed: () {
_searchController.clear();
_performSearch('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF3A3A3A)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFD4A574)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF3A3A3A)),
),
filled: true,
fillColor: const Color(0xFF1A1A1A),
),
),
),
// Results
Expanded(
child: _buildSearchResults(),
),
],
),
),
),
);
}
Widget _buildSearchResults() {
if (_isSearching) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFFD4A574),
),
);
}
if (_searchController.text.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: Color(0xFF3A3A3A),
),
SizedBox(height: 16),
Text(
'Start typing to search...',
style: TextStyle(
color: Color(0xFFD2B48C),
fontSize: 16,
),
),
SizedBox(height: 8),
Text(
'Search across beans, machines, recipes, and journal entries',
style: TextStyle(
color: Color(0xFF3A3A3A),
fontSize: 14,
),
textAlign: TextAlign.center,
),
],
),
);
}
if (_searchResults.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.search_off,
size: 64,
color: Color(0xFF3A3A3A),
),
const SizedBox(height: 16),
Text(
'No results found for "${_searchController.text}"',
style: const TextStyle(
color: Color(0xFFD2B48C),
fontSize: 16,
),
),
const SizedBox(height: 8),
const Text(
'Try different keywords or check your spelling',
style: TextStyle(
color: Color(0xFF3A3A3A),
fontSize: 14,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _searchResults.length,
itemBuilder: (context, index) {
final result = _searchResults[index];
return _buildSearchResultItem(result);
},
);
}
Widget _buildSearchResultItem(SearchResult result) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
widget.onResultSelected(result);
widget.onClose();
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF3A3A3A)),
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFD4A574).withAlpha((0.2 * 255).toInt()),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
result.icon,
color: const Color(0xFFD4A574),
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFF6F4E37),
borderRadius: BorderRadius.circular(4),
),
child: Text(
result.type,
style: const TextStyle(
color: Color(0xFFF5F5DC),
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
result.title,
style: const TextStyle(
color: Color(0xFFF5F5DC),
fontSize: 16,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
Text(
result.subtitle,
style: const TextStyle(
color: Color(0xFFD2B48C),
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(
Icons.arrow_forward_ios,
color: Color(0xFF3A3A3A),
size: 16,
),
],
),
),
),
),
);
}
}