Compare commits

...

5 commits

Author SHA1 Message Date
87e87abc7c chore(i18n): regenerate strings.g.dart (slang)
All checks were successful
ci / test-commons-core (push) Successful in 43s
ci / analyze (push) Successful in 1m23s
ci / test-app-seeds (push) Successful in 5m48s
2026-07-17 13:18:29 +02:00
93028c4933 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.
2026-07-17 13:18:29 +02:00
c0cd299408 feat(social): add SavedSearchAlertService + notification alerts channel
One discovery subscription over the shared connection for the user's
current zone; matches each incoming offer against saved searches and
fires a text-free OS alert on the new 'alerts' channel for fresh matches.
Skips own/blocked/hidden/non-active offers and dedups via seen keys. The
replay-on-connect doubles as the catch-up scan. Notification payloads are
now prefixed so taps route to chat or the saved search.
2026-07-17 13:18:29 +02:00
530bf96358 feat(social): add SavedSearchesStore with alert bookkeeping
Keystore-backed, account-scoped store mirroring SavedOffersStore. Tracks
per-search seen keys (dedup, capped 500) and unread match keys (badge
source), with markMatched/markViewed/newMatchCount and a changes stream.
2026-07-17 13:18:29 +02:00
5b1066a224 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.
2026-07-17 13:18:29 +02:00
20 changed files with 1492 additions and 20 deletions

View file

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

View file

