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.
This commit is contained in:
parent
f6967e8cbb
commit
d6225eed38
19 changed files with 806 additions and 3 deletions
123
apps/app_seeds/lib/services/saved_offers_store.dart
Normal file
123
apps/app_seeds/lib/services/saved_offers_store.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue