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
|
|
@ -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<OfferType> typeFilter;
|
||||
|
||||
/// Active category filter (free-text categories); empty = all.
|
||||
final Set<String> 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<Offer> 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<String> get categories {
|
||||
final set = <String>{
|
||||
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<Offer>? offers,
|
||||
String? areaGeohash,
|
||||
String? query,
|
||||
Set<OfferType>? typeFilter,
|
||||
Set<String>? 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<Object?> get props =>
|
||||
[offers, areaGeohash, query, searching, publishing, hasSearched, error];
|
||||
List<Object?> 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<OffersState> {
|
|||
_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<OffersState> {
|
|||
});
|
||||
}
|
||||
|
||||
/// 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<Offer> _merge(List<Offer> 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<PublishResult> publish(Offer offer) async {
|
||||
|
|
@ -172,6 +268,7 @@ class OffersCubit extends Cubit<OffersState> {
|
|||
sharing: lot.offerStatus,
|
||||
areaGeohash: areaGeohash,
|
||||
category: lot.category,
|
||||
isOrganic: lot.isOrganic,
|
||||
);
|
||||
final result = await transport.publish(offer);
|
||||
if (result.accepted) accepted++;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue