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

@ -40,6 +40,38 @@ class FakeOfferTransport implements OfferTransport {
Future<void> 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<Offer> _offers = [];
@override
Future<PublishResult> publish(Offer offer) async {
_offers.add(offer);
return PublishResult(accepted: true, transportRef: offer.id);
}
@override
Stream<Offer> discover(DiscoveryQuery query) {
final controller = StreamController<Offer>();
for (final o in _offers) {
if (o.approxGeohash.startsWith(query.geohashPrefix)) {
controller.add(o);
controller.add(o); // the duplicate
}
}
return controller.stream;
}
@override
Future<void> retract(String offerId) async =>
_offers.removeWhere((o) => o.id == offerId);
@override
Future<void> 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<OffersCubit> seeded(List<Offer> 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();
});
});
}