import 'dart:async'; import 'dart:convert'; import 'package:commons_core/commons_core.dart'; import '../security/secret_store.dart'; /// A person's saved ("favorite") market offers — other people's listings they /// bookmarked, Wallapop-style. Keystore-backed (no plaintext at rest) and /// namespaced by the active identity's [accountScope], like [ProfileStore]. /// /// We keep a *snapshot* of each offer (not just its coordinate) so the Favorites /// list renders offline, and so a since-withdrawn offer can still be shown as /// "no longer available" instead of vanishing. The stable key is the offer's /// addressable coordinate `authorPubkeyHex:id` ([keyOf]). class SavedOffersStore { SavedOffersStore(this._store, {String accountScope = ''}) : _key = accountScope.isEmpty ? 'tane.social.saved_offers' : 'tane.social.$accountScope.saved_offers'; final SecretStore _store; final String _key; final _changes = StreamController.broadcast(); /// Emits whenever the saved set changes, so open screens can refresh. Stream get changes => _changes.stream; /// The stable per-offer key: its NIP-99 addressable coordinate without the /// kind (`authorPubkeyHex:id`). Same offer → same key on every device. static String keyOf(Offer offer) => '${offer.authorPubkeyHex}:${offer.id}'; /// Every saved offer, newest first (by save time). Future> list() async { final raw = await _store.read(_key); if (raw == null || raw.isEmpty) return const []; final entries = (jsonDecode(raw) as List) .cast>() .toList() ..sort((a, b) => (b['savedAt'] as int).compareTo(a['savedAt'] as int)); return [for (final e in entries) _decode(e)]; } /// Whether [key] (from [keyOf]) is currently saved. Future isSaved(String key) async => (await _rawList()).any((e) => e['key'] == key); /// Saves [offer] (idempotent on its key). Newer snapshot replaces an older one. Future save(Offer offer, {required int savedAt}) async { final key = keyOf(offer); final list = await _rawList()..removeWhere((e) => e['key'] == key); list.add({..._encode(offer), 'key': key, 'savedAt': savedAt}); await _writeRaw(list); } /// Removes the saved offer with [key] (from [keyOf]); no-op if absent. Future remove(String key) async { final list = await _rawList()..removeWhere((e) => e['key'] == key); await _writeRaw(list); } /// Toggles [offer]'s saved state; returns the new state (true = now saved). Future toggle(Offer offer, {required int savedAt}) async { if (await isSaved(keyOf(offer))) { await remove(keyOf(offer)); return false; } await save(offer, savedAt: savedAt); return true; } Future>> _rawList() async { final raw = await _store.read(_key); if (raw == null || raw.isEmpty) return []; return (jsonDecode(raw) as List).cast>().toList(); } Future _writeRaw(List> list) async { await _store.write(_key, jsonEncode(list)); if (!_changes.isClosed) _changes.add(null); } Map _encode(Offer o) => { 'id': o.id, 'author': o.authorPubkeyHex, 'summary': o.summary, 'type': o.type.name, 'status': o.status.name, if (o.category != null) 'category': o.category, 'geohash': o.approxGeohash, if (o.radiusKm != null) 'radiusKm': o.radiusKm, if (o.priceAmount != null) 'priceAmount': o.priceAmount, if (o.priceCurrency != null) 'priceCurrency': o.priceCurrency, if (o.exchangeTerms != null) 'exchangeTerms': o.exchangeTerms, if (o.imageUrl != null) 'imageUrl': o.imageUrl, if (o.expiresAt != null) 'expiresAt': o.expiresAt!.millisecondsSinceEpoch, 'isOrganic': o.isOrganic, }; Offer _decode(Map e) => Offer( id: e['id'] as String, authorPubkeyHex: e['author'] as String, summary: e['summary'] as String, type: OfferType.values.byName(e['type'] as String), status: OfferLifecycle.values.byName(e['status'] as String? ?? 'active'), category: e['category'] as String?, approxGeohash: e['geohash'] as String? ?? '', radiusKm: e['radiusKm'] as int?, priceAmount: e['priceAmount'] as num?, priceCurrency: e['priceCurrency'] as String?, exchangeTerms: e['exchangeTerms'] as String?, imageUrl: e['imageUrl'] as String?, expiresAt: e['expiresAt'] == null ? null : DateTime.fromMillisecondsSinceEpoch(e['expiresAt'] as int), isOrganic: e['isOrganic'] as bool? ?? false, ); Future close() async { await _changes.close(); } }