tane/apps/app_seeds/lib/services/saved_offers_store.dart
vjrj d6225eed38 feat(market): save others' offers as favorites (Wallapop-style)
Adds a Favorites feature: a heart on the offer detail saves another
person's listing to an encrypted, per-identity SavedOffersStore
(keystore JSON snapshot, no plaintext at rest). A new /favorites screen
(wired from the drawer) lists saved offers offline-first and, when a
relay is reachable, flags ones that are gone as "no longer available".
i18n en/es/pt/ast; store + screen + detail-heart tests.
2026-07-13 08:21:43 +02:00

123 lines
4.6 KiB
Dart

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<void>.broadcast();
/// Emits whenever the saved set changes, so open screens can refresh.
Stream<void> 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<Offer>> list() async {
final raw = await _store.read(_key);
if (raw == null || raw.isEmpty) return const [];
final entries = (jsonDecode(raw) as List)
.cast<Map<String, dynamic>>()
.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<bool> isSaved(String key) async =>
(await _rawList()).any((e) => e['key'] == key);
/// Saves [offer] (idempotent on its key). Newer snapshot replaces an older one.
Future<void> 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<void> 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<bool> 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<List<Map<String, dynamic>>> _rawList() async {
final raw = await _store.read(_key);
if (raw == null || raw.isEmpty) return [];
return (jsonDecode(raw) as List).cast<Map<String, dynamic>>().toList();
}
Future<void> _writeRaw(List<Map<String, dynamic>> list) async {
await _store.write(_key, jsonEncode(list));
if (!_changes.isClosed) _changes.add(null);
}
Map<String, dynamic> _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<String, dynamic> 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<void> close() async {
await _changes.close();
}
}