diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 21c296c..a6aec7b 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -18,6 +18,7 @@ import 'services/onboarding_store.dart'; import 'services/profile_cache.dart'; import 'services/profile_store.dart'; import 'services/saved_offers_store.dart'; +import 'services/saved_searches_store.dart'; import 'services/social_account_store.dart'; import 'services/social_connection.dart'; import 'services/social_service.dart'; @@ -36,6 +37,7 @@ import 'ui/inventory_list_screen.dart'; import 'ui/legal_screen.dart'; import 'ui/plantares_screen.dart'; import 'ui/sales_screen.dart'; +import 'ui/saved_searches_screen.dart'; import 'ui/market_offer_detail_screen.dart'; import 'ui/market_screen.dart'; import 'ui/offline_banner.dart'; @@ -62,6 +64,7 @@ class TaneApp extends StatelessWidget { this.profileStore, this.profileCache, this.savedOffers, + this.savedSearches, this.socialAccounts, this.inbox, this.notifications, @@ -81,6 +84,7 @@ class TaneApp extends StatelessWidget { profileStore, profileCache, savedOffers, + savedSearches, socialAccounts, inbox, ) { @@ -88,6 +92,8 @@ class TaneApp extends StatelessWidget { // the router only exists now; taps only happen while the app is foreground, // so a live router reference is enough. notifications?.onTapChat = (pubkey) => _router.push('/chat/$pubkey'); + // A tapped saved-search alert opens the saved-searches list. + notifications?.onTapSearch = (_) => _router.push('/saved-searches'); } final VarietyRepository repository; @@ -120,6 +126,9 @@ class TaneApp extends StatelessWidget { /// Optional store of the user's saved ("favorite") market offers. final SavedOffersStore? savedOffers; + /// Optional store of the user's saved market searches (with alerts). + final SavedSearchesStore? savedSearches; + /// Optional store of the active social identity, for the profile switcher. final SocialAccountStore? socialAccounts; @@ -149,6 +158,7 @@ class TaneApp extends StatelessWidget { ProfileStore? profileStore, ProfileCache? profileCache, SavedOffersStore? savedOffers, + SavedSearchesStore? savedSearches, SocialAccountStore? socialAccounts, InboxService? inbox, ) { @@ -170,6 +180,7 @@ class TaneApp extends StatelessWidget { location: location, outbox: outbox, onboarding: onboarding, + savedSearches: savedSearches, ), ), if (social != null && connection != null) @@ -196,6 +207,12 @@ class TaneApp extends StatelessWidget { connection: connection, ), ), + if (social != null && savedSearches != null) + GoRoute( + path: '/saved-searches', + builder: (context, state) => + SavedSearchesScreen(store: savedSearches), + ), if (messageStore != null) GoRoute( path: '/messages', diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 6423bb0..57ab427 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -20,6 +20,8 @@ import 'services/plantare_service.dart'; import 'services/profile_cache.dart'; import 'services/profile_store.dart'; import 'services/saved_offers_store.dart'; +import 'services/saved_search_alert_service.dart'; +import 'services/saved_searches_store.dart'; import 'services/social_account_store.dart'; import 'services/social_connection.dart'; import 'services/social_service.dart'; @@ -70,12 +72,16 @@ class _BootstrapState extends State { getIt.isRegistered() ? getIt() : null; final plantares = getIt.isRegistered() ? getIt() : null; - // Subscribe the inbox + sync + plantaré listeners BEFORE the shared - // connection starts connecting, so the first session is caught; then bring - // the connection up. + final savedSearchAlerts = getIt.isRegistered() + ? getIt() + : null; + // Subscribe the inbox + sync + plantaré + saved-search listeners BEFORE the + // shared connection starts connecting, so the first session is caught; then + // bring the connection up. inbox?.start(); sync?.start(); plantares?.start(); + savedSearchAlerts?.start(); connection?.start(); return TaneApp( @@ -92,6 +98,7 @@ class _BootstrapState extends State { profileStore: getIt(), profileCache: getIt(), savedOffers: getIt(), + savedSearches: getIt(), socialAccounts: getIt(), inbox: inbox, notifications: notifications, diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index b88976f..528baf5 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -41,6 +41,8 @@ import '../services/plantare_service.dart'; import '../services/profile_cache.dart'; import '../services/profile_store.dart'; import '../services/saved_offers_store.dart'; +import '../services/saved_search_alert_service.dart'; +import '../services/saved_searches_store.dart'; import '../services/social_account_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; @@ -189,6 +191,9 @@ Future configureDependencies() async { ..registerSingleton( SavedOffersStore(secretStore, accountScope: scope), ) + ..registerSingleton( + SavedSearchesStore(secretStore, accountScope: scope), + ) ..registerSingleton( ExportImportService( repository: varietyRepository, @@ -273,6 +278,18 @@ Future configureDependencies() async { selfSecretKey: socialService.identity.privateKeyHex, connection: connection, ), + ) + // Watches the market for offers matching the user's saved searches and + // fires an OS alert on a fresh match. Started in `Bootstrap`. + ..registerSingleton( + SavedSearchAlertService( + connection: connection, + selfPubkey: socialService.publicKeyHex, + store: getIt(), + settings: getIt(), + notifications: getIt(), + alertTitle: (s) => t.savedSearches.alert(label: s.label), + ), ); } @@ -309,6 +326,10 @@ Future switchSocialAccount(int account) async { await getIt().stop(); await getIt.unregister(); } + if (getIt.isRegistered()) { + await getIt().stop(); + await getIt.unregister(); + } if (getIt.isRegistered()) { await getIt().dispose(); await getIt.unregister(); @@ -324,6 +345,8 @@ Future switchSocialAccount(int account) async { await getIt.unregister(); await getIt().close(); await getIt.unregister(); + await getIt().close(); + await getIt.unregister(); // Re-register the per-identity stores under the new scope, then the identity. getIt @@ -339,6 +362,9 @@ Future switchSocialAccount(int account) async { ..registerSingleton( SavedOffersStore(secretStore, accountScope: scope), ) + ..registerSingleton( + SavedSearchesStore(secretStore, accountScope: scope), + ) ..registerSingleton( UnreadService(getIt(), secretStore), ); @@ -373,16 +399,26 @@ Future switchSocialAccount(int account) async { selfSecretKey: social.identity.privateKeyHex, connection: connection, ); + final savedSearchAlerts = SavedSearchAlertService( + connection: connection, + selfPubkey: social.publicKeyHex, + store: getIt(), + settings: getIt(), + notifications: getIt(), + alertTitle: (s) => t.savedSearches.alert(label: s.label), + ); getIt ..registerSingleton(social) ..registerSingleton(connection) ..registerSingleton(inbox) ..registerSingleton(sync) - ..registerSingleton(plantares); + ..registerSingleton(plantares) + ..registerSingleton(savedSearchAlerts); // Subscribe the listeners BEFORE the connection starts connecting. inbox.start(); sync.start(); plantares.start(); + savedSearchAlerts.start(); connection.start(); } diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 97d0ccc..7b047f8 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -12,6 +12,19 @@ "remove": "Remove from favorites", "unavailable": "No longer available" }, + "savedSearches": { + "title": "Saved searches", + "empty": "No saved searches yet. Save a search from the market and we'll tell you when something like it appears in your zone.", + "save": "Save this search", + "nameLabel": "Name this search", + "namePlaceholder": "e.g. Tomatoes near me", + "saved": "Search saved — we'll alert you about new matches nearby", + "openTooltip": "Saved searches", + "delete": "Delete", + "deleteConfirm": "Delete this saved search?", + "newMatchesBadge": "{n} new", + "alert": "New seeds near you: {label}" + }, "seedSaving": { "title": "Saving its seed", "subtitle": "What it takes to keep the variety true", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index de9f4ff..c491536 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -12,6 +12,19 @@ "remove": "Quitar de favoritos", "unavailable": "Ya no está disponible" }, + "savedSearches": { + "title": "Búsquedas guardadas", + "empty": "Aún no tienes búsquedas guardadas. Guarda una búsqueda desde el mercado y te avisaremos cuando aparezca algo así en tu zona.", + "save": "Guardar esta búsqueda", + "nameLabel": "Nombra esta búsqueda", + "namePlaceholder": "p. ej. Tomates cerca", + "saved": "Búsqueda guardada — te avisaremos de lo nuevo que encaje cerca", + "openTooltip": "Búsquedas guardadas", + "delete": "Eliminar", + "deleteConfirm": "¿Eliminar esta búsqueda guardada?", + "newMatchesBadge": "{n} nuevas", + "alert": "Semillas nuevas cerca: {label}" + }, "seedSaving": { "title": "Conservar su semilla", "subtitle": "Lo que hace falta para mantener la variedad fiel", diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index 02ac491..d5fecc2 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -267,6 +267,16 @@ class OffersCubit extends Cubit { void setHiddenOffers(Set offerKeys) => emit(state.copyWith(hiddenOfferKeys: offerKeys)); + /// Applies a saved search's query and facets to the current discoveries. + /// Purely local (does not re-hit the transport) — the area is unchanged, so + /// the same discovered offers are simply re-narrowed by the saved filters. + void applySavedSearch(SavedSearch search) => emit(state.copyWith( + query: search.query, + typeFilter: search.types, + categoryFilter: search.categories, + organicOnly: search.organicOnly, + )); + /// Clears every chip filter (leaves the text search untouched). void clearFilters() => emit(state.copyWith( typeFilter: const {}, diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index ffe310a..b4a7eeb 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -8,6 +8,7 @@ import '../i18n/strings.g.dart'; import '../services/coarse_location.dart'; import '../services/discovery_area.dart'; import '../services/offer_outbox.dart'; +import '../services/saved_searches_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -18,6 +19,7 @@ import 'edge_fade.dart'; import 'filter_chips.dart'; import 'market_gate.dart'; import 'market_widgets.dart'; +import 'saved_searches_screen.dart'; import 'theme.dart'; /// The market (redesign screens 04/05): discover seeds shared near you. Opens @@ -31,12 +33,22 @@ class MarketScreen extends StatefulWidget { this.location, this.outbox, this.onboarding, + this.savedSearches, + this.initialSearch, super.key, }); final SocialService social; final SocialSettings settings; + /// Optional store of the user's saved searches (with alerts). Null in tests / + /// inventory-only → the save affordance and the saved-searches action hide. + final SavedSearchesStore? savedSearches; + + /// When set (arriving from the saved-searches list), the market opens with + /// this search's query and facets already applied. + final SavedSearch? initialSearch; + /// When set, joining the market is gated on a one-time acceptance of the /// community rules (store UGC policies require it before content is /// created). Null in tests → no gate. @@ -100,6 +112,9 @@ class _MarketScreenState extends State { await cubit.close(); return; } + // Arriving from the saved-searches list: pre-apply its query and facets. + final initial = widget.initialSearch; + if (initial != null) cubit.applySavedSearch(initial); final previous = _cubit; setState(() { _cubit = cubit; @@ -213,6 +228,8 @@ class _MarketScreenState extends State { appBar: AppBar( title: Text(t.market.title), actions: [ + if (widget.savedSearches != null) + SavedSearchesAction(store: widget.savedSearches!), IconButton( key: const Key('market.config'), icon: const Icon(Icons.tune), @@ -240,6 +257,7 @@ class _MarketScreenState extends State { hasArea: _hasArea, selfPubkey: widget.social.publicKeyHex, onOfferClosed: _reloadBlocked, + savedSearches: widget.savedSearches, ), ), ); @@ -253,11 +271,15 @@ class MarketBody extends StatelessWidget { this.hasArea = true, this.selfPubkey, this.onOfferClosed, + this.savedSearches, super.key, }); final VoidCallback onConfigure; + /// Store for the "save this search" affordance; null → the button hides. + final SavedSearchesStore? savedSearches; + /// Re-opens the connection (for the "can't reach servers" retry). final VoidCallback? onRetry; @@ -346,6 +368,20 @@ class MarketBody extends StatelessWidget { decoration: InputDecoration( hintText: t.market.searchHint, prefixIcon: const Icon(Icons.search, color: seedMuted), + // Offer to save the current search once it's worth alerting on + // (some text typed or a chip active), never for the bare list. + suffixIcon: savedSearches != null && + (state.query.trim().isNotEmpty || + state.hasActiveFilter) + ? IconButton( + key: const Key('market.saveSearch'), + icon: const Icon(Icons.bookmark_add_outlined, + color: seedGreen), + tooltip: t.savedSearches.save, + onPressed: () => + _saveCurrentSearch(context, savedSearches!, state), + ) + : null, hintStyle: const TextStyle(color: seedMuted), filled: true, fillColor: Colors.white, @@ -405,6 +441,63 @@ class MarketBody extends StatelessWidget { } } +/// Prompts for a name, then saves the current query + facets as a search the +/// user will be alerted about. Offers already visible are seeded as "seen" so +/// saving never immediately alerts about listings the user is looking at. +Future _saveCurrentSearch( + BuildContext context, + SavedSearchesStore store, + OffersState state, +) async { + final t = context.t; + final messenger = ScaffoldMessenger.of(context); + final defaultLabel = state.query.trim(); + final controller = TextEditingController(text: defaultLabel); + final label = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(t.savedSearches.save), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: t.savedSearches.nameLabel, + hintText: t.savedSearches.namePlaceholder, + ), + onSubmitted: (v) => Navigator.of(context).pop(v), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(t.common.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text), + child: Text(t.common.save), + ), + ], + ), + ); + if (label == null) return; // cancelled + final trimmed = label.trim(); + final search = SavedSearch( + id: IdGen().newId(), + label: trimmed.isEmpty ? t.savedSearches.title : trimmed, + query: state.query.trim(), + types: state.typeFilter, + categories: state.categoryFilter, + organicOnly: state.organicOnly, + createdAt: DateTime.now().millisecondsSinceEpoch, + ); + await store.save( + search, + seedSeenKeys: [ + for (final o in state.visibleOffers) SavedSearchesStore.keyOf(o), + ], + ); + messenger.showSnackBar(SnackBar(content: Text(t.savedSearches.saved))); +} + /// One discovered offer. Human words for the reciprocity mode; coarse "near you" /// instead of any precise location; price only when it's a sale. class _OfferCard extends StatelessWidget { diff --git a/apps/app_seeds/lib/ui/saved_searches_screen.dart b/apps/app_seeds/lib/ui/saved_searches_screen.dart new file mode 100644 index 0000000..d739c22 --- /dev/null +++ b/apps/app_seeds/lib/ui/saved_searches_screen.dart @@ -0,0 +1,282 @@ +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, + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/test/ui/saved_searches_screen_test.dart b/apps/app_seeds/test/ui/saved_searches_screen_test.dart new file mode 100644 index 0000000..f4aa959 --- /dev/null +++ b/apps/app_seeds/test/ui/saved_searches_screen_test.dart @@ -0,0 +1,74 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/saved_searches_store.dart'; +import 'package:tane/ui/saved_searches_screen.dart'; + +import '../support/test_support.dart'; + +Widget _wrap(Widget child) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: child, + ), + ); +} + +SavedSearch _search(String id, {String label = 'Tomatoes'}) => + SavedSearch(id: id, label: label, query: 'tomato', createdAt: 1); + +void main() { + testWidgets('shows the empty state when nothing is saved', (tester) async { + final store = SavedSearchesStore(InMemorySecretStore()); + addTearDown(store.close); + + await tester.pumpWidget(_wrap(SavedSearchesScreen(store: store))); + await tester.pumpAndSettle(); + + expect( + find.textContaining('No saved searches yet'), + findsOneWidget, + ); + }); + + testWidgets('lists a saved search and its new-match badge', (tester) async { + final store = SavedSearchesStore(InMemorySecretStore()); + addTearDown(store.close); + await store.save(_search('a')); + await store.markMatched('a', 'author:o1'); + + await tester.pumpWidget(_wrap(SavedSearchesScreen(store: store))); + await tester.pumpAndSettle(); + + expect(find.text('Tomatoes'), findsOneWidget); + expect(find.text('1 new'), findsOneWidget); + }); + + testWidgets('delete confirms then removes the search', (tester) async { + final store = SavedSearchesStore(InMemorySecretStore()); + addTearDown(store.close); + await store.save(_search('a')); + + await tester.pumpWidget(_wrap(SavedSearchesScreen(store: store))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('savedSearches.delete.a'))); + await tester.pumpAndSettle(); + // Confirm in the dialog. + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(find.text('Tomatoes'), findsNothing); + expect(await store.exists('a'), isFalse); + }); +}