From 5b1066a224965c86c569f9e1721eeb7dbccb8548 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 17 Jul 2026 12:35:56 +0200 Subject: [PATCH] feat(social): add SavedSearch model with shared offer matcher Extract the market's query/type/category/organic filter chain into SavedSearch.matchesFilters so live results and saved-search alerts can never disagree. OffersState.visibleOffers now delegates to it. --- apps/app_seeds/lib/state/offers_cubit.dart | 19 +-- packages/commons_core/lib/commons_core.dart | 1 + .../lib/src/social/saved_search.dart | 124 ++++++++++++++++++ .../test/social/saved_search_test.dart | 116 ++++++++++++++++ 4 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 packages/commons_core/lib/src/social/saved_search.dart create mode 100644 packages/commons_core/test/social/saved_search_test.dart diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index 8aa6e9d..02ac491 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -69,20 +69,21 @@ class OffersState extends Equatable { /// [offers] narrowed by the text [query] and the chip filters (all ANDed). /// Kept separate from [offers] so filtering never drops the discoveries. List get visibleOffers { - final q = query.trim().toLowerCase(); return offers.where((o) { + // Moderation state (block/hide) is app-side, not part of the search facets. if (blockedAuthors.contains(o.authorPubkeyHex)) return false; if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) { return false; } - if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false; - if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false; - if (categoryFilter.isNotEmpty && - (o.category == null || !categoryFilter.contains(o.category))) { - return false; - } - if (organicOnly && !o.isOrganic) return false; - return true; + // The query/facet chain is shared with saved-search alerts so the two + // never disagree on what "matches". + return SavedSearch.matchesFilters( + o, + query: query, + types: typeFilter, + categories: categoryFilter, + organicOnly: organicOnly, + ); }).toList(); } diff --git a/packages/commons_core/lib/commons_core.dart b/packages/commons_core/lib/commons_core.dart index 5d18310..0752077 100644 --- a/packages/commons_core/lib/commons_core.dart +++ b/packages/commons_core/lib/commons_core.dart @@ -35,6 +35,7 @@ export 'src/social/profile_transport.dart'; export 'src/social/rating.dart'; export 'src/social/rating_transport.dart'; export 'src/social/report_transport.dart'; +export 'src/social/saved_search.dart'; export 'src/social/sync_transport.dart'; export 'src/social/trust_transport.dart'; export 'src/social/web_of_trust.dart'; diff --git a/packages/commons_core/lib/src/social/saved_search.dart b/packages/commons_core/lib/src/social/saved_search.dart new file mode 100644 index 0000000..b807603 --- /dev/null +++ b/packages/commons_core/lib/src/social/saved_search.dart @@ -0,0 +1,124 @@ +import 'package:equatable/equatable.dart'; + +import 'offer.dart'; + +/// A person's saved market search — the query and facets they want to be +/// alerted about, Wallapop's "búsquedas favoritas". Generic core type: it knows +/// how to decide whether an [Offer] matches, but nothing about seeds. +/// +/// The filter chain mirrors the market screen's live filtering exactly (same +/// case-insensitive summary match, same ANDed facets), so a saved search and +/// the on-screen results can never disagree. Moderation state (blocked authors, +/// hidden offers, the user's own listings) is deliberately NOT part of a search +/// — the app layer applies that on top. +class SavedSearch extends Equatable { + const SavedSearch({ + required this.id, + required this.label, + this.query = '', + this.types = const {}, + this.categories = const {}, + this.organicOnly = false, + this.createdAt = 0, + }); + + /// Stable per-search identifier (a UUID from the app's IdGen). + final String id; + + /// Human name for the search, shown in the list (e.g. "Tomates cerca"). + final String label; + + /// Free-text filter over the offer summary; empty = no text constraint. + final String query; + + /// Reciprocity-mode facet (gift/exchange/sale/wanted); empty = any type. + final Set types; + + /// Category facet (free-text categories); empty = any category. + final Set categories; + + /// When true, only offers the grower declared organic ("eco") match. + final bool organicOnly; + + /// Creation time (ms since epoch), for stable newest-first ordering. + final int createdAt; + + /// Whether [offer] satisfies this search's query and facets (all ANDed). + /// Same semantics the market screen uses for its visible list. + bool matches(Offer offer) => matchesFilters( + offer, + query: query, + types: types, + categories: categories, + organicOnly: organicOnly, + ); + + /// The shared, pure filter chain used by both the market screen's live + /// results and saved-search alerts, so the two paths can't drift. + static bool matchesFilters( + Offer offer, { + String query = '', + Set types = const {}, + Set categories = const {}, + bool organicOnly = false, + }) { + final q = query.trim().toLowerCase(); + if (q.isNotEmpty && !offer.summary.toLowerCase().contains(q)) return false; + if (types.isNotEmpty && !types.contains(offer.type)) return false; + if (categories.isNotEmpty && + (offer.category == null || !categories.contains(offer.category))) { + return false; + } + if (organicOnly && !offer.isOrganic) return false; + return true; + } + + SavedSearch copyWith({ + String? id, + String? label, + String? query, + Set? types, + Set? categories, + bool? organicOnly, + int? createdAt, + }) { + return SavedSearch( + id: id ?? this.id, + label: label ?? this.label, + query: query ?? this.query, + types: types ?? this.types, + categories: categories ?? this.categories, + organicOnly: organicOnly ?? this.organicOnly, + createdAt: createdAt ?? this.createdAt, + ); + } + + Map toJson() => { + 'id': id, + 'label': label, + if (query.isNotEmpty) 'query': query, + if (types.isNotEmpty) 'types': [for (final t in types) t.name], + if (categories.isNotEmpty) 'categories': categories.toList(), + if (organicOnly) 'organicOnly': true, + 'createdAt': createdAt, + }; + + factory SavedSearch.fromJson(Map json) => SavedSearch( + id: json['id'] as String, + label: json['label'] as String, + query: json['query'] as String? ?? '', + types: { + for (final t in (json['types'] as List? ?? const [])) + OfferType.values.byName(t as String), + }, + categories: { + for (final c in (json['categories'] as List? ?? const [])) c as String, + }, + organicOnly: json['organicOnly'] as bool? ?? false, + createdAt: json['createdAt'] as int? ?? 0, + ); + + @override + List get props => + [id, label, query, types, categories, organicOnly, createdAt]; +} diff --git a/packages/commons_core/test/social/saved_search_test.dart b/packages/commons_core/test/social/saved_search_test.dart new file mode 100644 index 0000000..5a6e339 --- /dev/null +++ b/packages/commons_core/test/social/saved_search_test.dart @@ -0,0 +1,116 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:test/test.dart'; + +void main() { + Offer offer({ + String summary = 'Heirloom tomato seeds', + OfferType type = OfferType.gift, + String? category = 'Solanaceae', + bool isOrganic = false, + }) => + Offer( + id: 'o1', + authorPubkeyHex: 'author1', + summary: summary, + type: type, + approxGeohash: 'u09', + category: category, + isOrganic: isOrganic, + ); + + SavedSearch search({ + String query = '', + Set types = const {}, + Set categories = const {}, + bool organicOnly = false, + }) => + SavedSearch( + id: 's1', + label: 'test', + query: query, + types: types, + categories: categories, + organicOnly: organicOnly, + ); + + group('matches', () { + test('empty search matches any active offer', () { + expect(search().matches(offer()), isTrue); + }); + + test('text query matches case-insensitively over the summary', () { + expect(search(query: 'TOMATO').matches(offer()), isTrue); + expect(search(query: ' tomato ').matches(offer()), isTrue); + expect(search(query: 'pepper').matches(offer()), isFalse); + }); + + test('type facet filters by reciprocity mode', () { + expect(search(types: {OfferType.gift}).matches(offer()), isTrue); + expect(search(types: {OfferType.sale}).matches(offer()), isFalse); + expect( + search(types: {OfferType.gift, OfferType.exchange}).matches(offer()), + isTrue, + ); + }); + + test('category facet requires a matching category', () { + expect(search(categories: {'Solanaceae'}).matches(offer()), isTrue); + expect(search(categories: {'Fabaceae'}).matches(offer()), isFalse); + expect( + search(categories: {'Solanaceae'}).matches(offer(category: null)), + isFalse, + ); + }); + + test('organicOnly requires a self-declared organic offer', () { + expect(search(organicOnly: true).matches(offer(isOrganic: true)), isTrue); + expect(search(organicOnly: true).matches(offer(isOrganic: false)), isFalse); + expect(search().matches(offer(isOrganic: false)), isTrue); + }); + + test('facets are ANDed together', () { + final s = search( + query: 'tomato', + types: {OfferType.gift}, + categories: {'Solanaceae'}, + organicOnly: true, + ); + expect(s.matches(offer(isOrganic: true)), isTrue); + // One facet off is enough to reject. + expect(s.matches(offer(isOrganic: false)), isFalse); + expect(s.matches(offer(type: OfferType.sale, isOrganic: true)), isFalse); + }); + + test('matchesFilters agrees with the instance matcher', () { + expect( + SavedSearch.matchesFilters(offer(), query: 'tomato'), + search(query: 'tomato').matches(offer()), + ); + }); + }); + + group('json', () { + test('round-trips all fields', () { + final s = SavedSearch( + id: 's1', + label: 'Tomates cerca', + query: 'tomate', + types: {OfferType.gift, OfferType.exchange}, + categories: {'Solanaceae', 'Fabaceae'}, + organicOnly: true, + createdAt: 1720000000000, + ); + expect(SavedSearch.fromJson(s.toJson()), s); + }); + + test('round-trips a minimal search (empty facets)', () { + final s = SavedSearch(id: 's2', label: 'all', createdAt: 42); + final decoded = SavedSearch.fromJson(s.toJson()); + expect(decoded, s); + expect(decoded.query, isEmpty); + expect(decoded.types, isEmpty); + expect(decoded.categories, isEmpty); + expect(decoded.organicOnly, isFalse); + }); + }); +}