From d6225eed383f6ca72e76d592b71cac76bea7178b Mon Sep 17 00:00:00 2001 From: vjrj Date: Mon, 13 Jul 2026 08:21:43 +0200 Subject: [PATCH] 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. --- apps/app_seeds/lib/app.dart | 17 ++ apps/app_seeds/lib/bootstrap.dart | 2 + apps/app_seeds/lib/di/injector.dart | 9 + apps/app_seeds/lib/i18n/ast.i18n.json | 7 + apps/app_seeds/lib/i18n/en.i18n.json | 7 + apps/app_seeds/lib/i18n/es.i18n.json | 7 + apps/app_seeds/lib/i18n/pt.i18n.json | 7 + apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 20 ++ apps/app_seeds/lib/i18n/strings_en.g.dart | 32 +++ apps/app_seeds/lib/i18n/strings_es.g.dart | 22 ++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 20 ++ .../lib/services/saved_offers_store.dart | 123 +++++++++ apps/app_seeds/lib/ui/app_drawer.dart | 6 + apps/app_seeds/lib/ui/favorites_screen.dart | 257 ++++++++++++++++++ .../lib/ui/market_offer_detail_screen.dart | 45 ++- .../services/saved_offers_store_test.dart | 100 +++++++ .../test/ui/favorites_screen_test.dart | 77 ++++++ .../ui/market_offer_detail_screen_test.dart | 47 ++++ 19 files changed, 806 insertions(+), 3 deletions(-) create mode 100644 apps/app_seeds/lib/services/saved_offers_store.dart create mode 100644 apps/app_seeds/lib/ui/favorites_screen.dart create mode 100644 apps/app_seeds/test/services/saved_offers_store_test.dart create mode 100644 apps/app_seeds/test/ui/favorites_screen_test.dart diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 1df43c4..213286f 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -17,6 +17,7 @@ import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; import 'services/profile_cache.dart'; import 'services/profile_store.dart'; +import 'services/saved_offers_store.dart'; import 'services/social_account_store.dart'; import 'services/social_connection.dart'; import 'services/social_service.dart'; @@ -28,6 +29,7 @@ import 'ui/auto_backup_gate.dart'; import 'ui/calendar_screen.dart'; import 'ui/chat_list_screen.dart'; import 'ui/chat_screen.dart'; +import 'ui/favorites_screen.dart'; import 'ui/home_screen.dart'; import 'ui/intro_screen.dart'; import 'ui/inventory_list_screen.dart'; @@ -58,6 +60,7 @@ class TaneApp extends StatelessWidget { this.messageStore, this.profileStore, this.profileCache, + this.savedOffers, this.socialAccounts, this.inbox, this.notifications, @@ -76,6 +79,7 @@ class TaneApp extends StatelessWidget { messageStore, profileStore, profileCache, + savedOffers, socialAccounts, inbox, ) { @@ -112,6 +116,9 @@ class TaneApp extends StatelessWidget { /// Optional cache of peers' published display names. final ProfileCache? profileCache; + /// Optional store of the user's saved ("favorite") market offers. + final SavedOffersStore? savedOffers; + /// Optional store of the active social identity, for the profile switcher. final SocialAccountStore? socialAccounts; @@ -140,6 +147,7 @@ class TaneApp extends StatelessWidget { MessageStore? messageStore, ProfileStore? profileStore, ProfileCache? profileCache, + SavedOffersStore? savedOffers, SocialAccountStore? socialAccounts, InboxService? inbox, ) { @@ -173,9 +181,18 @@ class TaneApp extends StatelessWidget { mine: offer.authorPubkeyHex == social.publicKeyHex, profileCache: profileCache, selfPubkey: social.publicKeyHex, + savedOffers: savedOffers, ); }, ), + if (social != null && savedOffers != null) + GoRoute( + path: '/favorites', + builder: (context, state) => FavoritesScreen( + savedOffers: savedOffers, + connection: connection, + ), + ), if (messageStore != null) GoRoute( path: '/messages', diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index b2cfca0..f6b5050 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -18,6 +18,7 @@ import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; import 'services/profile_cache.dart'; import 'services/profile_store.dart'; +import 'services/saved_offers_store.dart'; import 'services/social_account_store.dart'; import 'services/social_connection.dart'; import 'services/social_service.dart'; @@ -85,6 +86,7 @@ class _BootstrapState extends State { messageStore: getIt(), profileStore: getIt(), profileCache: getIt(), + savedOffers: getIt(), socialAccounts: getIt(), inbox: inbox, notifications: notifications, diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 4e68987..e2f3084 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -39,6 +39,7 @@ import '../services/message_store.dart'; import '../services/offer_outbox.dart'; import '../services/profile_cache.dart'; import '../services/profile_store.dart'; +import '../services/saved_offers_store.dart'; import '../services/social_account_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; @@ -184,6 +185,9 @@ Future configureDependencies() async { ..registerSingleton( ProfileCache(secretStore, accountScope: scope), ) + ..registerSingleton( + SavedOffersStore(secretStore, accountScope: scope), + ) ..registerSingleton( ExportImportService( repository: varietyRepository, @@ -301,6 +305,8 @@ Future switchSocialAccount(int account) async { await getIt.unregister(); await getIt.unregister(); await getIt.unregister(); + await getIt().close(); + await getIt.unregister(); // Re-register the per-identity stores under the new scope, then the identity. getIt @@ -313,6 +319,9 @@ Future switchSocialAccount(int account) async { ..registerSingleton( ProfileCache(secretStore, accountScope: scope), ) + ..registerSingleton( + SavedOffersStore(secretStore, accountScope: scope), + ) ..registerSingleton( UnreadService(getIt(), secretStore), ); diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index b1c804d..3ca654b 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -5,6 +5,13 @@ "illustration": "O escueyi un dibuxu", "remove": "Quitar" }, + "favorites": { + "title": "Favoritos", + "empty": "Entá nun tienes favoritos. Guarda ofertes que te presten del mercáu.", + "save": "Guardar en favoritos", + "remove": "Quitar de favoritos", + "unavailable": "Yá nun ta disponible" + }, "seedSaving": { "title": "Guardar la so semiente", "subtitle": "Lo que fai falta pa caltener la variedá fiel", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 871b9f5..199a70c 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -5,6 +5,13 @@ "illustration": "Or pick an illustration", "remove": "Remove" }, + "favorites": { + "title": "Favorites", + "empty": "No favorites yet. Save offers you like from the market.", + "save": "Save to favorites", + "remove": "Remove from favorites", + "unavailable": "No longer available" + }, "seedSaving": { "title": "Saving its seed", "subtitle": "What it takes to keep the variety true", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 6089056..4faedad 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -5,6 +5,13 @@ "illustration": "O elige un dibujo", "remove": "Quitar" }, + "favorites": { + "title": "Favoritos", + "empty": "Aún no tienes favoritos. Guarda ofertas que te gusten del mercado.", + "save": "Guardar en favoritos", + "remove": "Quitar de favoritos", + "unavailable": "Ya no está disponible" + }, "seedSaving": { "title": "Conservar su semilla", "subtitle": "Lo que hace falta para mantener la variedad fiel", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 0108396..ff143dc 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -5,6 +5,13 @@ "illustration": "Ou escolhe um desenho", "remove": "Remover" }, + "favorites": { + "title": "Favoritos", + "empty": "Ainda não tens favoritos. Guarda ofertas que gostes do mercado.", + "save": "Guardar nos favoritos", + "remove": "Remover dos favoritos", + "unavailable": "Já não está disponível" + }, "seedSaving": { "title": "Guardar a sua semente", "subtitle": "O que é preciso para manter a variedade fiel", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 4020c6a..90499e6 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 2032 (508 per locale) +/// Strings: 2052 (513 per locale) /// -/// Built on 2026-07-12 at 10:59 UTC +/// Built on 2026-07-13 at 05:54 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index f6f3252..2b3228d 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -40,6 +40,7 @@ class TranslationsAst extends Translations with BaseTranslations 'Quitar'; } +// Path: favorites +class _Translations$favorites$ast extends Translations$favorites$en { + _Translations$favorites$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Favoritos'; + @override String get empty => 'Entá nun tienes favoritos. Guarda ofertes que te presten del mercáu.'; + @override String get save => 'Guardar en favoritos'; + @override String get remove => 'Quitar de favoritos'; + @override String get unavailable => 'Yá nun ta disponible'; +} + // Path: seedSaving class _Translations$seedSaving$ast extends Translations$seedSaving$en { _Translations$seedSaving$ast._(TranslationsAst root) : this._root = root, super.internal(root); @@ -1312,6 +1327,11 @@ extension on TranslationsAst { 'avatar.fromPhoto' => 'Facer o escoyer una semeya', 'avatar.illustration' => 'O escueyi un dibuxu', 'avatar.remove' => 'Quitar', + 'favorites.title' => 'Favoritos', + 'favorites.empty' => 'Entá nun tienes favoritos. Guarda ofertes que te presten del mercáu.', + 'favorites.save' => 'Guardar en favoritos', + 'favorites.remove' => 'Quitar de favoritos', + 'favorites.unavailable' => 'Yá nun ta disponible', 'seedSaving.title' => 'Guardar la so semiente', 'seedSaving.subtitle' => 'Lo que fai falta pa caltener la variedá fiel', 'seedSaving.lifeCycle' => 'Ciclu', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 17c15e6..f481b6b 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -41,6 +41,7 @@ class Translations with BaseTranslations { // Translations late final Translations$avatar$en avatar = Translations$avatar$en.internal(_root); + late final Translations$favorites$en favorites = Translations$favorites$en.internal(_root); late final Translations$seedSaving$en seedSaving = Translations$seedSaving$en.internal(_root); late final Translations$calendar$en calendar = Translations$calendar$en.internal(_root); late final Translations$app$en app = Translations$app$en.internal(_root); @@ -108,6 +109,30 @@ class Translations$avatar$en { String get remove => 'Remove'; } +// Path: favorites +class Translations$favorites$en { + Translations$favorites$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Favorites' + String get title => 'Favorites'; + + /// en: 'No favorites yet. Save offers you like from the market.' + String get empty => 'No favorites yet. Save offers you like from the market.'; + + /// en: 'Save to favorites' + String get save => 'Save to favorites'; + + /// en: 'Remove from favorites' + String get remove => 'Remove from favorites'; + + /// en: 'No longer available' + String get unavailable => 'No longer available'; +} + // Path: seedSaving class Translations$seedSaving$en { Translations$seedSaving$en.internal(this._root); @@ -2315,6 +2340,11 @@ extension on Translations { 'avatar.fromPhoto' => 'Take or choose a photo', 'avatar.illustration' => 'Or pick an illustration', 'avatar.remove' => 'Remove', + 'favorites.title' => 'Favorites', + 'favorites.empty' => 'No favorites yet. Save offers you like from the market.', + 'favorites.save' => 'Save to favorites', + 'favorites.remove' => 'Remove from favorites', + 'favorites.unavailable' => 'No longer available', 'seedSaving.title' => 'Saving its seed', 'seedSaving.subtitle' => 'What it takes to keep the variety true', 'seedSaving.lifeCycle' => 'Cycle', @@ -2818,6 +2848,8 @@ extension on Translations { 'sale.currencyHint' => '€, Ğ1, hours… (optional)', 'sale.hours' => 'hours', 'sale.note' => 'Note (optional)', + _ => null, + } ?? switch (path) { 'sale.save' => 'Save', 'sale.delete' => 'Remove', 'sale.removeConfirm' => 'Remove this sale?', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 809aab1..083336b 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -40,6 +40,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Quitar'; } +// Path: favorites +class _Translations$favorites$es extends Translations$favorites$en { + _Translations$favorites$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Favoritos'; + @override String get empty => 'Aún no tienes favoritos. Guarda ofertas que te gusten del mercado.'; + @override String get save => 'Guardar en favoritos'; + @override String get remove => 'Quitar de favoritos'; + @override String get unavailable => 'Ya no está disponible'; +} + // Path: seedSaving class _Translations$seedSaving$es extends Translations$seedSaving$en { _Translations$seedSaving$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1314,6 +1329,11 @@ extension on TranslationsEs { 'avatar.fromPhoto' => 'Hacer o elegir una foto', 'avatar.illustration' => 'O elige un dibujo', 'avatar.remove' => 'Quitar', + 'favorites.title' => 'Favoritos', + 'favorites.empty' => 'Aún no tienes favoritos. Guarda ofertas que te gusten del mercado.', + 'favorites.save' => 'Guardar en favoritos', + 'favorites.remove' => 'Quitar de favoritos', + 'favorites.unavailable' => 'Ya no está disponible', 'seedSaving.title' => 'Conservar su semilla', 'seedSaving.subtitle' => 'Lo que hace falta para mantener la variedad fiel', 'seedSaving.lifeCycle' => 'Ciclo', @@ -1817,6 +1837,8 @@ extension on TranslationsEs { 'sale.hours' => 'horas', 'sale.note' => 'Nota (opcional)', 'sale.save' => 'Guardar', + _ => null, + } ?? switch (path) { 'sale.delete' => 'Quitar', 'sale.removeConfirm' => '¿Quitar esta venta?', _ => null, diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 92196ca..611c653 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -40,6 +40,7 @@ class TranslationsPt extends Translations with BaseTranslations 'Remover'; } +// Path: favorites +class _Translations$favorites$pt extends Translations$favorites$en { + _Translations$favorites$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Favoritos'; + @override String get empty => 'Ainda não tens favoritos. Guarda ofertas que gostes do mercado.'; + @override String get save => 'Guardar nos favoritos'; + @override String get remove => 'Remover dos favoritos'; + @override String get unavailable => 'Já não está disponível'; +} + // Path: seedSaving class _Translations$seedSaving$pt extends Translations$seedSaving$en { _Translations$seedSaving$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1311,6 +1326,11 @@ extension on TranslationsPt { 'avatar.fromPhoto' => 'Tirar ou escolher uma foto', 'avatar.illustration' => 'Ou escolhe um desenho', 'avatar.remove' => 'Remover', + 'favorites.title' => 'Favoritos', + 'favorites.empty' => 'Ainda não tens favoritos. Guarda ofertas que gostes do mercado.', + 'favorites.save' => 'Guardar nos favoritos', + 'favorites.remove' => 'Remover dos favoritos', + 'favorites.unavailable' => 'Já não está disponível', 'seedSaving.title' => 'Guardar a sua semente', 'seedSaving.subtitle' => 'O que é preciso para manter a variedade fiel', 'seedSaving.lifeCycle' => 'Ciclo', diff --git a/apps/app_seeds/lib/services/saved_offers_store.dart b/apps/app_seeds/lib/services/saved_offers_store.dart new file mode 100644 index 0000000..b168d70 --- /dev/null +++ b/apps/app_seeds/lib/services/saved_offers_store.dart @@ -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.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(); + } +} diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index a890885..28d9264 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -103,6 +103,12 @@ class AppDrawer extends StatelessWidget { _DrawerItem( icon: const Icon(Icons.favorite), label: t.menu.wishlist, + onTap: marketEnabled + ? () { + Navigator.of(context).pop(); + context.push('/favorites'); + } + : null, ), // The ego-centric web of trust ("your people") — live once the // social layer is on. diff --git a/apps/app_seeds/lib/ui/favorites_screen.dart b/apps/app_seeds/lib/ui/favorites_screen.dart new file mode 100644 index 0000000..7a5af9a --- /dev/null +++ b/apps/app_seeds/lib/ui/favorites_screen.dart @@ -0,0 +1,257 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../i18n/strings.g.dart'; +import '../services/saved_offers_store.dart'; +import '../services/social_connection.dart'; +import 'market_widgets.dart'; +import 'theme.dart'; + +/// The Wallapop-style "Favorites": offers from other people the user saved. +/// Shows the saved snapshots offline-first; when a relay is reachable it checks +/// which ones are still published and flags the rest as no longer available +/// (they may have been withdrawn or expired). Tapping opens the offer detail. +class FavoritesScreen extends StatefulWidget { + const FavoritesScreen({ + required this.savedOffers, + this.connection, + super.key, + }); + + final SavedOffersStore savedOffers; + + /// The shared relay connection, used to check whether saved offers still + /// exist. Null (tests / no social layer) → no check, snapshots shown as-is. + final SocialConnection? connection; + + @override + State createState() => _FavoritesScreenState(); +} + +class _FavoritesScreenState extends State { + List? _offers; + var _liveKeys = {}; + var _checked = false; + StreamSubscription? _changesSub; + + @override + void initState() { + super.initState(); + _changesSub = widget.savedOffers.changes.listen((_) => _load()); + _load(); + } + + @override + void dispose() { + _changesSub?.cancel(); + super.dispose(); + } + + Future _load() async { + final offers = await widget.savedOffers.list(); + if (!mounted) return; + setState(() => _offers = offers); + unawaited(_check(offers)); + } + + /// Asks the relay which saved offers are still out there. Offline (no session) + /// leaves [_checked] false, so nothing is wrongly flagged as gone. + Future _check(List offers) async { + final connection = widget.connection; + if (connection == null || offers.isEmpty) return; + final session = await connection.session(); + if (session == null || !mounted) return; + + final live = {}; + final geohashes = { + for (final o in offers) + if (o.approxGeohash.isNotEmpty) o.approxGeohash, + }; + final subs = >[]; + for (final geohash in geohashes) { + subs.add( + session.offers + .discover(DiscoveryQuery(geohashPrefix: geohash)) + .listen((o) => live.add(SavedOffersStore.keyOf(o)), onError: (_) {}), + ); + } + // The discover streams stay open for live offers and never signal "done"; + // give them a beat to deliver what's stored, then take stock. + await Future.delayed(const Duration(seconds: 5)); + for (final s in subs) { + unawaited(s.cancel()); + } + if (!mounted) return; + setState(() { + _liveKeys = live; + _checked = true; + }); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final offers = _offers; + return Scaffold( + appBar: AppBar(title: Text(t.favorites.title)), + body: offers == null + ? const Center(child: CircularProgressIndicator()) + : offers.isEmpty + ? _Empty(text: t.favorites.empty) + : ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: offers.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, i) { + final offer = offers[i]; + final key = SavedOffersStore.keyOf(offer); + // Only call something gone once a check actually ran and didn't + // see it — never while offline (checked == false). + final gone = _checked && !_liveKeys.contains(key); + return _FavoriteCard( + offer: offer, + unavailable: gone, + onOpen: () => context.push('/market/offer', extra: offer), + onRemove: () => widget.savedOffers.remove(key), + ); + }, + ), + ); + } +} + +class _FavoriteCard extends StatelessWidget { + const _FavoriteCard({ + required this.offer, + required this.unavailable, + required this.onOpen, + required this.onRemove, + }); + + final Offer offer; + final bool unavailable; + final VoidCallback onOpen; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final t = context.t; + final radius = BorderRadius.circular(14); + return Material( + color: Colors.white, + borderRadius: radius, + child: InkWell( + key: Key('favorites.offer.${offer.authorPubkeyHex}.${offer.id}'), + onTap: onOpen, + borderRadius: radius, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all(color: seedOutline), + ), + padding: const EdgeInsets.all(16), + child: Opacity( + opacity: unavailable ? 0.55 : 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (offer.imageUrl != null) ...[ + OfferThumbnail( + url: offer.imageUrl!, + semanticLabel: t.market.photo, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + offer.summary, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + color: seedOnSurface, + ), + ), + ), + IconButton( + key: Key('favorites.remove.${offer.authorPubkeyHex}' + '.${offer.id}'), + icon: const Icon(Icons.favorite, color: seedGreen), + tooltip: t.favorites.remove, + onPressed: onRemove, + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + if (unavailable) + Row( + children: [ + const Icon(Icons.cloud_off, + size: 15, color: seedMuted), + const SizedBox(width: 4), + Text( + t.favorites.unavailable, + style: + const TextStyle(color: seedMuted, fontSize: 13), + ), + ], + ) + else + OfferTypeChip(label: offerTypeLabel(t, offer.type)), + const Spacer(), + if (offer.type == OfferType.sale && offer.priceAmount != null) + Text( + '${offer.priceAmount} ${offer.priceCurrency ?? ''}' + .trim(), + style: const TextStyle( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.favorite_border, + size: 64, + color: seedGreen.withValues(alpha: 0.5), + ), + const SizedBox(height: 16), + Text( + text, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart index d078830..9cacebe 100644 --- a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart +++ b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import '../i18n/strings.g.dart'; import '../services/profile_cache.dart'; +import '../services/saved_offers_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; import '../state/peer_rating_cubit.dart'; @@ -27,6 +28,7 @@ class MarketOfferDetailScreen extends StatefulWidget { this.mine = false, this.profileCache, this.selfPubkey, + this.savedOffers, super.key, }); @@ -38,6 +40,10 @@ class MarketOfferDetailScreen extends StatefulWidget { /// Whether this is the current user's own listing (hide the message button). final bool mine; + /// Store of the user's saved ("favorite") offers, for the save toggle. Null in + /// tests / when there's no social layer → the heart action is hidden. + final SavedOffersStore? savedOffers; + /// Optional cache of peers' published display names; null in tests. final ProfileCache? profileCache; @@ -58,10 +64,32 @@ class _MarketOfferDetailScreenState extends State { bool _profileLoading = true; PeerRatingState? _sellerRating; + /// Whether this offer is in the user's favorites; null while loading (the + /// heart action stays hidden until we know, to avoid a flicker). + bool? _saved; + @override void initState() { super.initState(); _loadSeller(); + _loadSaved(); + } + + Future _loadSaved() async { + final store = widget.savedOffers; + if (store == null) return; + final saved = await store.isSaved(SavedOffersStore.keyOf(widget.offer)); + if (mounted) setState(() => _saved = saved); + } + + Future _toggleSaved() async { + final store = widget.savedOffers; + if (store == null) return; + final nowSaved = await store.toggle( + widget.offer, + savedAt: DateTime.now().millisecondsSinceEpoch, + ); + if (mounted) setState(() => _saved = nowSaved); } /// Best-effort fetch of the author's published profile (name/about/Ğ1), the @@ -145,8 +173,23 @@ class _MarketOfferDetailScreenState extends State { Widget build(BuildContext context) { final t = context.t; final o = widget.offer; + final saved = _saved; return Scaffold( - appBar: AppBar(title: Text(o.summary)), + appBar: AppBar( + title: Text(o.summary), + actions: [ + // Save other people's offers (Wallapop-style). Hidden on your own + // listing and until the saved state is known. + if (!widget.mine && widget.savedOffers != null && saved != null) + IconButton( + key: const Key('offerDetail.save'), + icon: Icon(saved ? Icons.favorite : Icons.favorite_border), + color: saved ? seedGreen : null, + tooltip: saved ? t.favorites.remove : t.favorites.save, + onPressed: _toggleSaved, + ), + ], + ), body: ListView( padding: const EdgeInsets.all(16), children: [ diff --git a/apps/app_seeds/test/services/saved_offers_store_test.dart b/apps/app_seeds/test/services/saved_offers_store_test.dart new file mode 100644 index 0000000..e7d4eca --- /dev/null +++ b/apps/app_seeds/test/services/saved_offers_store_test.dart @@ -0,0 +1,100 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/saved_offers_store.dart'; + +import '../support/test_support.dart'; + +Offer _offer( + String id, { + String author = 'aa', + OfferType type = OfferType.gift, + num? price, + String? currency, + String geohash = 'u09', +}) => Offer( + id: id, + authorPubkeyHex: author, + summary: 'Tomate $id', + type: type, + approxGeohash: geohash, + priceAmount: price, + priceCurrency: currency, + isOrganic: true, +); + +void main() { + late SavedOffersStore store; + + setUp(() => store = SavedOffersStore(InMemorySecretStore())); + + test('keyOf is the author:id coordinate', () { + expect(SavedOffersStore.keyOf(_offer('x', author: 'bb')), 'bb:x'); + }); + + test('starts empty', () async { + expect(await store.list(), isEmpty); + expect(await store.isSaved('aa:x'), isFalse); + }); + + test('save then list round-trips every field', () async { + await store.save( + _offer('1', type: OfferType.sale, price: 3, currency: 'Ğ1'), + savedAt: 100, + ); + final saved = await store.list(); + expect(saved, hasLength(1)); + final o = saved.single; + expect(o.id, '1'); + expect(o.authorPubkeyHex, 'aa'); + expect(o.type, OfferType.sale); + expect(o.priceAmount, 3); + expect(o.priceCurrency, 'Ğ1'); + expect(o.isOrganic, isTrue); + expect(await store.isSaved('aa:1'), isTrue); + }); + + test('list is newest-first by savedAt', () async { + await store.save(_offer('old'), savedAt: 100); + await store.save(_offer('new'), savedAt: 200); + expect([for (final o in await store.list()) o.id], ['new', 'old']); + }); + + test('saving the same coordinate again de-duplicates', () async { + await store.save(_offer('1'), savedAt: 100); + await store.save(_offer('1'), savedAt: 200); // same author:id + expect(await store.list(), hasLength(1)); + }); + + test('remove drops it', () async { + await store.save(_offer('1'), savedAt: 100); + await store.remove('aa:1'); + expect(await store.list(), isEmpty); + expect(await store.isSaved('aa:1'), isFalse); + }); + + test('toggle saves then removes and reports the new state', () async { + expect(await store.toggle(_offer('1'), savedAt: 100), isTrue); + expect(await store.isSaved('aa:1'), isTrue); + expect(await store.toggle(_offer('1'), savedAt: 200), isFalse); + expect(await store.isSaved('aa:1'), isFalse); + }); + + test('scopes are isolated by account', () async { + final secret = InMemorySecretStore(); + final a = SavedOffersStore(secret, accountScope: 'acct1'); + final b = SavedOffersStore(secret, accountScope: 'acct2'); + await a.save(_offer('1'), savedAt: 100); + expect(await a.list(), hasLength(1)); + expect(await b.list(), isEmpty); + }); + + test('changes stream fires on save and remove', () async { + final events = []; + final sub = store.changes.listen(events.add); + await store.save(_offer('1'), savedAt: 100); + await store.remove('aa:1'); + await Future.delayed(Duration.zero); + expect(events, hasLength(2)); + await sub.cancel(); + }); +} diff --git a/apps/app_seeds/test/ui/favorites_screen_test.dart b/apps/app_seeds/test/ui/favorites_screen_test.dart new file mode 100644 index 0000000..20c1b1f --- /dev/null +++ b/apps/app_seeds/test/ui/favorites_screen_test.dart @@ -0,0 +1,77 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/saved_offers_store.dart'; +import 'package:tane/ui/favorites_screen.dart'; + +import '../support/test_support.dart'; + +Widget _wrap(Widget child) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: child, + ), + ); +} + +Offer _offer(String id) => Offer( + id: id, + authorPubkeyHex: 'ab', + summary: 'Tomate $id', + type: OfferType.gift, + approxGeohash: 'u09', +); + +void main() { + testWidgets('shows the empty state when nothing is saved', (tester) async { + final store = SavedOffersStore(InMemorySecretStore()); + // No connection → no relay check, just the local snapshots. + await tester.pumpWidget(_wrap(FavoritesScreen(savedOffers: store))); + await tester.pumpAndSettle(); + + expect( + find.text('No favorites yet. Save offers you like from the market.'), + findsOneWidget, + ); + }); + + testWidgets('lists saved offers newest-first', (tester) async { + final store = SavedOffersStore(InMemorySecretStore()); + await store.save(_offer('old'), savedAt: 100); + await store.save(_offer('new'), savedAt: 200); + + await tester.pumpWidget(_wrap(FavoritesScreen(savedOffers: store))); + await tester.pumpAndSettle(); + + expect(find.text('Tomate new'), findsOneWidget); + expect(find.text('Tomate old'), findsOneWidget); + final newY = tester.getTopLeft(find.text('Tomate new')).dy; + final oldY = tester.getTopLeft(find.text('Tomate old')).dy; + expect(newY, lessThan(oldY)); // newest on top + }); + + testWidgets('the heart removes a favorite', (tester) async { + final store = SavedOffersStore(InMemorySecretStore()); + await store.save(_offer('1'), savedAt: 100); + + await tester.pumpWidget(_wrap(FavoritesScreen(savedOffers: store))); + await tester.pumpAndSettle(); + expect(find.text('Tomate 1'), findsOneWidget); + + await tester.tap(find.byKey(const Key('favorites.remove.ab.1'))); + await tester.pumpAndSettle(); + + expect(find.text('Tomate 1'), findsNothing); + expect(await store.isSaved('ab:1'), isFalse); + }); +} diff --git a/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart b/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart index f1d90db..120d6d5 100644 --- a/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/services/saved_offers_store.dart'; import 'package:tane/services/social_connection.dart'; import 'package:tane/services/social_service.dart'; import 'package:tane/services/social_settings.dart'; @@ -103,4 +104,50 @@ void main() { expect(find.byType(OfferHeroImage), findsNothing); }); + + testWidgets('the heart saves and unsaves a stranger\'s offer', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + final connection = SocialConnection(social: social, settings: settings); + final saved = SavedOffersStore(InMemorySecretStore()); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(), + connection: connection, + savedOffers: saved, + ))); + await tester.pumpAndSettle(); + + final heart = find.byKey(const Key('offerDetail.save')); + expect(heart, findsOneWidget); + expect(await saved.isSaved('${'ab' * 32}:lot-1'), isFalse); + + await tester.tap(heart); + await tester.pumpAndSettle(); + expect(await saved.isSaved('${'ab' * 32}:lot-1'), isTrue); + + await tester.tap(heart); + await tester.pumpAndSettle(); + expect(await saved.isSaved('${'ab' * 32}:lot-1'), isFalse); + }); + + testWidgets('no heart on my own listing', (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + final connection = SocialConnection(social: social, settings: settings); + final saved = SavedOffersStore(InMemorySecretStore()); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(), + connection: connection, + mine: true, + savedOffers: saved, + ))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('offerDetail.save')), findsNothing); + }); }