From 54d7c2d3b59bf1d148a8d81000ae2cd3ab517bf0 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 19:10:40 +0200 Subject: [PATCH] feat(market): fix publish-duplicates, add offer detail page + filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vecino/market view had three gaps: - Publishing inventory duplicated the listing. discover() appended every offer the relay streamed, but relays legitimately resend an addressable NIP-99 event (stored copy + live echo on publish). Merge by (authorPubkeyHex, id) so each offer appears once; two authors reusing a lot id stay distinct. - No product detail. Tapping a card jumped straight to chat. Add a read-only "product page" (MarketOfferDetailScreen) with title, mode, category, eco badge, coarse location, price, a "Shared by" seller section (profile fetched by pubkey) and the message button. Reached via a new /market/offer route; cards are now fully tappable. Shows only what the network already carries — no photos/description added to the wire. - No filters. Add a filter bar (type, category, eco) mirroring inventory, each chip group shown only when a discovered offer can match it. To make the eco filter real, publish the organic flag on the wire: Offer.isOrganic -> NIP-99 'organic' tag -> OfferMapper -> ShareableLot. Tests: commons_core organic round-trip; app-side dedup, two-author separation, filter logic, mapper passthrough; detail-screen widget tests. i18n keys added to en/es/pt/ast and slang regenerated. --- apps/app_seeds/lib/app.dart | 16 + .../lib/data/variety_repository.dart | 9 +- apps/app_seeds/lib/i18n/ast.i18n.json | 6 +- apps/app_seeds/lib/i18n/en.i18n.json | 6 +- apps/app_seeds/lib/i18n/es.i18n.json | 6 +- apps/app_seeds/lib/i18n/pt.i18n.json | 6 +- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 18 + apps/app_seeds/lib/i18n/strings_en.g.dart | 16 + apps/app_seeds/lib/i18n/strings_es.g.dart | 8 + apps/app_seeds/lib/i18n/strings_pt.g.dart | 8 + apps/app_seeds/lib/services/offer_mapper.dart | 2 + apps/app_seeds/lib/state/offers_cubit.dart | 113 ++++++- .../lib/ui/market_offer_detail_screen.dart | 307 ++++++++++++++++++ apps/app_seeds/lib/ui/market_screen.dart | 242 ++++++++------ apps/app_seeds/lib/ui/market_widgets.dart | 41 +++ .../test/state/offers_cubit_test.dart | 164 ++++++++++ .../ui/market_offer_detail_screen_test.dart | 73 +++++ .../lib/src/social/nostr/nip99_codec.dart | 2 + .../commons_core/lib/src/social/offer.dart | 5 + .../test/social/nostr_transports_test.dart | 37 +++ 21 files changed, 982 insertions(+), 107 deletions(-) create mode 100644 apps/app_seeds/lib/ui/market_offer_detail_screen.dart create mode 100644 apps/app_seeds/lib/ui/market_widgets.dart create mode 100644 apps/app_seeds/test/ui/market_offer_detail_screen_test.dart diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index c05f58d..7eb71df 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -1,3 +1,4 @@ +import 'package:commons_core/commons_core.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -25,6 +26,7 @@ import 'ui/chat_screen.dart'; import 'ui/home_screen.dart'; import 'ui/intro_screen.dart'; import 'ui/inventory_list_screen.dart'; +import 'ui/market_offer_detail_screen.dart'; import 'ui/market_screen.dart'; import 'ui/offline_banner.dart'; import 'ui/profile_screen.dart'; @@ -123,6 +125,20 @@ class TaneApp extends StatelessWidget { outbox: outbox, ), ), + if (social != null && socialSettings != null) + GoRoute( + path: '/market/offer', + builder: (context, state) { + final offer = state.extra as Offer; + return MarketOfferDetailScreen( + offer: offer, + social: social, + settings: socialSettings, + mine: offer.authorPubkeyHex == social.publicKeyHex, + profileCache: profileCache, + ); + }, + ), if (social != null && socialSettings != null && messageStore != null) GoRoute( path: '/messages', diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 30e1ba4..f533ce8 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -128,6 +128,7 @@ class ShareableLot extends Equatable { required this.offerStatus, this.category, this.harvestYear, + this.isOrganic = false, }); final String lotId; @@ -140,8 +141,13 @@ class ShareableLot extends Equatable { final String? category; final int? harvestYear; + /// The variety's grower-declared organic ("eco") flag, published so peers can + /// filter the market for it. + final bool isOrganic; + @override - List get props => [lotId, summary, offerStatus, category, harvestYear]; + List get props => + [lotId, summary, offerStatus, category, harvestYear, isOrganic]; } /// One germination test on a lot; [rate] is derived (0..1), null when it can't @@ -1269,6 +1275,7 @@ class VarietyRepository { offerStatus: l.offerStatus, category: v.category, harvestYear: l.harvestYear, + isOrganic: v.isOrganic, ), ]; } diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 107d18d..679e597 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -375,7 +375,11 @@ "rangeLabel": "Hasta ónde guetar", "rangeNear": "Bien cerca", "rangeArea": "Pela mio zona", - "rangeRegion": "La mio rexón" + "rangeRegion": "La mio rexón", + "sharedBy": "Compartíu por", + "noProfile": "Esta persona entá nun compartió'l so perfil", + "copyId": "Copiar códigu", + "idCopied": "Códigu copiáu" }, "profile": { "title": "El to perfil", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index c0bd30b..5952bed 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -378,7 +378,11 @@ "rangeLabel": "How far to look", "rangeNear": "Very close", "rangeArea": "Around here", - "rangeRegion": "My region" + "rangeRegion": "My region", + "sharedBy": "Shared by", + "noProfile": "This person hasn't shared a profile yet", + "copyId": "Copy code", + "idCopied": "Code copied" }, "profile": { "title": "Your profile", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 37c64cd..16441fa 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -377,7 +377,11 @@ "rangeLabel": "Hasta dónde buscar", "rangeNear": "Muy cerca", "rangeArea": "Por mi zona", - "rangeRegion": "Mi región" + "rangeRegion": "Mi región", + "sharedBy": "Compartido por", + "noProfile": "Esta persona aún no ha compartido su perfil", + "copyId": "Copiar código", + "idCopied": "Código copiado" }, "profile": { "title": "Tu perfil", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 149e92a..2af1574 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -374,7 +374,11 @@ "rangeLabel": "Até onde procurar", "rangeNear": "Muito perto", "rangeArea": "Pela minha zona", - "rangeRegion": "A minha região" + "rangeRegion": "A minha região", + "sharedBy": "Partilhado por", + "noProfile": "Esta pessoa ainda não partilhou o seu perfil", + "copyId": "Copiar código", + "idCopied": "Código copiado" }, "profile": { "title": "O teu perfil", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 058a3c4..4bc25a1 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: 1464 (366 per locale) +/// Strings: 1480 (370 per locale) /// -/// Built on 2026-07-10 at 13:52 UTC +/// Built on 2026-07-10 at 15:39 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 e3f2d4a..fb349fc 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -645,6 +645,15 @@ class _Translations$market$ast extends Translations$market$en { @override String get useLocation => 'Usar el mio allugamientu averáu'; @override String get locationFailed => 'Nun se pudo consiguir el to allugamientu — comprueba que l\'allugamientu ta activáu y el permisu concedíu'; @override String get queued => 'Guardáu — compartirémosles cuando tengas conexón'; + @override String get shareFailed => 'Nun se pudo coneutar colos sirvidores — les tos granes nun se compartieron. Vuelvi a intentalo nun momentu.'; + @override String get rangeLabel => 'Hasta ónde guetar'; + @override String get rangeNear => 'Bien cerca'; + @override String get rangeArea => 'Pela mio zona'; + @override String get rangeRegion => 'La mio rexón'; + @override String get sharedBy => 'Compartíu por'; + @override String get noProfile => 'Esta persona entá nun compartió\'l so perfil'; + @override String get copyId => 'Copiar códigu'; + @override String get idCopied => 'Códigu copiáu'; } // Path: profile @@ -1384,6 +1393,15 @@ extension on TranslationsAst { 'market.useLocation' => 'Usar el mio allugamientu averáu', 'market.locationFailed' => 'Nun se pudo consiguir el to allugamientu — comprueba que l\'allugamientu ta activáu y el permisu concedíu', 'market.queued' => 'Guardáu — compartirémosles cuando tengas conexón', + 'market.shareFailed' => 'Nun se pudo coneutar colos sirvidores — les tos granes nun se compartieron. Vuelvi a intentalo nun momentu.', + 'market.rangeLabel' => 'Hasta ónde guetar', + 'market.rangeNear' => 'Bien cerca', + 'market.rangeArea' => 'Pela mio zona', + 'market.rangeRegion' => 'La mio rexón', + 'market.sharedBy' => 'Compartíu por', + 'market.noProfile' => 'Esta persona entá nun compartió\'l so perfil', + 'market.copyId' => 'Copiar códigu', + 'market.idCopied' => 'Códigu copiáu', 'profile.title' => 'El to perfil', 'profile.name' => 'Nome', 'profile.nameHint' => 'Cómo te ven los demás', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index adb1071..8eda815 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1199,6 +1199,18 @@ class Translations$market$en { /// en: 'My region' String get rangeRegion => 'My region'; + + /// en: 'Shared by' + String get sharedBy => 'Shared by'; + + /// en: 'This person hasn't shared a profile yet' + String get noProfile => 'This person hasn\'t shared a profile yet'; + + /// en: 'Copy code' + String get copyId => 'Copy code'; + + /// en: 'Code copied' + String get idCopied => 'Code copied'; } // Path: profile @@ -2116,6 +2128,10 @@ extension on Translations { 'market.rangeNear' => 'Very close', 'market.rangeArea' => 'Around here', 'market.rangeRegion' => 'My region', + 'market.sharedBy' => 'Shared by', + 'market.noProfile' => 'This person hasn\'t shared a profile yet', + 'market.copyId' => 'Copy code', + 'market.idCopied' => 'Code copied', 'profile.title' => 'Your profile', 'profile.name' => 'Display name', 'profile.nameHint' => 'How others see you', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index e01e6bd..63a052c 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -652,6 +652,10 @@ class _Translations$market$es extends Translations$market$en { @override String get rangeNear => 'Muy cerca'; @override String get rangeArea => 'Por mi zona'; @override String get rangeRegion => 'Mi región'; + @override String get sharedBy => 'Compartido por'; + @override String get noProfile => 'Esta persona aún no ha compartido su perfil'; + @override String get copyId => 'Copiar código'; + @override String get idCopied => 'Código copiado'; } // Path: profile @@ -1398,6 +1402,10 @@ extension on TranslationsEs { 'market.rangeNear' => 'Muy cerca', 'market.rangeArea' => 'Por mi zona', 'market.rangeRegion' => 'Mi región', + 'market.sharedBy' => 'Compartido por', + 'market.noProfile' => 'Esta persona aún no ha compartido su perfil', + 'market.copyId' => 'Copiar código', + 'market.idCopied' => 'Código copiado', 'profile.title' => 'Tu perfil', 'profile.name' => 'Nombre', 'profile.nameHint' => 'Cómo te ven los demás', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 772857f..9382072 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -649,6 +649,10 @@ class _Translations$market$pt extends Translations$market$en { @override String get rangeNear => 'Muito perto'; @override String get rangeArea => 'Pela minha zona'; @override String get rangeRegion => 'A minha região'; + @override String get sharedBy => 'Partilhado por'; + @override String get noProfile => 'Esta pessoa ainda não partilhou o seu perfil'; + @override String get copyId => 'Copiar código'; + @override String get idCopied => 'Código copiado'; } // Path: profile @@ -1392,6 +1396,10 @@ extension on TranslationsPt { 'market.rangeNear' => 'Muito perto', 'market.rangeArea' => 'Pela minha zona', 'market.rangeRegion' => 'A minha região', + 'market.sharedBy' => 'Partilhado por', + 'market.noProfile' => 'Esta pessoa ainda não partilhou o seu perfil', + 'market.copyId' => 'Copiar código', + 'market.idCopied' => 'Código copiado', 'profile.title' => 'O teu perfil', 'profile.name' => 'Nome', 'profile.nameHint' => 'Como os outros te veem', diff --git a/apps/app_seeds/lib/services/offer_mapper.dart b/apps/app_seeds/lib/services/offer_mapper.dart index 9d64233..d09b500 100644 --- a/apps/app_seeds/lib/services/offer_mapper.dart +++ b/apps/app_seeds/lib/services/offer_mapper.dart @@ -29,6 +29,7 @@ class OfferMapper { required OfferStatus sharing, required String areaGeohash, String? category, + bool isOrganic = false, num? priceAmount, String? priceCurrency, String? exchangeTerms, @@ -43,6 +44,7 @@ class OfferMapper { type: type, approxGeohash: areaGeohash, category: category, + isOrganic: isOrganic, priceAmount: type == OfferType.sale ? priceAmount : null, priceCurrency: type == OfferType.sale ? priceCurrency : null, exchangeTerms: type == OfferType.exchange ? exchangeTerms : null, diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index 979c5fc..021def8 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -17,6 +17,9 @@ class OffersState extends Equatable { this.offers = const [], this.areaGeohash = '', this.query = '', + this.typeFilter = const {}, + this.categoryFilter = const {}, + this.organicOnly = false, this.searching = false, this.publishing = false, this.hasSearched = false, @@ -32,6 +35,15 @@ class OffersState extends Equatable { /// Free-text filter over [offers] typed in the search box (empty = show all). final String query; + /// Active reciprocity-mode filter (gift/exchange/sale/wanted); empty = all. + final Set typeFilter; + + /// Active category filter (free-text categories); empty = all. + final Set categoryFilter; + + /// When true, show only offers the grower declared organic ("eco"). + final bool organicOnly; + final bool searching; final bool publishing; @@ -42,18 +54,47 @@ class OffersState extends Equatable { /// Last error, in human terms for the UI (null when fine). final String? error; - /// [offers] narrowed by [query], matched against the offer summary. Kept - /// separate from [offers] so a search never drops the underlying discoveries. + /// [offers] narrowed by the text [query] and the chip filters (all ANDed). + /// Kept separate from [offers] so filtering never drops the discoveries. List get visibleOffers { final q = query.trim().toLowerCase(); - if (q.isEmpty) return offers; - return offers.where((o) => o.summary.toLowerCase().contains(q)).toList(); + return offers.where((o) { + if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false; + if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false; + if (categoryFilter.isNotEmpty && + (o.category == null || !categoryFilter.contains(o.category))) { + return false; + } + if (organicOnly && !o.isOrganic) return false; + return true; + }).toList(); } + /// The distinct categories present across discovered [offers], sorted, so the + /// UI only offers a category chip when some offer carries it. + List get categories { + final set = { + for (final o in offers) + if (o.category != null && o.category!.isNotEmpty) o.category!, + }; + final list = set.toList()..sort(); + return list; + } + + /// Whether any discovered offer declares organic, so the eco chip only shows + /// when it can match something. + bool get hasOrganic => offers.any((o) => o.isOrganic); + + bool get hasActiveFilter => + typeFilter.isNotEmpty || categoryFilter.isNotEmpty || organicOnly; + OffersState copyWith({ List? offers, String? areaGeohash, String? query, + Set? typeFilter, + Set? categoryFilter, + bool? organicOnly, bool? searching, bool? publishing, bool? hasSearched, @@ -63,6 +104,9 @@ class OffersState extends Equatable { offers: offers ?? this.offers, areaGeohash: areaGeohash ?? this.areaGeohash, query: query ?? this.query, + typeFilter: typeFilter ?? this.typeFilter, + categoryFilter: categoryFilter ?? this.categoryFilter, + organicOnly: organicOnly ?? this.organicOnly, searching: searching ?? this.searching, publishing: publishing ?? this.publishing, hasSearched: hasSearched ?? this.hasSearched, @@ -71,8 +115,18 @@ class OffersState extends Equatable { } @override - List get props => - [offers, areaGeohash, query, searching, publishing, hasSearched, error]; + List get props => [ + offers, + areaGeohash, + query, + typeFilter, + categoryFilter, + organicOnly, + searching, + publishing, + hasSearched, + error, + ]; } /// Drives offer discovery and publishing over an [OfferTransport]. Depends on @@ -106,13 +160,17 @@ class OffersCubit extends Cubit { _searchTimeout?.cancel(); emit(OffersState( areaGeohash: geohashPrefix, - query: state.query, // keep the text filter across a refresh + // Keep the text and chip filters across a refresh (only the results reset). + query: state.query, + typeFilter: state.typeFilter, + categoryFilter: state.categoryFilter, + organicOnly: state.organicOnly, searching: true, hasSearched: true, )); _sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen( (offer) => - emit(state.copyWith(offers: [...state.offers, offer], searching: false)), + emit(state.copyWith(offers: _merge(state.offers, offer), searching: false)), onError: (Object e) => emit(state.copyWith(searching: false, error: () => '$e')), ); @@ -126,10 +184,48 @@ class OffersCubit extends Cubit { }); } + /// Appends [incoming] to [current], replacing any existing offer with the same + /// author + id. Relays legitimately resend addressable events (a stored copy + /// plus a live echo after publishing), so a plain append would double the + /// listing; keeping one entry per (author, id) is the NIP-99 semantics. + static List _merge(List current, Offer incoming) => [ + for (final o in current) + if (!(o.id == incoming.id && + o.authorPubkeyHex == incoming.authorPubkeyHex)) + o, + incoming, + ]; + /// Narrows the visible offers to those whose summary matches [query]. Purely /// local over the already-discovered list; does not re-hit the transport. void search(String query) => emit(state.copyWith(query: query)); + /// Toggles a reciprocity-mode chip on/off. Purely local over the discovered + /// list; does not re-hit the transport. + void toggleType(OfferType type) { + final next = {...state.typeFilter}; + next.contains(type) ? next.remove(type) : next.add(type); + emit(state.copyWith(typeFilter: next)); + } + + /// Toggles a category chip on/off. + void toggleCategory(String category) { + final next = {...state.categoryFilter}; + next.contains(category) ? next.remove(category) : next.add(category); + emit(state.copyWith(categoryFilter: next)); + } + + /// Toggles the organic ("eco") filter. + void toggleOrganicOnly() => + emit(state.copyWith(organicOnly: !state.organicOnly)); + + /// Clears every chip filter (leaves the text search untouched). + void clearFilters() => emit(state.copyWith( + typeFilter: const {}, + categoryFilter: const {}, + organicOnly: false, + )); + /// Publishes [offer]; returns the transport's verdict. No-op result when /// offline. Future publish(Offer offer) async { @@ -172,6 +268,7 @@ class OffersCubit extends Cubit { sharing: lot.offerStatus, areaGeohash: areaGeohash, category: lot.category, + isOrganic: lot.isOrganic, ); final result = await transport.publish(offer); if (result.accepted) accepted++; diff --git a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart new file mode 100644 index 0000000..ac81f23 --- /dev/null +++ b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart @@ -0,0 +1,307 @@ +import 'dart:async'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +import '../i18n/strings.g.dart'; +import '../services/profile_cache.dart'; +import '../services/social_service.dart'; +import '../services/social_settings.dart'; +import 'market_widgets.dart'; +import 'theme.dart'; + +/// Read-only "product page" for one discovered [Offer] (the Wallapop-style +/// detail behind a market card). Shows only what the author elected to publish — +/// summary, reciprocity mode, category, coarse area, price, and the eco flag — +/// plus who's sharing it and a way to message them. It never fetches inventory +/// or an exact address: the privacy seam means the network simply has no more. +class MarketOfferDetailScreen extends StatefulWidget { + const MarketOfferDetailScreen({ + required this.offer, + required this.social, + required this.settings, + this.mine = false, + this.profileCache, + super.key, + }); + + final Offer offer; + final SocialService social; + final SocialSettings settings; + + /// Whether this is the current user's own listing (hide the message button). + final bool mine; + + /// Optional cache of peers' published display names; null in tests. + final ProfileCache? profileCache; + + @override + State createState() => + _MarketOfferDetailScreenState(); +} + +class _MarketOfferDetailScreenState extends State { + SocialSession? _session; + String? _sellerName; + String? _sellerAbout; + String? _sellerG1; + bool _profileLoading = true; + + @override + void initState() { + super.initState(); + _loadSeller(); + } + + /// Best-effort fetch of the author's published profile (name/about/Ğ1), the + /// same path the chat header uses. Degrades silently when offline. + Future _loadSeller() async { + final cachedName = await widget.profileCache?.name(widget.offer.authorPubkeyHex); + if (mounted && cachedName != null) setState(() => _sellerName = cachedName); + + final relays = await widget.settings.relayUrls(); + SocialSession? session; + if (relays.isNotEmpty) { + try { + session = await widget.social.openSession(relays); + } catch (_) { + session = null; + } + } + if (!mounted) { + await session?.close(); + return; + } + _session = session; + if (session == null) { + setState(() => _profileLoading = false); + return; + } + try { + final profile = await session.profile.fetch(widget.offer.authorPubkeyHex); + if (profile != null && profile.name.isNotEmpty) { + unawaited(widget.profileCache + ?.setName(widget.offer.authorPubkeyHex, profile.name)); + } + if (mounted) { + setState(() { + if (profile != null && profile.name.isNotEmpty) { + _sellerName = profile.name; + } + _sellerAbout = profile?.about.isNotEmpty == true ? profile!.about : null; + _sellerG1 = profile?.g1.isNotEmpty == true ? profile!.g1 : null; + _profileLoading = false; + }); + } + } catch (_) { + if (mounted) setState(() => _profileLoading = false); + } + } + + @override + void dispose() { + _session?.close(); + super.dispose(); + } + + Future _copyId() async { + final t = context.t; + final messenger = ScaffoldMessenger.of(context); + await Clipboard.setData( + ClipboardData(text: widget.offer.authorPubkeyHex)); + messenger.showSnackBar(SnackBar(content: Text(t.market.idCopied))); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final o = widget.offer; + return Scaffold( + appBar: AppBar(title: Text(o.summary)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Expanded( + child: Text( + o.summary, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + color: seedTitle, + ), + ), + ), + const SizedBox(width: 8), + OfferTypeChip(label: offerTypeLabel(t, o.type)), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (o.isOrganic) + _Badge( + icon: Icons.eco, + label: t.editVariety.organic, + ), + if (o.category != null && o.category!.isNotEmpty) + _Badge(icon: Icons.folder_outlined, label: o.category!), + _Badge(icon: Icons.place_outlined, label: t.market.near), + ], + ), + if (o.type == OfferType.sale && o.priceAmount != null) ...[ + const SizedBox(height: 16), + Text( + '${o.priceAmount} ${o.priceCurrency ?? ''}'.trim(), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: seedOnSurface, + ), + ), + ], + if (o.type == OfferType.exchange && + o.exchangeTerms != null && + o.exchangeTerms!.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + o.exchangeTerms!, + style: const TextStyle(color: seedOnSurface, fontSize: 15), + ), + ], + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 12), + _SellerSection( + title: t.market.sharedBy, + name: _sellerName ?? shortPubkey(o.authorPubkeyHex), + about: _sellerAbout, + loading: _profileLoading, + hasProfile: + _sellerName != null || _sellerAbout != null || _sellerG1 != null, + noProfileLabel: t.market.noProfile, + onCopyId: _copyId, + copyIdTooltip: t.market.copyId, + ), + const SizedBox(height: 24), + if (!widget.mine) + FilledButton.icon( + key: const Key('offerDetail.contact'), + onPressed: () => context.push('/chat/${o.authorPubkeyHex}'), + icon: const Icon(Icons.chat_bubble_outline), + label: Text(t.market.contact), + ), + ], + ), + ); + } +} + +/// A small pill with an icon + label (eco, category, "near you"). +class _Badge extends StatelessWidget { + const _Badge({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: seedPrimaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: seedGreen), + const SizedBox(width: 5), + Text(label, + style: const TextStyle(color: seedOnSurface, fontSize: 13)), + ], + ), + ); + } +} + +/// "Shared by" card: who's offering this, their bio, and a copy-code affordance. +class _SellerSection extends StatelessWidget { + const _SellerSection({ + required this.title, + required this.name, + required this.about, + required this.loading, + required this.hasProfile, + required this.noProfileLabel, + required this.onCopyId, + required this.copyIdTooltip, + }); + + final String title; + final String name; + final String? about; + final bool loading; + final bool hasProfile; + final String noProfileLabel; + final VoidCallback onCopyId; + final String copyIdTooltip; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 13, + color: seedMuted, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const CircleAvatar( + radius: 20, + backgroundColor: seedPrimaryContainer, + child: Icon(Icons.person_outline, color: seedGreen), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: seedOnSurface, + ), + ), + ), + IconButton( + key: const Key('offerDetail.copyId'), + icon: const Icon(Icons.copy, size: 18, color: seedMuted), + tooltip: copyIdTooltip, + onPressed: onCopyId, + ), + ], + ), + if (about != null) ...[ + const SizedBox(height: 8), + Text(about!, + style: const TextStyle(color: seedOnSurface, fontSize: 14)), + ] else if (!loading && !hasProfile) ...[ + const SizedBox(height: 8), + Text(noProfileLabel, + style: const TextStyle(color: seedMuted, fontSize: 13)), + ], + ], + ); + } +} diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index 74a534a..0c405a2 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -11,6 +11,7 @@ import '../services/offer_outbox.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../state/offers_cubit.dart'; +import 'market_widgets.dart'; import 'theme.dart'; /// The market (redesign screens 04/05): discover seeds shared near you. Opens @@ -300,6 +301,7 @@ class MarketBody extends StatelessWidget { onChanged: cubit.search, ), ), + _MarketFilterBar(state: state), Expanded( child: RefreshIndicator( onRefresh: refresh, @@ -329,10 +331,8 @@ class MarketBody extends StatelessWidget { return _OfferCard( offer: o, mine: mine, - onContact: mine - ? null - : () => - context.push('/chat/${o.authorPubkeyHex}'), + onTap: () => + context.push('/market/offer', extra: o), ); }, ), @@ -348,119 +348,177 @@ class MarketBody extends StatelessWidget { /// One discovered offer. Human words for the reciprocity mode; coarse "near you" /// instead of any precise location; price only when it's a sale. class _OfferCard extends StatelessWidget { - const _OfferCard({required this.offer, this.mine = false, this.onContact}); + const _OfferCard({required this.offer, this.mine = false, this.onTap}); final Offer offer; /// Whether this is the current user's own listing (shown with a "You" badge). final bool mine; - /// Opens a private chat with the author; null for our own listings. - final VoidCallback? onContact; + /// Opens the offer's detail ("product") page. + final VoidCallback? onTap; @override Widget build(BuildContext context) { final t = context.t; - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: seedOutline), - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + final radius = BorderRadius.circular(14); + return Material( + color: Colors.white, + borderRadius: radius, + child: InkWell( + key: Key('market.offer.${offer.authorPubkeyHex}.${offer.id}'), + onTap: onTap, + borderRadius: radius, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all(color: seedOutline), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Text( - offer.summary, - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w500, - color: seedOnSurface, - ), - ), - ), - if (mine) ...[ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: seedGreen, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - t.market.mine, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w700, + Row( + children: [ + Expanded( + child: Text( + offer.summary, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w500, + color: seedOnSurface, + ), ), ), - ), - const SizedBox(width: 6), - ], - _TypeChip(label: _offerTypeLabel(t, offer.type)), + if (mine) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: seedGreen, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + t.market.mine, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + ], + OfferTypeChip(label: offerTypeLabel(t, offer.type)), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.place_outlined, size: 15, color: seedMuted), + const SizedBox(width: 4), + Text(t.market.near, + style: const TextStyle(color: seedMuted, fontSize: 13)), + if (offer.isOrganic) ...[ + const SizedBox(width: 10), + const Icon(Icons.eco, size: 15, color: seedGreen), + ], + const Spacer(), + if (offer.type == OfferType.sale && offer.priceAmount != null) + Text( + '${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(), + style: const TextStyle( + color: seedOnSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 18, color: seedMuted), + ], + ), ], ), - const SizedBox(height: 6), - Row( - children: [ - const Icon(Icons.place_outlined, size: 15, color: seedMuted), - const SizedBox(width: 4), - Text(t.market.near, style: const TextStyle(color: seedMuted, fontSize: 13)), - const Spacer(), - if (offer.type == OfferType.sale && offer.priceAmount != null) - Text( - '${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(), - style: const TextStyle( - color: seedOnSurface, - fontWeight: FontWeight.w600, - ), - ), - if (onContact != null) - TextButton.icon( - onPressed: onContact, - icon: const Icon(Icons.chat_bubble_outline, size: 16), - label: Text(t.market.contact), - ), - ], - ), - ], + ), ), ); } } -String _offerTypeLabel(Translations t, OfferType type) => switch (type) { - OfferType.gift => t.share.gift, - OfferType.exchange => t.share.exchange, - OfferType.sale => t.share.sell, - OfferType.wanted => t.market.wanted, - OfferType.lend => t.share.exchange, - }; +/// Optional filters over the discovered offers, mirroring the inventory filter +/// bar: reciprocity mode (give/swap/sell/wanted), category, and the eco flag. +/// Each chip group only appears when some discovered offer can match it. +class _MarketFilterBar extends StatelessWidget { + const _MarketFilterBar({required this.state}); -class _TypeChip extends StatelessWidget { - const _TypeChip({required this.label}); - - final String label; + final OffersState state; @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: seedPrimaryContainer, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - label, - style: const TextStyle( - color: seedOnPrimaryContainer, - fontSize: 12, - fontWeight: FontWeight.w600, + final t = context.t; + final cubit = context.read(); + final categories = state.categories; + // Only offer type chips for modes some discovered offer actually holds. + final types = [ + for (final type in const [ + OfferType.gift, + OfferType.exchange, + OfferType.sale, + OfferType.wanted, + ]) + if (state.offers.any((o) => o.type == type)) type, + ]; + final hasOrganic = state.hasOrganic; + if (categories.isEmpty && types.isEmpty && !hasOrganic) { + return const SizedBox.shrink(); + } + + final chips = [ + if (hasOrganic) + FilterChip( + key: const Key('market.filter.organic'), + avatar: Icon( + Icons.eco, + size: 18, + color: state.organicOnly ? null : seedGreen, + ), + label: Text(t.editVariety.organic), + selected: state.organicOnly, + onSelected: (_) => cubit.toggleOrganicOnly(), ), + for (final type in types) + FilterChip( + key: Key('market.filter.type.${type.name}'), + label: Text(offerTypeLabel(t, type)), + selected: state.typeFilter.contains(type), + onSelected: (_) => cubit.toggleType(type), + ), + for (final category in categories) + FilterChip( + key: Key('market.filter.category.$category'), + label: Text(category), + selected: state.categoryFilter.contains(category), + onSelected: (_) => cubit.toggleCategory(category), + ), + ]; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + for (final chip in chips) + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: chip, + ), + if (state.hasActiveFilter) + TextButton.icon( + key: const Key('market.filter.clear'), + onPressed: cubit.clearFilters, + icon: const Icon(Icons.clear, size: 18), + label: Text(t.inventory.clearFilters), + ), + ], ), ); } diff --git a/apps/app_seeds/lib/ui/market_widgets.dart b/apps/app_seeds/lib/ui/market_widgets.dart new file mode 100644 index 0000000..155eefa --- /dev/null +++ b/apps/app_seeds/lib/ui/market_widgets.dart @@ -0,0 +1,41 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; +import 'theme.dart'; + +/// Human words for an offer's reciprocity mode. Shared by the market list and +/// the offer detail page so both read the same way. +String offerTypeLabel(Translations t, OfferType type) => switch (type) { + OfferType.gift => t.share.gift, + OfferType.exchange => t.share.exchange, + OfferType.sale => t.share.sell, + OfferType.wanted => t.market.wanted, + OfferType.lend => t.share.exchange, + }; + +/// The small pill showing an offer's reciprocity mode (give/swap/sell/wanted). +class OfferTypeChip extends StatelessWidget { + const OfferTypeChip({required this.label, super.key}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: seedPrimaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: const TextStyle( + color: seedOnPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index 1eb4565..2698c49 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -40,6 +40,38 @@ class FakeOfferTransport implements OfferTransport { Future close() async {} } +/// Emits every published offer TWICE on discover — the way a relay legitimately +/// resends an addressable event (a stored copy plus a live echo). Used to prove +/// the cubit dedups instead of doubling the listing. +class DuplicatingOfferTransport implements OfferTransport { + final List _offers = []; + + @override + Future publish(Offer offer) async { + _offers.add(offer); + return PublishResult(accepted: true, transportRef: offer.id); + } + + @override + Stream discover(DiscoveryQuery query) { + final controller = StreamController(); + for (final o in _offers) { + if (o.approxGeohash.startsWith(query.geohashPrefix)) { + controller.add(o); + controller.add(o); // the duplicate + } + } + return controller.stream; + } + + @override + Future retract(String offerId) async => + _offers.removeWhere((o) => o.id == offerId); + + @override + Future close() async {} +} + void main() { group('OfferMapper', () { test('maps local sharing intent to the network offer type', () { @@ -79,6 +111,26 @@ void main() { expect(sale.priceAmount, 3); expect(sale.priceCurrency, 'G1'); }); + + test('carries the organic (eco) flag through to the offer', () { + final eco = OfferMapper.fromSharedLot( + lotId: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Lechuga', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + isOrganic: true, + ); + expect(eco.isOrganic, isTrue); + final plain = OfferMapper.fromSharedLot( + lotId: 'lot-2', + authorPubkeyHex: 'ab' * 32, + summary: 'Lechuga', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + ); + expect(plain.isOrganic, isFalse); + }); }); group('OffersCubit', () { @@ -347,5 +399,117 @@ void main() { expect(result.accepted, isFalse); await cubit.close(); }); + + test('a resent offer is not duplicated in the list', () async { + // Regression: publishing an inventory lot showed up twice because the + // relay resends the addressable event; the cubit must dedup by author+id. + final transport = DuplicatingOfferTransport(); + await transport.publish(OfferMapper.fromSharedLot( + lotId: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Tomate rosa', + sharing: OfferStatus.shared, + areaGeohash: 'sp3e9', + )); + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers, hasLength(1)); + await cubit.close(); + }); + + test('two authors reusing the same lot id are kept apart', () async { + // A non-deduping transport, so both authors' same-id offers reach discover. + final transport = DuplicatingOfferTransport(); + for (final author in ['ab' * 32, 'cd' * 32]) { + await transport.publish(Offer( + id: 'lot-1', // same d-tag, different author + authorPubkeyHex: author, + summary: 'Tomate', + type: OfferType.gift, + approxGeohash: 'sp3e9', + )); + } + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers, hasLength(2)); + await cubit.close(); + }); + }); + + group('OffersCubit filters', () { + Offer offer(String id, OfferType type, + {String? category, bool organic = false}) => + Offer( + id: id, + authorPubkeyHex: 'ab' * 32, + summary: id, + type: type, + category: category, + approxGeohash: 'sp3e9', + isOrganic: organic, + ); + + Future seeded(List offers) async { + final transport = FakeOfferTransport(); + for (final o in offers) { + await transport.publish(o); + } + final cubit = OffersCubit(transport); + await cubit.discover('sp3'); + await pumpEventQueue(); + return cubit; + } + + test('type, category and eco chips narrow the list (ANDed with search)', + () async { + final cubit = await seeded([ + offer('tomate-eco', OfferType.gift, category: 'Solanaceae', organic: true), + offer('tomate-sale', OfferType.sale, category: 'Solanaceae'), + offer('judia-eco', OfferType.gift, category: 'Fabaceae', organic: true), + ]); + + cubit.toggleType(OfferType.gift); + expect(cubit.state.visibleOffers.map((o) => o.id), + containsAll(['tomate-eco', 'judia-eco'])); + expect(cubit.state.visibleOffers, hasLength(2)); + + cubit.toggleCategory('Solanaceae'); + expect(cubit.state.visibleOffers.single.id, 'tomate-eco'); + + cubit.toggleOrganicOnly(); + expect(cubit.state.visibleOffers.single.id, 'tomate-eco'); + + cubit.clearFilters(); + expect(cubit.state.visibleOffers, hasLength(3)); + expect(cubit.state.hasActiveFilter, isFalse); + await cubit.close(); + }); + + test('exposes categories and hasOrganic from the discovered offers', + () async { + final cubit = await seeded([ + offer('a', OfferType.gift, category: 'Solanaceae', organic: true), + offer('b', OfferType.gift, category: 'Fabaceae'), + offer('c', OfferType.gift), + ]); + expect(cubit.state.categories, ['Fabaceae', 'Solanaceae']); // sorted + expect(cubit.state.hasOrganic, isTrue); + await cubit.close(); + }); + + test('chip filters survive a refresh (re-discovery)', () async { + final cubit = await seeded([ + offer('gift', OfferType.gift), + offer('sale', OfferType.sale), + ]); + cubit.toggleType(OfferType.gift); + await cubit.discover('sp3'); // pull-to-refresh + await pumpEventQueue(); + expect(cubit.state.typeFilter, {OfferType.gift}); + expect(cubit.state.visibleOffers.single.id, 'gift'); + await cubit.close(); + }); }); } 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 new file mode 100644 index 0000000..63fa9b4 --- /dev/null +++ b/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart @@ -0,0 +1,73 @@ +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/social_service.dart'; +import 'package:tane/services/social_settings.dart'; +import 'package:tane/ui/market_offer_detail_screen.dart'; + +import '../support/test_support.dart'; + +Widget _wrap(MarketOfferDetailScreen screen) { + LocaleSettings.setLocaleSync(AppLocale.en); + return TranslationProvider( + child: MaterialApp( + locale: AppLocale.en.flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: screen, + ), + ); +} + +Offer _offer({bool organic = false}) => Offer( + id: 'lot-1', + authorPubkeyHex: 'ab' * 32, + summary: 'Tomate rosa de Barbastro', + type: OfferType.gift, + category: 'Solanaceae', + approxGeohash: 'sp3e9', + isOrganic: organic, + ); + +void main() { + testWidgets('renders the offer and a message button for a stranger', + (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); // offline: no network in the test + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(organic: true), + social: social, + settings: settings, + ))); + await tester.pumpAndSettle(); + + expect(find.text('Tomate rosa de Barbastro'), findsWidgets); + expect(find.text('Solanaceae'), findsOneWidget); + expect(find.text('Organic'), findsOneWidget); // eco badge + expect(find.byKey(const Key('offerDetail.contact')), findsOneWidget); + }); + + testWidgets('hides the message button on my own listing', (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(), + social: social, + settings: settings, + mine: true, + ))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('offerDetail.contact')), findsNothing); + }); +} diff --git a/packages/commons_core/lib/src/social/nostr/nip99_codec.dart b/packages/commons_core/lib/src/social/nostr/nip99_codec.dart index 486b018..94deeaa 100644 --- a/packages/commons_core/lib/src/social/nostr/nip99_codec.dart +++ b/packages/commons_core/lib/src/social/nostr/nip99_codec.dart @@ -30,6 +30,7 @@ class Nip99Codec { tags.add(['g', coarse.substring(0, i)]); // NIP-52 prefix ladder, coarse only } if (offer.category != null) tags.add(['t', offer.category!]); + if (offer.isOrganic) tags.add(['organic', '1']); if (offer.radiusKm != null) tags.add(['radius_km', '${offer.radiusKm}']); if (offer.type == OfferType.sale && offer.priceAmount != null) { tags.add(['price', '${offer.priceAmount}', offer.priceCurrency ?? 'EUR']); @@ -62,6 +63,7 @@ class Nip99Codec { priceCurrency: price != null && price.length > 2 ? price[2] : null, exchangeTerms: _tagValue(event, 'exchange_terms'), imageUrl: _tagValue(event, 'image'), + isOrganic: _tagValue(event, 'organic') == '1', ); } diff --git a/packages/commons_core/lib/src/social/offer.dart b/packages/commons_core/lib/src/social/offer.dart index 167c162..5baa1b1 100644 --- a/packages/commons_core/lib/src/social/offer.dart +++ b/packages/commons_core/lib/src/social/offer.dart @@ -29,6 +29,7 @@ class Offer { this.radiusKm, this.imageUrl, this.expiresAt, + this.isOrganic = false, }); /// Stable per-offer identifier (NIP-99 `d` tag → addressable event). @@ -57,6 +58,10 @@ class Offer { final String? imageUrl; final DateTime? expiresAt; + + /// Grower-declared organic ("eco") provenance — a self-declaration the author + /// chose to publish, so peers can filter for it. Never a certification. + final bool isOrganic; } /// Result of publishing to a transport. diff --git a/packages/commons_core/test/social/nostr_transports_test.dart b/packages/commons_core/test/social/nostr_transports_test.dart index 2f6f978..4ba25cc 100644 --- a/packages/commons_core/test/social/nostr_transports_test.dart +++ b/packages/commons_core/test/social/nostr_transports_test.dart @@ -77,6 +77,43 @@ void main() { await bobT.close(); }); + test('the organic (eco) flag round-trips; absent when not declared', + () async { + final alice = await idFor(1); + final bob = await idFor(2); + final aliceT = NostrOfferTransport(await connFor(alice)); + final bobT = NostrOfferTransport(await connFor(bob)); + + await aliceT.publish(Offer( + id: 'eco-1', + authorPubkeyHex: alice.publicKeyHex, + summary: 'Lechuga eco', + type: OfferType.gift, + approxGeohash: 'sp3e9', + isOrganic: true, + )); + await aliceT.publish(Offer( + id: 'plain-1', + authorPubkeyHex: alice.publicKeyHex, + summary: 'Lechuga', + type: OfferType.gift, + approxGeohash: 'sp3e9', + )); + + final found = await bobT + .discoverUntilEose(const DiscoveryQuery(geohashPrefix: 'sp3')); + final eco = found.firstWhere((o) => o.id == 'eco-1'); + final plain = found.firstWhere((o) => o.id == 'plain-1'); + expect(eco.isOrganic, isTrue); + expect(plain.isOrganic, isFalse); + // A plain listing carries no organic tag at all. + final wire = jsonEncode(relay.eventsOfKind(30402)); + expect('organic'.allMatches(wire), hasLength(1)); + + await aliceT.close(); + await bobT.close(); + }); + test('exact geohash never leaves the device (coarsened on the wire)', () async { final alice = await idFor(1);