diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 21c296c..a6aec7b 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -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', diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 6423bb0..57ab427 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -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 { getIt.isRegistered() ? getIt() : null; final plantares = getIt.isRegistered() ? getIt() : 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() + ? 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. inbox?.start(); sync?.start(); plantares?.start(); + savedSearchAlerts?.start(); connection?.start(); return TaneApp( @@ -92,6 +98,7 @@ 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 b88976f..528baf5 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -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 configureDependencies() async { ..registerSingleton( SavedOffersStore(secretStore, accountScope: scope), ) + ..registerSingleton( + SavedSearchesStore(secretStore, accountScope: scope), + ) ..registerSingleton( ExportImportService( repository: varietyRepository, @@ -273,6 +278,18 @@ 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), + ), ); } @@ -309,6 +326,10 @@ 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(); @@ -324,6 +345,8 @@ 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 @@ -339,6 +362,9 @@ Future switchSocialAccount(int account) async { ..registerSingleton( SavedOffersStore(secretStore, accountScope: scope), ) + ..registerSingleton( + SavedSearchesStore(secretStore, accountScope: scope), + ) ..registerSingleton( UnreadService(getIt(), secretStore), ); @@ -373,16 +399,26 @@ 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(plantares) + ..registerSingleton(savedSearchAlerts); // 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 97d0ccc..7b047f8 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index de9f4ff..c491536 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index b3f25b0..b62d4ec 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: 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 diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 725c1a4..5fb786c 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -42,6 +42,7 @@ 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); @@ -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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index f3a5c8a..f0ecbc0 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -41,6 +41,7 @@ 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); @@ -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é', diff --git a/apps/app_seeds/lib/services/notification_service.dart b/apps/app_seeds/lib/services/notification_service.dart index 18418cc..611a8c6 100644 --- a/apps/app_seeds/lib/services/notification_service.dart +++ b/apps/app_seeds/lib/services/notification_service.dart @@ -25,7 +25,16 @@ 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; @@ -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 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 new file mode 100644 index 0000000..31e458a --- /dev/null +++ b/apps/app_seeds/lib/services/saved_search_alert_service.dart @@ -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? _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 new file mode 100644 index 0000000..57b5e04 --- /dev/null +++ b/apps/app_seeds/lib/services/saved_searches_store.dart @@ -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.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 8aa6e9d..d5fecc2 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -69,20 +69,21 @@ class OffersState extends Equatable { /// [offers] narrowed by the text [query] and the chip filters (all ANDed). /// Kept separate from [offers] so filtering never drops the discoveries. List get visibleOffers { - final q = query.trim().toLowerCase(); return offers.where((o) { + // Moderation state (block/hide) is app-side, not part of the search facets. if (blockedAuthors.contains(o.authorPubkeyHex)) return false; if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) { return false; } - if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false; - if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false; - if (categoryFilter.isNotEmpty && - (o.category == null || !categoryFilter.contains(o.category))) { - return false; - } - if (organicOnly && !o.isOrganic) return false; - return true; + // The query/facet chain is shared with saved-search alerts so the two + // never disagree on what "matches". + return SavedSearch.matchesFilters( + o, + query: query, + types: typeFilter, + categories: categoryFilter, + organicOnly: organicOnly, + ); }).toList(); } @@ -266,6 +267,16 @@ 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 ffe310a..b4a7eeb 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -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 { 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 { 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 { 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 _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 new file mode 100644 index 0000000..d739c22 --- /dev/null +++ b/apps/app_seeds/lib/ui/saved_searches_screen.dart @@ -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 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 new file mode 100644 index 0000000..0ab6482 --- /dev/null +++ b/apps/app_seeds/test/services/saved_search_alert_service_test.dart @@ -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 = []; + 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 new file mode 100644 index 0000000..b4723ca --- /dev/null +++ b/apps/app_seeds/test/services/saved_searches_store_test.dart @@ -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 = []; + 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 new file mode 100644 index 0000000..f4aa959 --- /dev/null +++ b/apps/app_seeds/test/ui/saved_searches_screen_test.dart @@ -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); + }); +} diff --git a/packages/commons_core/lib/commons_core.dart b/packages/commons_core/lib/commons_core.dart index 5d18310..0752077 100644 --- a/packages/commons_core/lib/commons_core.dart +++ b/packages/commons_core/lib/commons_core.dart @@ -35,6 +35,7 @@ export 'src/social/profile_transport.dart'; export 'src/social/rating.dart'; export 'src/social/rating_transport.dart'; export 'src/social/report_transport.dart'; +export 'src/social/saved_search.dart'; export 'src/social/sync_transport.dart'; export 'src/social/trust_transport.dart'; export 'src/social/web_of_trust.dart'; diff --git a/packages/commons_core/lib/src/social/saved_search.dart b/packages/commons_core/lib/src/social/saved_search.dart new file mode 100644 index 0000000..b807603 --- /dev/null +++ b/packages/commons_core/lib/src/social/saved_search.dart @@ -0,0 +1,124 @@ +import 'package:equatable/equatable.dart'; + +import 'offer.dart'; + +/// A person's saved market search — the query and facets they want to be +/// alerted about, Wallapop's "búsquedas favoritas". Generic core type: it knows +/// how to decide whether an [Offer] matches, but nothing about seeds. +/// +/// The filter chain mirrors the market screen's live filtering exactly (same +/// case-insensitive summary match, same ANDed facets), so a saved search and +/// the on-screen results can never disagree. Moderation state (blocked authors, +/// hidden offers, the user's own listings) is deliberately NOT part of a search +/// — the app layer applies that on top. +class SavedSearch extends Equatable { + const SavedSearch({ + required this.id, + required this.label, + this.query = '', + this.types = const {}, + this.categories = const {}, + this.organicOnly = false, + this.createdAt = 0, + }); + + /// Stable per-search identifier (a UUID from the app's IdGen). + final String id; + + /// Human name for the search, shown in the list (e.g. "Tomates cerca"). + final String label; + + /// Free-text filter over the offer summary; empty = no text constraint. + final String query; + + /// Reciprocity-mode facet (gift/exchange/sale/wanted); empty = any type. + final Set types; + + /// Category facet (free-text categories); empty = any category. + final Set categories; + + /// When true, only offers the grower declared organic ("eco") match. + final bool organicOnly; + + /// Creation time (ms since epoch), for stable newest-first ordering. + final int createdAt; + + /// Whether [offer] satisfies this search's query and facets (all ANDed). + /// Same semantics the market screen uses for its visible list. + bool matches(Offer offer) => matchesFilters( + offer, + query: query, + types: types, + categories: categories, + organicOnly: organicOnly, + ); + + /// The shared, pure filter chain used by both the market screen's live + /// results and saved-search alerts, so the two paths can't drift. + static bool matchesFilters( + Offer offer, { + String query = '', + Set types = const {}, + Set categories = const {}, + bool organicOnly = false, + }) { + final q = query.trim().toLowerCase(); + if (q.isNotEmpty && !offer.summary.toLowerCase().contains(q)) return false; + if (types.isNotEmpty && !types.contains(offer.type)) return false; + if (categories.isNotEmpty && + (offer.category == null || !categories.contains(offer.category))) { + return false; + } + if (organicOnly && !offer.isOrganic) return false; + return true; + } + + SavedSearch copyWith({ + String? id, + String? label, + String? query, + Set? types, + Set? categories, + bool? organicOnly, + int? createdAt, + }) { + return SavedSearch( + id: id ?? this.id, + label: label ?? this.label, + query: query ?? this.query, + types: types ?? this.types, + categories: categories ?? this.categories, + organicOnly: organicOnly ?? this.organicOnly, + createdAt: createdAt ?? this.createdAt, + ); + } + + Map toJson() => { + 'id': id, + 'label': label, + if (query.isNotEmpty) 'query': query, + if (types.isNotEmpty) 'types': [for (final t in types) t.name], + if (categories.isNotEmpty) 'categories': categories.toList(), + if (organicOnly) 'organicOnly': true, + 'createdAt': createdAt, + }; + + factory SavedSearch.fromJson(Map json) => SavedSearch( + id: json['id'] as String, + label: json['label'] as String, + query: json['query'] as String? ?? '', + types: { + for (final t in (json['types'] as List? ?? const [])) + OfferType.values.byName(t as String), + }, + categories: { + for (final c in (json['categories'] as List? ?? const [])) c as String, + }, + organicOnly: json['organicOnly'] as bool? ?? false, + createdAt: json['createdAt'] as int? ?? 0, + ); + + @override + List get props => + [id, label, query, types, categories, organicOnly, createdAt]; +} diff --git a/packages/commons_core/test/social/saved_search_test.dart b/packages/commons_core/test/social/saved_search_test.dart new file mode 100644 index 0000000..5a6e339 --- /dev/null +++ b/packages/commons_core/test/social/saved_search_test.dart @@ -0,0 +1,116 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:test/test.dart'; + +void main() { + Offer offer({ + String summary = 'Heirloom tomato seeds', + OfferType type = OfferType.gift, + String? category = 'Solanaceae', + bool isOrganic = false, + }) => + Offer( + id: 'o1', + authorPubkeyHex: 'author1', + summary: summary, + type: type, + approxGeohash: 'u09', + category: category, + isOrganic: isOrganic, + ); + + SavedSearch search({ + String query = '', + Set types = const {}, + Set categories = const {}, + bool organicOnly = false, + }) => + SavedSearch( + id: 's1', + label: 'test', + query: query, + types: types, + categories: categories, + organicOnly: organicOnly, + ); + + group('matches', () { + test('empty search matches any active offer', () { + expect(search().matches(offer()), isTrue); + }); + + test('text query matches case-insensitively over the summary', () { + expect(search(query: 'TOMATO').matches(offer()), isTrue); + expect(search(query: ' tomato ').matches(offer()), isTrue); + expect(search(query: 'pepper').matches(offer()), isFalse); + }); + + test('type facet filters by reciprocity mode', () { + expect(search(types: {OfferType.gift}).matches(offer()), isTrue); + expect(search(types: {OfferType.sale}).matches(offer()), isFalse); + expect( + search(types: {OfferType.gift, OfferType.exchange}).matches(offer()), + isTrue, + ); + }); + + test('category facet requires a matching category', () { + expect(search(categories: {'Solanaceae'}).matches(offer()), isTrue); + expect(search(categories: {'Fabaceae'}).matches(offer()), isFalse); + expect( + search(categories: {'Solanaceae'}).matches(offer(category: null)), + isFalse, + ); + }); + + test('organicOnly requires a self-declared organic offer', () { + expect(search(organicOnly: true).matches(offer(isOrganic: true)), isTrue); + expect(search(organicOnly: true).matches(offer(isOrganic: false)), isFalse); + expect(search().matches(offer(isOrganic: false)), isTrue); + }); + + test('facets are ANDed together', () { + final s = search( + query: 'tomato', + types: {OfferType.gift}, + categories: {'Solanaceae'}, + organicOnly: true, + ); + expect(s.matches(offer(isOrganic: true)), isTrue); + // One facet off is enough to reject. + expect(s.matches(offer(isOrganic: false)), isFalse); + expect(s.matches(offer(type: OfferType.sale, isOrganic: true)), isFalse); + }); + + test('matchesFilters agrees with the instance matcher', () { + expect( + SavedSearch.matchesFilters(offer(), query: 'tomato'), + search(query: 'tomato').matches(offer()), + ); + }); + }); + + group('json', () { + test('round-trips all fields', () { + final s = SavedSearch( + id: 's1', + label: 'Tomates cerca', + query: 'tomate', + types: {OfferType.gift, OfferType.exchange}, + categories: {'Solanaceae', 'Fabaceae'}, + organicOnly: true, + createdAt: 1720000000000, + ); + expect(SavedSearch.fromJson(s.toJson()), s); + }); + + test('round-trips a minimal search (empty facets)', () { + final s = SavedSearch(id: 's2', label: 'all', createdAt: 42); + final decoded = SavedSearch.fromJson(s.toJson()); + expect(decoded, s); + expect(decoded.query, isEmpty); + expect(decoded.types, isEmpty); + expect(decoded.categories, isEmpty); + expect(decoded.organicOnly, isFalse); + }); + }); +}