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