feat(market): saved searches with in-app alerts (Wallapop-style)

Save the current market search (query + facets) and get notified when a
matching offer appears in your zone while the app is open, with a catch-up
scan on start. Adds a save affordance in the search bar, a saved-searches
list (apply/delete) with a per-search new-match badge, an AppBar entry
badged with the unseen total, and notification-tap routing to the list.
Alerts evaluate against the user's current area, dedup via seen keys, and
skip own/blocked/hidden/non-active offers. en+es strings.
This commit is contained in:
vjrj 2026-07-17 13:10:52 +02:00
parent c0cd299408
commit 93028c4933
9 changed files with 549 additions and 4 deletions

View file

@ -18,6 +18,7 @@ import 'services/onboarding_store.dart';
import 'services/profile_cache.dart'; import 'services/profile_cache.dart';
import 'services/profile_store.dart'; import 'services/profile_store.dart';
import 'services/saved_offers_store.dart'; import 'services/saved_offers_store.dart';
import 'services/saved_searches_store.dart';
import 'services/social_account_store.dart'; import 'services/social_account_store.dart';
import 'services/social_connection.dart'; import 'services/social_connection.dart';
import 'services/social_service.dart'; import 'services/social_service.dart';
@ -36,6 +37,7 @@ import 'ui/inventory_list_screen.dart';
import 'ui/legal_screen.dart'; import 'ui/legal_screen.dart';
import 'ui/plantares_screen.dart'; import 'ui/plantares_screen.dart';
import 'ui/sales_screen.dart'; import 'ui/sales_screen.dart';
import 'ui/saved_searches_screen.dart';
import 'ui/market_offer_detail_screen.dart'; import 'ui/market_offer_detail_screen.dart';
import 'ui/market_screen.dart'; import 'ui/market_screen.dart';
import 'ui/offline_banner.dart'; import 'ui/offline_banner.dart';
@ -62,6 +64,7 @@ class TaneApp extends StatelessWidget {
this.profileStore, this.profileStore,
this.profileCache, this.profileCache,
this.savedOffers, this.savedOffers,
this.savedSearches,
this.socialAccounts, this.socialAccounts,
this.inbox, this.inbox,
this.notifications, this.notifications,
@ -81,6 +84,7 @@ class TaneApp extends StatelessWidget {
profileStore, profileStore,
profileCache, profileCache,
savedOffers, savedOffers,
savedSearches,
socialAccounts, socialAccounts,
inbox, inbox,
) { ) {
@ -88,6 +92,8 @@ class TaneApp extends StatelessWidget {
// the router only exists now; taps only happen while the app is foreground, // the router only exists now; taps only happen while the app is foreground,
// so a live router reference is enough. // so a live router reference is enough.
notifications?.onTapChat = (pubkey) => _router.push('/chat/$pubkey'); 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; final VarietyRepository repository;
@ -120,6 +126,9 @@ class TaneApp extends StatelessWidget {
/// Optional store of the user's saved ("favorite") market offers. /// Optional store of the user's saved ("favorite") market offers.
final SavedOffersStore? savedOffers; 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. /// Optional store of the active social identity, for the profile switcher.
final SocialAccountStore? socialAccounts; final SocialAccountStore? socialAccounts;
@ -149,6 +158,7 @@ class TaneApp extends StatelessWidget {
ProfileStore? profileStore, ProfileStore? profileStore,
ProfileCache? profileCache, ProfileCache? profileCache,
SavedOffersStore? savedOffers, SavedOffersStore? savedOffers,
SavedSearchesStore? savedSearches,
SocialAccountStore? socialAccounts, SocialAccountStore? socialAccounts,
InboxService? inbox, InboxService? inbox,
) { ) {
@ -170,6 +180,7 @@ class TaneApp extends StatelessWidget {
location: location, location: location,
outbox: outbox, outbox: outbox,
onboarding: onboarding, onboarding: onboarding,
savedSearches: savedSearches,
), ),
), ),
if (social != null && connection != null) if (social != null && connection != null)
@ -196,6 +207,12 @@ class TaneApp extends StatelessWidget {
connection: connection, connection: connection,
), ),
), ),
if (social != null && savedSearches != null)
GoRoute(
path: '/saved-searches',
builder: (context, state) =>
SavedSearchesScreen(store: savedSearches),
),
if (messageStore != null) if (messageStore != null)
GoRoute( GoRoute(
path: '/messages', path: '/messages',

View file

@ -20,6 +20,8 @@ import 'services/plantare_service.dart';
import 'services/profile_cache.dart'; import 'services/profile_cache.dart';
import 'services/profile_store.dart'; import 'services/profile_store.dart';
import 'services/saved_offers_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_account_store.dart';
import 'services/social_connection.dart'; import 'services/social_connection.dart';
import 'services/social_service.dart'; import 'services/social_service.dart';
@ -70,12 +72,16 @@ class _BootstrapState extends State<Bootstrap> {
getIt.isRegistered<SyncService>() ? getIt<SyncService>() : null; getIt.isRegistered<SyncService>() ? getIt<SyncService>() : null;
final plantares = final plantares =
getIt.isRegistered<PlantareService>() ? getIt<PlantareService>() : null; getIt.isRegistered<PlantareService>() ? getIt<PlantareService>() : null;
// Subscribe the inbox + sync + plantaré listeners BEFORE the shared final savedSearchAlerts = getIt.isRegistered<SavedSearchAlertService>()
// connection starts connecting, so the first session is caught; then bring ? getIt<SavedSearchAlertService>()
// the connection up. : 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(); inbox?.start();
sync?.start(); sync?.start();
plantares?.start(); plantares?.start();
savedSearchAlerts?.start();
connection?.start(); connection?.start();
return TaneApp( return TaneApp(
@ -92,6 +98,7 @@ class _BootstrapState extends State<Bootstrap> {
profileStore: getIt<ProfileStore>(), profileStore: getIt<ProfileStore>(),
profileCache: getIt<ProfileCache>(), profileCache: getIt<ProfileCache>(),
savedOffers: getIt<SavedOffersStore>(), savedOffers: getIt<SavedOffersStore>(),
savedSearches: getIt<SavedSearchesStore>(),
socialAccounts: getIt<SocialAccountStore>(), socialAccounts: getIt<SocialAccountStore>(),
inbox: inbox, inbox: inbox,
notifications: notifications, notifications: notifications,

View file

@ -41,6 +41,8 @@ import '../services/plantare_service.dart';
import '../services/profile_cache.dart'; import '../services/profile_cache.dart';
import '../services/profile_store.dart'; import '../services/profile_store.dart';
import '../services/saved_offers_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_account_store.dart';
import '../services/social_connection.dart'; import '../services/social_connection.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
@ -189,6 +191,9 @@ Future<void> configureDependencies() async {
..registerSingleton<SavedOffersStore>( ..registerSingleton<SavedOffersStore>(
SavedOffersStore(secretStore, accountScope: scope), SavedOffersStore(secretStore, accountScope: scope),
) )
..registerSingleton<SavedSearchesStore>(
SavedSearchesStore(secretStore, accountScope: scope),
)
..registerSingleton<ExportImportService>( ..registerSingleton<ExportImportService>(
ExportImportService( ExportImportService(
repository: varietyRepository, repository: varietyRepository,
@ -273,6 +278,18 @@ Future<void> configureDependencies() async {
selfSecretKey: socialService.identity.privateKeyHex, selfSecretKey: socialService.identity.privateKeyHex,
connection: connection, 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>(
SavedSearchAlertService(
connection: connection,
selfPubkey: socialService.publicKeyHex,
store: getIt<SavedSearchesStore>(),
settings: getIt<SocialSettings>(),
notifications: getIt<NotificationService>(),
alertTitle: (s) => t.savedSearches.alert(label: s.label),
),
); );
} }
@ -309,6 +326,10 @@ Future<void> switchSocialAccount(int account) async {
await getIt<PlantareService>().stop(); await getIt<PlantareService>().stop();
await getIt.unregister<PlantareService>(); await getIt.unregister<PlantareService>();
} }
if (getIt.isRegistered<SavedSearchAlertService>()) {
await getIt<SavedSearchAlertService>().stop();
await getIt.unregister<SavedSearchAlertService>();
}
if (getIt.isRegistered<SocialConnection>()) { if (getIt.isRegistered<SocialConnection>()) {
await getIt<SocialConnection>().dispose(); await getIt<SocialConnection>().dispose();
await getIt.unregister<SocialConnection>(); await getIt.unregister<SocialConnection>();
@ -324,6 +345,8 @@ Future<void> switchSocialAccount(int account) async {
await getIt.unregister<ProfileCache>(); await getIt.unregister<ProfileCache>();
await getIt<SavedOffersStore>().close(); await getIt<SavedOffersStore>().close();
await getIt.unregister<SavedOffersStore>(); await getIt.unregister<SavedOffersStore>();
await getIt<SavedSearchesStore>().close();
await getIt.unregister<SavedSearchesStore>();
// Re-register the per-identity stores under the new scope, then the identity. // Re-register the per-identity stores under the new scope, then the identity.
getIt getIt
@ -339,6 +362,9 @@ Future<void> switchSocialAccount(int account) async {
..registerSingleton<SavedOffersStore>( ..registerSingleton<SavedOffersStore>(
SavedOffersStore(secretStore, accountScope: scope), SavedOffersStore(secretStore, accountScope: scope),
) )
..registerSingleton<SavedSearchesStore>(
SavedSearchesStore(secretStore, accountScope: scope),
)
..registerSingleton<UnreadService>( ..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore), UnreadService(getIt<MessageStore>(), secretStore),
); );
@ -373,16 +399,26 @@ Future<void> switchSocialAccount(int account) async {
selfSecretKey: social.identity.privateKeyHex, selfSecretKey: social.identity.privateKeyHex,
connection: connection, connection: connection,
); );
final savedSearchAlerts = SavedSearchAlertService(
connection: connection,
selfPubkey: social.publicKeyHex,
store: getIt<SavedSearchesStore>(),
settings: getIt<SocialSettings>(),
notifications: getIt<NotificationService>(),
alertTitle: (s) => t.savedSearches.alert(label: s.label),
);
getIt getIt
..registerSingleton<SocialService>(social) ..registerSingleton<SocialService>(social)
..registerSingleton<SocialConnection>(connection) ..registerSingleton<SocialConnection>(connection)
..registerSingleton<InboxService>(inbox) ..registerSingleton<InboxService>(inbox)
..registerSingleton<SyncService>(sync) ..registerSingleton<SyncService>(sync)
..registerSingleton<PlantareService>(plantares); ..registerSingleton<PlantareService>(plantares)
..registerSingleton<SavedSearchAlertService>(savedSearchAlerts);
// Subscribe the listeners BEFORE the connection starts connecting. // Subscribe the listeners BEFORE the connection starts connecting.
inbox.start(); inbox.start();
sync.start(); sync.start();
plantares.start(); plantares.start();
savedSearchAlerts.start();
connection.start(); connection.start();
} }

View file

@ -12,6 +12,19 @@
"remove": "Remove from favorites", "remove": "Remove from favorites",
"unavailable": "No longer available" "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": { "seedSaving": {
"title": "Saving its seed", "title": "Saving its seed",
"subtitle": "What it takes to keep the variety true", "subtitle": "What it takes to keep the variety true",

View file

@ -12,6 +12,19 @@
"remove": "Quitar de favoritos", "remove": "Quitar de favoritos",
"unavailable": "Ya no está disponible" "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": { "seedSaving": {
"title": "Conservar su semilla", "title": "Conservar su semilla",
"subtitle": "Lo que hace falta para mantener la variedad fiel", "subtitle": "Lo que hace falta para mantener la variedad fiel",

View file

@ -267,6 +267,16 @@ class OffersCubit extends Cubit<OffersState> {
void setHiddenOffers(Set<String> offerKeys) => void setHiddenOffers(Set<String> offerKeys) =>
emit(state.copyWith(hiddenOfferKeys: 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). /// Clears every chip filter (leaves the text search untouched).
void clearFilters() => emit(state.copyWith( void clearFilters() => emit(state.copyWith(
typeFilter: const {}, typeFilter: const {},

View file

@ -8,6 +8,7 @@ import '../i18n/strings.g.dart';
import '../services/coarse_location.dart'; import '../services/coarse_location.dart';
import '../services/discovery_area.dart'; import '../services/discovery_area.dart';
import '../services/offer_outbox.dart'; import '../services/offer_outbox.dart';
import '../services/saved_searches_store.dart';
import '../services/social_connection.dart'; import '../services/social_connection.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/social_settings.dart'; import '../services/social_settings.dart';
@ -18,6 +19,7 @@ import 'edge_fade.dart';
import 'filter_chips.dart'; import 'filter_chips.dart';
import 'market_gate.dart'; import 'market_gate.dart';
import 'market_widgets.dart'; import 'market_widgets.dart';
import 'saved_searches_screen.dart';
import 'theme.dart'; import 'theme.dart';
/// The market (redesign screens 04/05): discover seeds shared near you. Opens /// The market (redesign screens 04/05): discover seeds shared near you. Opens
@ -31,12 +33,22 @@ class MarketScreen extends StatefulWidget {
this.location, this.location,
this.outbox, this.outbox,
this.onboarding, this.onboarding,
this.savedSearches,
this.initialSearch,
super.key, super.key,
}); });
final SocialService social; final SocialService social;
final SocialSettings settings; 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 /// When set, joining the market is gated on a one-time acceptance of the
/// community rules (store UGC policies require it before content is /// community rules (store UGC policies require it before content is
/// created). Null in tests no gate. /// created). Null in tests no gate.
@ -100,6 +112,9 @@ class _MarketScreenState extends State<MarketScreen> {
await cubit.close(); await cubit.close();
return; 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; final previous = _cubit;
setState(() { setState(() {
_cubit = cubit; _cubit = cubit;
@ -213,6 +228,8 @@ class _MarketScreenState extends State<MarketScreen> {
appBar: AppBar( appBar: AppBar(
title: Text(t.market.title), title: Text(t.market.title),
actions: [ actions: [
if (widget.savedSearches != null)
SavedSearchesAction(store: widget.savedSearches!),
IconButton( IconButton(
key: const Key('market.config'), key: const Key('market.config'),
icon: const Icon(Icons.tune), icon: const Icon(Icons.tune),
@ -240,6 +257,7 @@ class _MarketScreenState extends State<MarketScreen> {
hasArea: _hasArea, hasArea: _hasArea,
selfPubkey: widget.social.publicKeyHex, selfPubkey: widget.social.publicKeyHex,
onOfferClosed: _reloadBlocked, onOfferClosed: _reloadBlocked,
savedSearches: widget.savedSearches,
), ),
), ),
); );
@ -253,11 +271,15 @@ class MarketBody extends StatelessWidget {
this.hasArea = true, this.hasArea = true,
this.selfPubkey, this.selfPubkey,
this.onOfferClosed, this.onOfferClosed,
this.savedSearches,
super.key, super.key,
}); });
final VoidCallback onConfigure; 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). /// Re-opens the connection (for the "can't reach servers" retry).
final VoidCallback? onRetry; final VoidCallback? onRetry;
@ -346,6 +368,20 @@ class MarketBody extends StatelessWidget {
decoration: InputDecoration( decoration: InputDecoration(
hintText: t.market.searchHint, hintText: t.market.searchHint,
prefixIcon: const Icon(Icons.search, color: seedMuted), 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), hintStyle: const TextStyle(color: seedMuted),
filled: true, filled: true,
fillColor: Colors.white, 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<void> _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<String>(
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" /// One discovered offer. Human words for the reciprocity mode; coarse "near you"
/// instead of any precise location; price only when it's a sale. /// instead of any precise location; price only when it's a sale.
class _OfferCard extends StatelessWidget { class _OfferCard extends StatelessWidget {

View file

@ -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<SavedSearchesScreen> createState() => _SavedSearchesScreenState();
}
class _SavedSearchesScreenState extends State<SavedSearchesScreen> {
List<SavedSearch>? _searches;
Map<String, int> _newCounts = const {};
StreamSubscription<void>? _changesSub;
@override
void initState() {
super.initState();
_changesSub = widget.store.changes.listen((_) => _load());
_load();
}
@override
void dispose() {
_changesSub?.cancel();
super.dispose();
}
Future<void> _load() async {
final searches = await widget.store.list();
final counts = <String, int>{
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<void> _open(SavedSearch search) async {
await widget.store.markViewed(search.id);
if (!mounted) return;
context.go('/market', extra: search);
}
Future<void> _confirmDelete(SavedSearch search) async {
final t = context.t;
final ok = await showDialog<bool>(
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<SavedSearchesAction> createState() => _SavedSearchesActionState();
}
class _SavedSearchesActionState extends State<SavedSearchesAction> {
int _count = 0;
StreamSubscription<void>? _changesSub;
@override
void initState() {
super.initState();
_changesSub = widget.store.changes.listen((_) => _refresh());
_refresh();
}
@override
void dispose() {
_changesSub?.cancel();
super.dispose();
}
Future<void> _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 = <String>[
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,
),
],
),
),
);
}
}

View file

@ -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);
});
}