feat(market): fix publish-duplicates, add offer detail page + filters

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.
This commit is contained in:
vjrj 2026-07-10 19:10:40 +02:00
parent e852b569ce
commit 54d7c2d3b5
21 changed files with 982 additions and 107 deletions

View file

@ -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',
);
}

View file

@ -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.

View file

@ -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);