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:
vjrj 2026-07-17 12:35:56 +02:00
parent dd9c97a57a
commit 5b1066a224
4 changed files with 251 additions and 9 deletions

View file

@ -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';

View 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];
}