import 'dart:async'; import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import '../services/saved_searches_store.dart'; import 'market_widgets.dart'; import 'theme.dart'; /// The Wallapop-style "Saved searches": queries the user asked to be alerted /// about. Tapping one runs it in the market (applying its query and facets); /// each shows a badge when new matching offers have arrived while the app was /// open. Delete removes the search and its alerts. class SavedSearchesScreen extends StatefulWidget { const SavedSearchesScreen({required this.store, super.key}); final SavedSearchesStore store; @override State createState() => _SavedSearchesScreenState(); } class _SavedSearchesScreenState extends State { List? _searches; Map _newCounts = const {}; StreamSubscription? _changesSub; @override void initState() { super.initState(); _changesSub = widget.store.changes.listen((_) => _load()); _load(); } @override void dispose() { _changesSub?.cancel(); super.dispose(); } Future _load() async { final searches = await widget.store.list(); final counts = { for (final s in searches) s.id: await widget.store.newMatchCount(s.id), }; if (!mounted) return; setState(() { _searches = searches; _newCounts = counts; }); } /// Runs [search] in the market: mark it seen (clears its badge), then open the /// market with the search applied. Future _open(SavedSearch search) async { await widget.store.markViewed(search.id); if (!mounted) return; context.go('/market', extra: search); } Future _confirmDelete(SavedSearch search) async { final t = context.t; final ok = await showDialog( context: context, builder: (context) => AlertDialog( content: Text(t.savedSearches.deleteConfirm), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text(t.common.cancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: Text(t.savedSearches.delete), ), ], ), ); if (ok == true) await widget.store.remove(search.id); } @override Widget build(BuildContext context) { final t = context.t; final searches = _searches; return Scaffold( appBar: AppBar(title: Text(t.savedSearches.title)), body: searches == null ? const Center(child: CircularProgressIndicator()) : searches.isEmpty ? _Empty(text: t.savedSearches.empty) : ListView.separated( padding: const EdgeInsets.all(12), itemCount: searches.length, separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (context, i) { final search = searches[i]; return _SavedSearchCard( search: search, newCount: _newCounts[search.id] ?? 0, onOpen: () => _open(search), onDelete: () => _confirmDelete(search), ); }, ), ); } } /// An AppBar action that opens the saved-searches list, badged with the total /// number of unseen matches. Stream-driven off the store's [changes], mirroring /// how [UnreadBadge] tracks unread messages. class SavedSearchesAction extends StatefulWidget { const SavedSearchesAction({required this.store, super.key}); final SavedSearchesStore store; @override State createState() => _SavedSearchesActionState(); } class _SavedSearchesActionState extends State { int _count = 0; StreamSubscription? _changesSub; @override void initState() { super.initState(); _changesSub = widget.store.changes.listen((_) => _refresh()); _refresh(); } @override void dispose() { _changesSub?.cancel(); super.dispose(); } Future _refresh() async { final count = await widget.store.totalNewMatchCount(); if (!mounted) return; setState(() => _count = count); } @override Widget build(BuildContext context) { final t = context.t; final icon = const Icon(Icons.bookmarks_outlined); return IconButton( key: const Key('market.savedSearches'), icon: _count > 0 ? Badge( label: Text('$_count'), backgroundColor: seedGreen, child: icon, ) : icon, tooltip: t.savedSearches.openTooltip, onPressed: () => context.push('/saved-searches'), ); } } class _SavedSearchCard extends StatelessWidget { const _SavedSearchCard({ required this.search, required this.newCount, required this.onOpen, required this.onDelete, }); final SavedSearch search; final int newCount; final VoidCallback onOpen; final VoidCallback onDelete; @override Widget build(BuildContext context) { final t = context.t; final radius = BorderRadius.circular(14); final facets = _facetSummary(t, search); return Material( color: Colors.white, borderRadius: radius, child: InkWell( key: Key('savedSearches.item.${search.id}'), onTap: onOpen, borderRadius: radius, child: Container( decoration: BoxDecoration( borderRadius: radius, border: Border.all(color: seedOutline), ), padding: const EdgeInsets.all(16), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( search.label, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w500, color: seedOnSurface, ), ), if (facets.isNotEmpty) ...[ const SizedBox(height: 4), Text( facets, style: const TextStyle(color: seedMuted, fontSize: 13), ), ], ], ), ), if (newCount > 0) ...[ Badge( label: Text(t.savedSearches.newMatchesBadge(n: newCount)), backgroundColor: seedGreen, ), const SizedBox(width: 4), ], IconButton( key: Key('savedSearches.delete.${search.id}'), icon: const Icon(Icons.delete_outline, color: seedMuted), tooltip: t.savedSearches.delete, onPressed: onDelete, ), ], ), ), ), ); } /// A short, human line describing the search's facets (never the raw query, /// which is already the label by default). static String _facetSummary(Translations t, SavedSearch s) { final parts = [ for (final type in s.types) offerTypeLabel(t, type), ...s.categories, if (s.organicOnly) t.editVariety.organic, ]..removeWhere((e) => e.isEmpty); return parts.join(' ยท '); } } class _Empty extends StatelessWidget { const _Empty({required this.text}); final String text; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.bookmark_border, size: 64, color: seedGreen.withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( text, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), ], ), ), ); } }