diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index a6aec7b..21c296c 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -18,7 +18,6 @@ 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'; @@ -37,7 +36,6 @@ 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'; @@ -64,7 +62,6 @@ class TaneApp extends StatelessWidget { this.profileStore, this.profileCache, this.savedOffers, - this.savedSearches, this.socialAccounts, this.inbox, this.notifications, @@ -84,7 +81,6 @@ class TaneApp extends StatelessWidget { profileStore, profileCache, savedOffers, - savedSearches, socialAccounts, inbox, ) { @@ -92,8 +88,6 @@ 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; @@ -126,9 +120,6 @@ 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; @@ -158,7 +149,6 @@ class TaneApp extends StatelessWidget { ProfileStore? profileStore, ProfileCache? profileCache, SavedOffersStore? savedOffers, - SavedSearchesStore? savedSearches, SocialAccountStore? socialAccounts, InboxService? inbox, ) { @@ -180,7 +170,6 @@ class TaneApp extends StatelessWidget { location: location, outbox: outbox, onboarding: onboarding, - savedSearches: savedSearches, ), ), if (social != null && connection != null) @@ -207,12 +196,6 @@ 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', diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 57ab427..6423bb0 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -20,8 +20,6 @@ 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'; @@ -72,16 +70,12 @@ class _BootstrapState extends State { getIt.isRegistered() ? getIt() : null; final plantares = getIt.isRegistered() ? getIt() : null; - final savedSearchAlerts = getIt.isRegistered() - ? getIt() - : 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. + // Subscribe the inbox + sync + plantaré 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( @@ -98,7 +92,6 @@ class _BootstrapState extends State { profileStore: getIt(), profileCache: getIt(), savedOffers: getIt(), - savedSearches: getIt(), socialAccounts: getIt(), inbox: inbox, notifications: notifications, diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 528baf5..b88976f 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -41,8 +41,6 @@ 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'; @@ -191,9 +189,6 @@ Future configureDependencies() async { ..registerSingleton( SavedOffersStore(secretStore, accountScope: scope), ) - ..registerSingleton( - SavedSearchesStore(secretStore, accountScope: scope), - ) ..registerSingleton( ExportImportService( repository: varietyRepository, @@ -278,18 +273,6 @@ Future 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( - connection: connection, - selfPubkey: socialService.publicKeyHex, - store: getIt(), - settings: getIt(), - notifications: getIt(), - alertTitle: (s) => t.savedSearches.alert(label: s.label), - ), ); } @@ -326,10 +309,6 @@ Future switchSocialAccount(int account) async { await getIt().stop(); await getIt.unregister(); } - if (getIt.isRegistered()) { - await getIt().stop(); - await getIt.unregister(); - } if (getIt.isRegistered()) { await getIt().dispose(); await getIt.unregister(); @@ -345,8 +324,6 @@ Future switchSocialAccount(int account) async { await getIt.unregister(); await getIt().close(); await getIt.unregister(); - await getIt().close(); - await getIt.unregister(); // Re-register the per-identity stores under the new scope, then the identity. getIt @@ -362,9 +339,6 @@ Future switchSocialAccount(int account) async { ..registerSingleton( SavedOffersStore(secretStore, accountScope: scope), ) - ..registerSingleton( - SavedSearchesStore(secretStore, accountScope: scope), - ) ..registerSingleton( UnreadService(getIt(), secretStore), ); @@ -399,26 +373,16 @@ Future switchSocialAccount(int account) async { selfSecretKey: social.identity.privateKeyHex, connection: connection, ); - final savedSearchAlerts = SavedSearchAlertService( - connection: connection, - selfPubkey: social.publicKeyHex, - store: getIt(), - settings: getIt(), - notifications: getIt(), - alertTitle: (s) => t.savedSearches.alert(label: s.label), - ); getIt ..registerSingleton(social) ..registerSingleton(connection) ..registerSingleton(inbox) ..registerSingleton(sync) - ..registerSingleton(plantares) - ..registerSingleton(savedSearchAlerts); + ..registerSingleton(plantares); // Subscribe the listeners BEFORE the connection starts connecting. inbox.start(); sync.start(); plantares.start(); - savedSearchAlerts.start(); connection.start(); } diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 7b047f8..97d0ccc 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -12,19 +12,6 @@ "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", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index c491536..de9f4ff 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -12,19 +12,6 @@ "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", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index b62d4ec..b3f25b0 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 7 -/// Strings: 3494 (499 per locale) +/// Strings: 3472 (496 per locale) /// -/// Built on 2026-07-17 at 11:10 UTC +/// Built on 2026-07-17 at 07:24 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 5fb786c..725c1a4 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -42,7 +42,6 @@ class Translations with BaseTranslations { // 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); @@ -138,48 +137,6 @@ 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); @@ -2659,17 +2616,6 @@ 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', @@ -3162,8 +3108,6 @@ 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.', @@ -3175,6 +3119,8 @@ 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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index f0ecbc0..f3a5c8a 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -41,7 +41,6 @@ class TranslationsEs extends Translations with BaseTranslations '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); @@ -1472,17 +1451,6 @@ 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', @@ -1975,8 +1943,6 @@ 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}', @@ -1988,6 +1954,8 @@ 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é', diff --git a/apps/app_seeds/lib/services/notification_service.dart b/apps/app_seeds/lib/services/notification_service.dart index 611a8c6..18418cc 100644 --- a/apps/app_seeds/lib/services/notification_service.dart +++ b/apps/app_seeds/lib/services/notification_service.dart @@ -25,16 +25,7 @@ class NotificationService { /// app wires this to `router.push('/chat/')` 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; @@ -66,12 +57,7 @@ class NotificationService { ), onDidReceiveNotificationResponse: (response) { final payload = response.payload; - if (payload == null || payload.isEmpty) return; - if (payload.startsWith(_searchPayloadPrefix)) { - onTapSearch?.call(payload.substring(_searchPayloadPrefix.length)); - } else { - onTapChat?.call(payload); - } + if (payload != null && payload.isNotEmpty) onTapChat?.call(payload); }, ); await _plugin @@ -114,35 +100,4 @@ 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 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', - ); - } } diff --git a/apps/app_seeds/lib/services/saved_search_alert_service.dart b/apps/app_seeds/lib/services/saved_search_alert_service.dart deleted file mode 100644 index 31e458a..0000000 --- a/apps/app_seeds/lib/services/saved_search_alert_service.dart +++ /dev/null @@ -1,147 +0,0 @@ -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? _sessionsSub; - StreamSubscription? _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 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 _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 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 stop() async { - _started = false; - await _sessionsSub?.cancel(); - _sessionsSub = null; - await _offersSub?.cancel(); - _offersSub = null; - _session = null; - _prefix = ''; - } -} diff --git a/apps/app_seeds/lib/services/saved_searches_store.dart b/apps/app_seeds/lib/services/saved_searches_store.dart deleted file mode 100644 index 57b5e04..0000000 --- a/apps/app_seeds/lib/services/saved_searches_store.dart +++ /dev/null @@ -1,162 +0,0 @@ -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.broadcast(); - - /// Emits whenever the stored searches or their alert counts change, so open - /// screens and badges can refresh. - Stream 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() 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 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 save( - SavedSearch search, { - Iterable seedSeenKeys = const [], - }) async { - final list = await _rawList(); - final existing = _find(list, search.id); - final seen = { - ...?(existing?[_seenKeysField] as List?)?.cast(), - ...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() ?? const [], - }); - await _writeRaw(list); - } - - /// Removes the search with [id]; no-op if absent. - Future 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 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() ?? const []; - if (seen.contains(offerKey)) return false; - entry[_seenKeysField] = _capped([...seen, offerKey]); - final unread = (entry[_newKeysField] as List?)?.cast() ?? 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 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 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 totalNewMatchCount() async { - var total = 0; - for (final e in await _rawList()) { - total += ((e[_newKeysField] as List?) ?? const []).length; - } - return total; - } - - static Map? _find( - List> 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 _capped(List keys) => keys.length <= _seenCap - ? keys - : keys.sublist(keys.length - _seenCap); - - Future>> _rawList() async { - final raw = await _store.read(_key); - if (raw == null || raw.isEmpty) return []; - return (jsonDecode(raw) as List) - .cast>() - .map((e) => Map.from(e)) - .toList(); - } - - Future _writeRaw(List> list) async { - await _store.write(_key, jsonEncode(list)); - if (!_changes.isClosed) _changes.add(null); - } - - Future close() async { - await _changes.close(); - } -} diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index d5fecc2..8aa6e9d 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -69,21 +69,20 @@ 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; } - // 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, - ); + 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; }).toList(); } @@ -267,16 +266,6 @@ class OffersCubit extends Cubit { void setHiddenOffers(Set 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 {}, diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index b4a7eeb..ffe310a 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -8,7 +8,6 @@ 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'; @@ -19,7 +18,6 @@ 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 @@ -33,22 +31,12 @@ 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. @@ -112,9 +100,6 @@ class _MarketScreenState extends State { 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; @@ -228,8 +213,6 @@ class _MarketScreenState extends State { 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), @@ -257,7 +240,6 @@ class _MarketScreenState extends State { hasArea: _hasArea, selfPubkey: widget.social.publicKeyHex, onOfferClosed: _reloadBlocked, - savedSearches: widget.savedSearches, ), ), ); @@ -271,15 +253,11 @@ 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; @@ -368,20 +346,6 @@ 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, @@ -441,63 +405,6 @@ 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 _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( - 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 { diff --git a/apps/app_seeds/lib/ui/saved_searches_screen.dart b/apps/app_seeds/lib/ui/saved_searches_screen.dart deleted file mode 100644 index d739c22..0000000 --- a/apps/app_seeds/lib/ui/saved_searches_screen.dart +++ /dev/null @@ -1,282 +0,0 @@ -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 createState() => _SavedSearchesScreenState(); -} - -class _SavedSearchesScreenState extends State { - List? _searches; - Map _newCounts = const {}; - StreamSubscription? _changesSub; - - @override - void initState() { - super.initState(); - _changesSub = widget.store.changes.listen((_) => _load()); - _load(); - } - - @override - void dispose() { - _changesSub?.cancel(); - super.dispose(); - } - - Future _load() async { - final searches = await widget.store.list(); - final counts = { - 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 _open(SavedSearch search) async { - await widget.store.markViewed(search.id); - if (!mounted) return; - context.go('/market', extra: search); - } - - Future _confirmDelete(SavedSearch search) async { - final t = context.t; - final ok = await showDialog( - 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 createState() => _SavedSearchesActionState(); -} - -class _SavedSearchesActionState extends State { - int _count = 0; - StreamSubscription? _changesSub; - - @override - void initState() { - super.initState(); - _changesSub = widget.store.changes.listen((_) => _refresh()); - _refresh(); - } - - @override - void dispose() { - _changesSub?.cancel(); - super.dispose(); - } - - Future _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 = [ - 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, - ), - ], - ), - ), - ); - } -} diff --git a/apps/app_seeds/test/services/saved_search_alert_service_test.dart b/apps/app_seeds/test/services/saved_search_alert_service_test.dart deleted file mode 100644 index 0ab6482..0000000 --- a/apps/app_seeds/test/services/saved_search_alert_service_test.dart +++ /dev/null @@ -1,148 +0,0 @@ -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 = []; - final titles = []; - - @override - Future 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 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(['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); - }); -} diff --git a/apps/app_seeds/test/services/saved_searches_store_test.dart b/apps/app_seeds/test/services/saved_searches_store_test.dart deleted file mode 100644 index b4723ca..0000000 --- a/apps/app_seeds/test/services/saved_searches_store_test.dart +++ /dev/null @@ -1,97 +0,0 @@ -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 = []; - 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.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(); - }); -} diff --git a/apps/app_seeds/test/ui/saved_searches_screen_test.dart b/apps/app_seeds/test/ui/saved_searches_screen_test.dart deleted file mode 100644 index f4aa959..0000000 --- a/apps/app_seeds/test/ui/saved_searches_screen_test.dart +++ /dev/null @@ -1,74 +0,0 @@ -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); - }); -} diff --git a/packages/commons_core/lib/commons_core.dart b/packages/commons_core/lib/commons_core.dart index 0752077..5d18310 100644 --- a/packages/commons_core/lib/commons_core.dart +++ b/packages/commons_core/lib/commons_core.dart @@ -35,7 +35,6 @@ 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 deleted file mode 100644 index b807603..0000000 --- a/packages/commons_core/lib/src/social/saved_search.dart +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index 5a6e339..0000000 --- a/packages/commons_core/test/social/saved_search_test.dart +++ /dev/null @@ -1,116 +0,0 @@ -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); - }); - }); -}