@ -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<Bootstrap> {
getIt.isRegistered<SyncService>() ? getIt<SyncService>() : null;
final plantares =
getIt.isRegistered<PlantareService>() ? getIt<PlantareService>() : 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<SavedSearchAlertService>()
? getIt<SavedSearchAlertService>()
: 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<Bootstrap> {
profileStore: getIt<ProfileStore>(),
profileCache: getIt<ProfileCache>(),
savedOffers: getIt<SavedOffersStore>(),
savedSearches: getIt<SavedSearchesStore>(),
socialAccounts: getIt<SocialAccountStore>(),
inbox: inbox,
notifications: notifications,

View file

@ -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<void> configureDependencies() async {
..registerSingleton<SavedOffersStore>(
SavedOffersStore(secretStore, accountScope: scope),
)
..registerSingleton<SavedSearchesStore>(
SavedSearchesStore(secretStore, accountScope: scope),
)
..registerSingleton<ExportImportService>(
ExportImportService(
repository: varietyRepository,
@ -273,6 +278,18 @@ Future<void> 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>(
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.unregister<PlantareService>();
}
if (getIt.isRegistered<SavedSearchAlertService>()) {
await getIt<SavedSearchAlertService>().stop();
await getIt.unregister<SavedSearchAlertService>();
}
if (getIt.isRegistered<SocialConnection>()) {
await getIt<SocialConnection>().dispose();
await getIt.unregister<SocialConnection>();
@ -324,6 +345,8 @@ Future<void> switchSocialAccount(int account) async {
await getIt.unregister<ProfileCache>();
await getIt<SavedOffersStore>().close();
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.
getIt
@ -339,6 +362,9 @@ Future<void> switchSocialAccount(int account) async {
..registerSingleton<SavedOffersStore>(
SavedOffersStore(secretStore, accountScope: scope),
)
..registerSingleton<SavedSearchesStore>(
SavedSearchesStore(secretStore, accountScope: scope),
)
..registerSingleton<UnreadService>(
UnreadService(getIt<MessageStore>(), secretStore),
);
@ -373,16 +399,26 @@ Future<void> switchSocialAccount(int account) async {
selfSecretKey: social.identity.privateKeyHex,
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
..registerSingleton<SocialService>(social)
..registerSingleton<SocialConnection>(connection)
..registerSingleton<InboxService>(inbox)
..registerSingleton<SyncService>(sync)
..registerSingleton<PlantareService>(plantares);
..registerSingleton<PlantareService>(plantares)
..registerSingleton<SavedSearchAlertService>(savedSearchAlerts);
// Subscribe the listeners BEFORE the connection starts connecting.
inbox.start();
sync.start();
plantares.start();
savedSearchAlerts.start();
connection.start();
}

View file

@ -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",

View file

@ -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",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 7
/// Strings: 3472 (496 per locale)
/// Strings: 3494 (499 per locale)
///
/// Built on 2026-07-17 at 07:24 UTC
/// Built on 2026-07-17 at 11:10 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -42,6 +42,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
// Translations
late final Translations$avatar$en avatar = Translations$avatar$en.internal(_root);
late final Translations$favorites$en favorites = Translations$favorites$en.internal(_root);
late final Translations$savedSearches$en savedSearches = Translations$savedSearches$en.internal(_root);
late final Translations$seedSaving$en seedSaving = Translations$seedSaving$en.internal(_root);
late final Translations$calendar$en calendar = Translations$calendar$en.internal(_root);
late final Translations$app$en app = Translations$app$en.internal(_root);
@ -137,6 +138,48 @@ class Translations$favorites$en {
String get unavailable => 'No longer available';
}
// Path: savedSearches
class Translations$savedSearches$en {
Translations$savedSearches$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Saved searches'
String get title => 'Saved searches';
/// en: 'No saved searches yet. Save a search from the market and we'll tell you when something like it appears in your zone.'
String get empty => 'No saved searches yet. Save a search from the market and we\'ll tell you when something like it appears in your zone.';
/// en: 'Save this search'
String get save => 'Save this search';
/// en: 'Name this search'
String get nameLabel => 'Name this search';
/// en: 'e.g. Tomatoes near me'
String get namePlaceholder => 'e.g. Tomatoes near me';
/// en: 'Search saved — we'll alert you about new matches nearby'
String get saved => 'Search saved — we\'ll alert you about new matches nearby';
/// en: 'Saved searches'
String get openTooltip => 'Saved searches';
/// en: 'Delete'
String get delete => 'Delete';
/// en: 'Delete this saved search?'
String get deleteConfirm => 'Delete this saved search?';
/// en: '{n} new'
String newMatchesBadge({required Object n}) => '${n} new';
/// en: 'New seeds near you: {label}'
String alert({required Object label}) => 'New seeds near you: ${label}';
}
// Path: seedSaving
class Translations$seedSaving$en {
Translations$seedSaving$en.internal(this._root);
@ -2616,6 +2659,17 @@ extension on Translations {
'favorites.save' => 'Save to favorites',
'favorites.remove' => 'Remove from favorites',
'favorites.unavailable' => 'No longer available',
'savedSearches.title' => 'Saved searches',
'savedSearches.empty' => 'No saved searches yet. Save a search from the market and we\'ll tell you when something like it appears in your zone.',
'savedSearches.save' => 'Save this search',
'savedSearches.nameLabel' => 'Name this search',
'savedSearches.namePlaceholder' => 'e.g. Tomatoes near me',
'savedSearches.saved' => 'Search saved — we\'ll alert you about new matches nearby',
'savedSearches.openTooltip' => 'Saved searches',
'savedSearches.delete' => 'Delete',
'savedSearches.deleteConfirm' => 'Delete this saved search?',
'savedSearches.newMatchesBadge' => ({required Object n}) => '${n} new',
'savedSearches.alert' => ({required Object label}) => 'New seeds near you: ${label}',
'seedSaving.title' => 'Saving its seed',
'seedSaving.subtitle' => 'What it takes to keep the variety true',
'seedSaving.lifeCycle' => 'Cycle',
@ -3108,6 +3162,8 @@ extension on Translations {
'plantare.dueByHint' => 'A gentle reminder, never enforced',
'plantare.pickDate' => 'Pick a date',
'plantare.clearDate' => 'Clear date',
_ => null,
} ?? switch (path) {
'plantare.sectionTitle' => 'Commitments',
'plantare.propose' => 'Propose a signed Plantaré',
'plantare.proposeHelp' => 'Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.',
@ -3119,8 +3175,6 @@ extension on Translations {
'plantare.returnSimilar' => 'A similar amount of seed',
'plantare.returnSimilarNote' => 'open-pollinated · non-GMO · organically grown',
'plantare.returnWork' => 'Some hours of work',
_ => null,
} ?? switch (path) {
'plantare.returnOther' => 'Something else',
'plantare.workHoursLabel' => 'How many hours?',
'plantare.proposalsSection' => 'Waiting for your reply',

View file

@ -41,6 +41,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
// Translations
@override late final _Translations$avatar$es avatar = _Translations$avatar$es._(_root);
@override late final _Translations$favorites$es favorites = _Translations$favorites$es._(_root);
@override late final _Translations$savedSearches$es savedSearches = _Translations$savedSearches$es._(_root);
@override late final _Translations$seedSaving$es seedSaving = _Translations$seedSaving$es._(_root);
@override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root);
@override late final _Translations$app$es app = _Translations$app$es._(_root);
@ -118,6 +119,26 @@ class _Translations$favorites$es extends Translations$favorites$en {
@override String get unavailable => 'Ya no está disponible';
}
// Path: savedSearches
class _Translations$savedSearches$es extends Translations$savedSearches$en {
_Translations$savedSearches$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Búsquedas guardadas';
@override String get 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.';
@override String get save => 'Guardar esta búsqueda';
@override String get nameLabel => 'Nombra esta búsqueda';
@override String get namePlaceholder => 'p. ej. Tomates cerca';
@override String get saved => 'Búsqueda guardada — te avisaremos de lo nuevo que encaje cerca';
@override String get openTooltip => 'Búsquedas guardadas';
@override String get delete => 'Eliminar';
@override String get deleteConfirm => '¿Eliminar esta búsqueda guardada?';
@override String newMatchesBadge({required Object n}) => '${n} nuevas';
@override String alert({required Object label}) => 'Semillas nuevas cerca: ${label}';
}
// Path: seedSaving
class _Translations$seedSaving$es extends Translations$seedSaving$en {
_Translations$seedSaving$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -1451,6 +1472,17 @@ extension on TranslationsEs {
'favorites.save' => 'Guardar en favoritos',
'favorites.remove' => 'Quitar de favoritos',
'favorites.unavailable' => 'Ya no está disponible',
'savedSearches.title' => 'Búsquedas guardadas',
'savedSearches.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.',
'savedSearches.save' => 'Guardar esta búsqueda',
'savedSearches.nameLabel' => 'Nombra esta búsqueda',
'savedSearches.namePlaceholder' => 'p. ej. Tomates cerca',
'savedSearches.saved' => 'Búsqueda guardada — te avisaremos de lo nuevo que encaje cerca',
'savedSearches.openTooltip' => 'Búsquedas guardadas',
'savedSearches.delete' => 'Eliminar',
'savedSearches.deleteConfirm' => '¿Eliminar esta búsqueda guardada?',
'savedSearches.newMatchesBadge' => ({required Object n}) => '${n} nuevas',
'savedSearches.alert' => ({required Object label}) => 'Semillas nuevas cerca: ${label}',
'seedSaving.title' => 'Conservar su semilla',
'seedSaving.subtitle' => 'Lo que hace falta para mantener la variedad fiel',
'seedSaving.lifeCycle' => 'Ciclo',
@ -1943,6 +1975,8 @@ extension on TranslationsEs {
'plantare.pickDate' => 'Elegir fecha',
'plantare.clearDate' => 'Quitar fecha',
'plantare.sectionTitle' => 'Compromisos',
_ => null,
} ?? switch (path) {
'plantare.propose' => 'Proponer un Plantaré firmado',
'plantare.proposeHelp' => 'Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.',
'plantare.proposeTo' => ({required Object name}) => 'Con ${name}',
@ -1954,8 +1988,6 @@ extension on TranslationsEs {
'plantare.returnSimilarNote' => 'polinización abierta · no transgénica · cultivada en ecológico',
'plantare.returnWork' => 'Unas horas de trabajo',
'plantare.returnOther' => 'Otra cosa',
_ => null,
} ?? switch (path) {
'plantare.workHoursLabel' => '¿Cuántas horas?',
'plantare.proposalsSection' => 'Esperando tu respuesta',
'plantare.incomingFrom' => ({required Object name}) => '${name} te propone un Plantaré',

View file

@ -25,7 +25,16 @@ class NotificationService {
/// app wires this to `router.push('/chat/<pubkey>')` once the router exists.
void Function(String peerPubkey)? onTapChat;
/// Called with a saved-search id when the user taps a search-alert
/// notification. The app wires this to open the saved-searches list.
void Function(String searchId)? onTapSearch;
static const _channelId = 'messages';
static const _alertsChannelId = 'alerts';
/// Tap payloads are prefixed so one handler can route by kind. A bare payload
/// (no prefix) stays a chat peer pubkey, for backward compatibility.
static const _searchPayloadPrefix = 'search:';
static bool get _platformSupported {
if (kIsWeb) return false;
@ -57,7 +66,12 @@ class NotificationService {
),
onDidReceiveNotificationResponse: (response) {
final payload = response.payload;
if (payload != null && payload.isNotEmpty) onTapChat?.call(payload);
if (payload == null || payload.isEmpty) return;
if (payload.startsWith(_searchPayloadPrefix)) {
onTapSearch?.call(payload.substring(_searchPayloadPrefix.length));
} else {
onTapChat?.call(payload);
}
},
);
await _plugin
@ -100,4 +114,35 @@ class NotificationService {
await _plugin.show(peerPubkey.hashCode, title, null, details,
payload: peerPubkey);
}
/// Shows a notification for a new offer matching a saved search. [title] is a
/// generic line built by the caller (e.g. "New match: Tomatoes"); [searchId]
/// rides along so a tap opens that search. No-op on unsupported platforms.
Future<void> showSearchAlert({
required String searchId,
required String title,
}) async {
if (!_supported) return;
const details = NotificationDetails(
android: AndroidNotificationDetails(
_alertsChannelId,
'Search alerts',
channelDescription: 'New offers matching your saved searches',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
);
// One notification per search (same id replaces the previous), so repeated
// matches for one search don't stack up.
await _plugin.show(
_searchPayloadPrefix.hashCode ^ searchId.hashCode,
title,
null,
details,
payload: '$_searchPayloadPrefix$searchId',
);
}
}

View file

@ -0,0 +1,147 @@
import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/foundation.dart';
import 'discovery_area.dart';
import 'notification_service.dart';
import 'saved_searches_store.dart';
import 'social_connection.dart';
import 'social_service.dart' show SocialSession;
import 'social_settings.dart';
/// Builds the notification title for a fresh match on [search]. Injected so the
/// app can supply a localized string while tests stay i18n-free.
typedef AlertTitle = String Function(SavedSearch search);
/// App-wide listener that turns incoming market offers into saved-search alerts
/// (Wallapop's "búsquedas favoritas"). One discovery subscription over the
/// SHARED [SocialConnection] for the user's whole current zone; each offer is
/// matched locally against every saved search.
///
/// The discover stream replays a relay's stored offers before going live, so
/// this doubles as the catch-up scan on app start the per-search seen-key
/// dedup in [SavedSearchesStore] keeps replays and edits silent, so only
/// genuinely new matches notify. It re-subscribes when the connection
/// reconnects and when the user's search area changes.
///
/// Foreground only background/push delivery is a later, larger concern.
class SavedSearchAlertService {
SavedSearchAlertService({
required SocialConnection connection,
required String selfPubkey,
required SavedSearchesStore store,
required SocialSettings settings,
NotificationService? notifications,
AlertTitle? alertTitle,
}) : _connection = connection,
_selfPubkey = selfPubkey,
_store = store,
_settings = settings,
_notifications = notifications,
_alertTitle = alertTitle ?? ((s) => s.label);
final SocialConnection _connection;
final String _selfPubkey;
final SavedSearchesStore _store;
final SocialSettings _settings;
final NotificationService? _notifications;
final AlertTitle _alertTitle;
StreamSubscription<SocialSession?>? _sessionsSub;
StreamSubscription<Offer>? _offersSub;
SocialSession? _session;
String _prefix = '';
bool _started = false;
/// Begins listening. Subscribe to the connection's sessions BEFORE it starts
/// connecting, so the first session is caught too.
void start() {
if (_started) return;
_started = true;
_sessionsSub = _connection.sessions.listen(_onSession);
final current = _connection.current;
if (current != null) _onSession(current);
}
/// Re-subscribes discovery, e.g. after the user changes their search area.
/// Cheap no-op when the session and prefix are unchanged.
Future<void> refreshArea() async {
final session = _session;
if (session != null) await _bind(session, force: true);
}
void _onSession(SocialSession? session) {
if (identical(session, _session) && _offersSub != null) return;
_session = session;
if (session == null) {
unawaited(_offersSub?.cancel());
_offersSub = null;
return;
}
unawaited(_bind(session, force: true));
}
/// (Re)binds the offer subscription to [session] over the user's current
/// zone. Skips re-subscribing when nothing changed unless [force].
Future<void> _bind(SocialSession session, {bool force = false}) async {
final area = await _settings.areaGeohash();
if (area.isEmpty) {
// No zone set yet nothing to search. Drop any stale subscription.
await _offersSub?.cancel();
_offersSub = null;
_prefix = '';
return;
}
final prefix = searchPrefix(area, await _settings.searchPrecision());
if (!force && prefix == _prefix && _offersSub != null) return;
await _offersSub?.cancel();
_prefix = prefix;
_offersSub = session.offers
.discover(DiscoveryQuery(geohashPrefix: prefix))
.listen(
ingest,
onError: (_) {}, // handled by the connection's reconnect
);
}
/// Matches one incoming [offer] against every saved search and fires an alert
/// for each fresh match. A testable seam (no relay).
@visibleForTesting
Future<void> ingest(Offer offer) async {
// Never alert on your own listings.
if (offer.authorPubkeyHex == _selfPubkey) return;
// Only live offers are worth surfacing.
if (offer.status != OfferLifecycle.active) return;
// Respect the same moderation the market applies.
if (await _settings.isBlocked(offer.authorPubkeyHex)) return;
final hidden = await _settings.hiddenOfferKeys();
if (hidden.contains(SocialSettings.offerKey(offer.authorPubkeyHex, offer.id))) {
return;
}
final searches = await _store.list();
if (searches.isEmpty) return;
final key = SavedSearchesStore.keyOf(offer);
for (final search in searches) {
if (!search.matches(offer)) continue;
final isNew = await _store.markMatched(search.id, key);
if (isNew) {
await _notifications?.showSearchAlert(
searchId: search.id,
title: _alertTitle(search),
);
}
}
}
Future<void> stop() async {
_started = false;
await _sessionsSub?.cancel();
_sessionsSub = null;
await _offersSub?.cancel();
_offersSub = null;
_session = null;
_prefix = '';
}
}

View file

@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:convert';
import 'package:commons_core/commons_core.dart';
import '../security/secret_store.dart';
/// A person's saved market searches with their alert bookkeeping — the query
/// and facets they want to be notified about, Wallapop-style. Keystore-backed
/// (no plaintext at rest) and namespaced by the active identity's
/// [accountScope], like [SavedOffersStore].
///
/// Per search we also persist:
/// - [_seenKeysField]: offer coordinates (`authorPubkeyHex:id`) already alerted
/// on, so a relay replaying stored/edited events never re-notifies. Capped at
/// [_seenCap] (oldest dropped) to keep the blob small.
/// - [_newKeysField]: matches the user hasn't looked at yet — the badge source,
/// cleared when they open the search.
class SavedSearchesStore {
SavedSearchesStore(this._store, {String accountScope = ''})
: _key = accountScope.isEmpty
? 'tane.social.saved_searches'
: 'tane.social.$accountScope.saved_searches';
final SecretStore _store;
final String _key;
static const _seenCap = 500;
static const _seenKeysField = 'seenKeys';
static const _newKeysField = 'newKeys';
final _changes = StreamController<void>.broadcast();
/// Emits whenever the stored searches or their alert counts change, so open
/// screens and badges can refresh.
Stream<void> get changes => _changes.stream;
/// The stable per-offer key used for dedup: its addressable coordinate
/// (`authorPubkeyHex:id`), matching [SavedOffersStore.keyOf].
static String keyOf(Offer offer) => '${offer.authorPubkeyHex}:${offer.id}';
/// Every saved search, newest first (by creation time).
Future<List<SavedSearch>> list() async {
final entries = await _rawList()
..sort((a, b) =>
((b['createdAt'] as int?) ?? 0).compareTo((a['createdAt'] as int?) ?? 0));
return [for (final e in entries) SavedSearch.fromJson(e)];
}
/// Whether a search with [id] exists.
Future<bool> exists(String id) async =>
(await _rawList()).any((e) => e['id'] == id);
/// Saves [search] (idempotent on its id). [seedSeenKeys] pre-marks offers the
/// user has already seen (the current on-screen matches), so they aren't
/// alerted about listings that were visible when they saved the search.
Future<void> save(
SavedSearch search, {
Iterable<String> seedSeenKeys = const [],
}) async {
final list = await _rawList();
final existing = _find(list, search.id);
final seen = <String>{
...?(existing?[_seenKeysField] as List?)?.cast<String>(),
...seedSeenKeys,
};
list
..removeWhere((e) => e['id'] == search.id)
..add({
...search.toJson(),
_seenKeysField: _capped(seen.toList()),
// Keep any unread matches on re-save (e.g. an edit), else start empty.
_newKeysField:
(existing?[_newKeysField] as List?)?.cast<String>() ?? const [],
});
await _writeRaw(list);
}
/// Removes the search with [id]; no-op if absent.
Future<void> remove(String id) async {
final list = await _rawList()..removeWhere((e) => e['id'] == id);
await _writeRaw(list);
}
/// Records that [offerKey] matched search [id]: adds it to both the seen set
/// (so it never re-alerts) and the unread set (so the badge shows it).
/// Returns true when this is a fresh match worth notifying about; false when
/// the key was already seen (a replay/echo) or the search is gone.
Future<bool> markMatched(String id, String offerKey) async {
final list = await _rawList();
final entry = _find(list, id);
if (entry == null) return false;
final seen = (entry[_seenKeysField] as List?)?.cast<String>() ?? const [];
if (seen.contains(offerKey)) return false;
entry[_seenKeysField] = _capped([...seen, offerKey]);
final unread = (entry[_newKeysField] as List?)?.cast<String>() ?? const [];
if (!unread.contains(offerKey)) {
entry[_newKeysField] = [...unread, offerKey];
}
await _writeRaw(list);
return true;
}
/// Clears the unread matches for search [id] (the user opened it).
Future<void> markViewed(String id) async {
final list = await _rawList();
final entry = _find(list, id);
if (entry == null) return;
if (((entry[_newKeysField] as List?) ?? const []).isEmpty) return;
entry[_newKeysField] = const [];
await _writeRaw(list);
}
/// Unread match count for a single search.
Future<int> newMatchCount(String id) async {
final entry = _find(await _rawList(), id);
return ((entry?[_newKeysField] as List?) ?? const []).length;
}
/// Total unread matches across every saved search (the app-level badge).
Future<int> totalNewMatchCount() async {
var total = 0;
for (final e in await _rawList()) {
total += ((e[_newKeysField] as List?) ?? const []).length;
}
return total;
}
static Map<String, dynamic>? _find(
List<Map<String, dynamic>> list,
String id,
) {
for (final e in list) {
if (e['id'] == id) return e;
}
return null;
}
/// Keeps only the newest [_seenCap] keys (list is append-ordered, so drop
/// from the front).
static List<String> _capped(List<String> keys) => keys.length <= _seenCap
? keys
: keys.sublist(keys.length - _seenCap);
Future<List<Map<String, dynamic>>> _rawList() async {
final raw = await _store.read(_key);
if (raw == null || raw.isEmpty) return [];
return (jsonDecode(raw) as List)
.cast<Map<String, dynamic>>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
Future<void> _writeRaw(List<Map<String, dynamic>> list) async {
await _store.write(_key, jsonEncode(list));
if (!_changes.isClosed) _changes.add(null);
}
Future<void> close() async {
await _changes.close();
}
}

View file

@ -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<Offer> 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();
}
@ -266,6 +267,16 @@ class OffersCubit extends Cubit<OffersState> {
void setHiddenOffers(Set<String> 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 {},

View file

@ -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<MarketScreen> {
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<MarketScreen> {
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<MarketScreen> {
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<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"
/// instead of any precise location; price only when it's a sale.
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,148 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/notification_service.dart';
import 'package:tane/services/saved_search_alert_service.dart';
import 'package:tane/services/saved_searches_store.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import '../support/test_support.dart';
/// Records search-alert calls instead of touching the OS plugin.
class _RecordingNotifications extends NotificationService {
_RecordingNotifications() : super(supported: false);
final searchIds = <String>[];
final titles = <String>[];
@override
Future<void> showSearchAlert({
required String searchId,
required String title,
}) async {
searchIds.add(searchId);
titles.add(title);
}
}
void main() {
const seedHex =
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
const myPubkey = 'me';
// A connection that never actually opens ingest doesn't use it.
Future<SocialConnection> offlineConnection() async => SocialConnection(
social: await SocialService.fromRootSeedHex(seedHex),
settings: SocialSettings(InMemorySecretStore()),
open: (_) async => throw StateError('unused in ingest tests'),
online: const Stream.empty(),
);
late SavedSearchesStore store;
late SocialSettings settings;
late _RecordingNotifications notifications;
late SavedSearchAlertService service;
setUp(() async {
store = SavedSearchesStore(InMemorySecretStore());
settings = SocialSettings(InMemorySecretStore());
notifications = _RecordingNotifications();
service = SavedSearchAlertService(
connection: await offlineConnection(),
selfPubkey: myPubkey,
store: store,
settings: settings,
notifications: notifications,
alertTitle: (s) => 'New match: ${s.label}',
);
});
tearDown(() async {
await service.stop();
await store.close();
});
Offer offer({
String id = 'o1',
String author = 'grower',
String summary = 'Heirloom tomato seeds',
OfferType type = OfferType.gift,
OfferLifecycle status = OfferLifecycle.active,
String? category = 'Solanaceae',
bool isOrganic = false,
}) =>
Offer(
id: id,
authorPubkeyHex: author,
summary: summary,
type: type,
status: status,
approxGeohash: 'u09',
category: category,
isOrganic: isOrganic,
);
SavedSearch search(String id, {String query = ''}) =>
SavedSearch(id: id, label: id, query: query);
test('a matching offer fires one alert and records the match', () async {
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
expect(notifications.searchIds, ['tom']);
expect(notifications.titles, ['New match: tom']);
expect(await store.newMatchCount('tom'), 1);
});
test('a non-matching offer is silent', () async {
await store.save(search('pep', query: 'pepper'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('a replayed offer does not re-alert', () async {
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
await service.ingest(offer()); // relay replays the stored event
expect(notifications.searchIds, ['tom']);
});
test('one offer can match several searches', () async {
await store.save(search('all'));
await store.save(search('tom', query: 'tomato'));
await service.ingest(offer());
expect(notifications.searchIds, containsAll(<String>['all', 'tom']));
expect(notifications.searchIds, hasLength(2));
});
test('my own offers never alert', () async {
await store.save(search('all'));
await service.ingest(offer(author: myPubkey));
expect(notifications.searchIds, isEmpty);
});
test('non-active offers never alert', () async {
await store.save(search('all'));
await service.ingest(offer(status: OfferLifecycle.closed));
expect(notifications.searchIds, isEmpty);
});
test('offers from a blocked author never alert', () async {
await settings.block('grower');
await store.save(search('all'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('a hidden (reported) offer never alerts', () async {
await settings.hideOffer(authorPubkeyHex: 'grower', offerId: 'o1');
await store.save(search('all'));
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
test('with no saved searches nothing fires', () async {
await service.ingest(offer());
expect(notifications.searchIds, isEmpty);
});
}

View file

@ -0,0 +1,97 @@
import 'package:tane/services/saved_searches_store.dart';
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import '../support/test_support.dart';
void main() {
late SavedSearchesStore store;
setUp(() => store = SavedSearchesStore(InMemorySecretStore()));
tearDown(() => store.close());
SavedSearch search(String id, {String query = '', int createdAt = 0}) =>
SavedSearch(id: id, label: id, query: query, createdAt: createdAt);
test('saves and lists newest first', () async {
await store.save(search('a', createdAt: 100));
await store.save(search('b', createdAt: 200));
final list = await store.list();
expect(list.map((s) => s.id), ['b', 'a']);
});
test('save is idempotent on id', () async {
await store.save(search('a', query: 'one'));
await store.save(search('a', query: 'two'));
final list = await store.list();
expect(list, hasLength(1));
expect(list.single.query, 'two');
});
test('remove deletes a search', () async {
await store.save(search('a'));
await store.remove('a');
expect(await store.list(), isEmpty);
expect(await store.exists('a'), isFalse);
});
test('markMatched reports a fresh match once, then dedups replays', () async {
await store.save(search('a'));
expect(await store.markMatched('a', 'author:o1'), isTrue);
// Same key again = replay/echo, not a new alert.
expect(await store.markMatched('a', 'author:o1'), isFalse);
expect(await store.markMatched('a', 'author:o2'), isTrue);
expect(await store.newMatchCount('a'), 2);
});
test('markMatched returns false for an unknown search', () async {
expect(await store.markMatched('ghost', 'author:o1'), isFalse);
});
test('seeded seen keys suppress alerts for already-visible offers', () async {
await store.save(search('a'), seedSeenKeys: ['author:o1']);
expect(await store.markMatched('a', 'author:o1'), isFalse);
expect(await store.newMatchCount('a'), 0);
});
test('markViewed clears unread but keeps seen (no re-alert)', () async {
await store.save(search('a'));
await store.markMatched('a', 'author:o1');
await store.markViewed('a');
expect(await store.newMatchCount('a'), 0);
// Still seen: the same offer won't alert again.
expect(await store.markMatched('a', 'author:o1'), isFalse);
});
test('totalNewMatchCount sums across searches', () async {
await store.save(search('a'));
await store.save(search('b'));
await store.markMatched('a', 'author:o1');
await store.markMatched('b', 'author:o2');
await store.markMatched('b', 'author:o3');
expect(await store.totalNewMatchCount(), 3);
});
test('changes stream fires on mutations', () async {
final events = <void>[];
final sub = store.changes.listen(events.add);
await store.save(search('a'));
await store.markMatched('a', 'author:o1');
await store.markViewed('a');
await store.remove('a');
await Future<void>.delayed(Duration.zero);
expect(events, hasLength(4));
await sub.cancel();
});
test('account scopes are isolated', () async {
final secret = InMemorySecretStore();
final s1 = SavedSearchesStore(secret, accountScope: 'acct1');
final s2 = SavedSearchesStore(secret, accountScope: 'acct2');
await s1.save(search('a'));
expect(await s1.list(), hasLength(1));
expect(await s2.list(), isEmpty);
await s1.close();
await s2.close();
});
}

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

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

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