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.
This commit is contained in:
parent
dd9c97a57a
commit
5b1066a224
4 changed files with 251 additions and 9 deletions
|
|
@ -69,20 +69,21 @@ class OffersState extends Equatable {
|
||||||
/// [offers] narrowed by the text [query] and the chip filters (all ANDed).
|
/// [offers] narrowed by the text [query] and the chip filters (all ANDed).
|
||||||
/// Kept separate from [offers] so filtering never drops the discoveries.
|
/// Kept separate from [offers] so filtering never drops the discoveries.
|
||||||
List<Offer> get visibleOffers {
|
List<Offer> get visibleOffers {
|
||||||
final q = query.trim().toLowerCase();
|
|
||||||
return offers.where((o) {
|
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 (blockedAuthors.contains(o.authorPubkeyHex)) return false;
|
||||||
if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) {
|
if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false;
|
// The query/facet chain is shared with saved-search alerts so the two
|
||||||
if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false;
|
// never disagree on what "matches".
|
||||||
if (categoryFilter.isNotEmpty &&
|
return SavedSearch.matchesFilters(
|
||||||
(o.category == null || !categoryFilter.contains(o.category))) {
|
o,
|
||||||
return false;
|
query: query,
|
||||||
}
|
types: typeFilter,
|
||||||
if (organicOnly && !o.isOrganic) return false;
|
categories: categoryFilter,
|
||||||
return true;
|
organicOnly: organicOnly,
|
||||||
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export 'src/social/profile_transport.dart';
|
||||||
export 'src/social/rating.dart';
|
export 'src/social/rating.dart';
|
||||||
export 'src/social/rating_transport.dart';
|
export 'src/social/rating_transport.dart';
|
||||||
export 'src/social/report_transport.dart';
|
export 'src/social/report_transport.dart';
|
||||||
|
export 'src/social/saved_search.dart';
|
||||||
export 'src/social/sync_transport.dart';
|
export 'src/social/sync_transport.dart';
|
||||||
export 'src/social/trust_transport.dart';
|
export 'src/social/trust_transport.dart';
|
||||||
export 'src/social/web_of_trust.dart';
|
export 'src/social/web_of_trust.dart';
|
||||||
|
|
|
||||||
124
packages/commons_core/lib/src/social/saved_search.dart
Normal file
124
packages/commons_core/lib/src/social/saved_search.dart
Normal file
|
|
@ -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<OfferType> types;
|
||||||
|
|
||||||
|
/// Category facet (free-text categories); empty = any category.
|
||||||
|
final Set<String> 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<OfferType> types = const {},
|
||||||
|
Set<String> 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<OfferType>? types,
|
||||||
|
Set<String>? 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<String, dynamic> 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<String, dynamic> 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<Object?> get props =>
|
||||||
|
[id, label, query, types, categories, organicOnly, createdAt];
|
||||||
|
}
|
||||||
116
packages/commons_core/test/social/saved_search_test.dart
Normal file
116
packages/commons_core/test/social/saved_search_test.dart
Normal file
|
|
@ -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<OfferType> types = const {},
|
||||||
|
Set<String> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue