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 = ''; } }