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:
parent
e852b569ce
commit
54d7c2d3b5
21 changed files with 982 additions and 107 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
73
apps/app_seeds/test/ui/market_offer_detail_screen_test.dart
Normal file
73
apps/app_seeds/test/ui/market_offer_detail_screen_test.dart
Normal file
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue