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