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.
336 lines
11 KiB
Dart
336 lines
11 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:commons_core/commons_core.dart';
|
|
import 'package:equatable/equatable.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
|
|
import '../data/variety_repository.dart';
|
|
import '../services/offer_mapper.dart';
|
|
import '../services/offer_outbox.dart';
|
|
import '../services/social_service.dart';
|
|
import '../services/social_settings.dart';
|
|
|
|
/// State of the offer discovery/publish screen. Transport-agnostic — it holds
|
|
/// only what the UI shows, never a relay handle.
|
|
class OffersState extends Equatable {
|
|
const OffersState({
|
|
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,
|
|
this.error,
|
|
});
|
|
|
|
/// Offers discovered so far for [areaGeohash], newest appended as they arrive.
|
|
final List<Offer> offers;
|
|
|
|
/// The coarse area currently being browsed.
|
|
final String areaGeohash;
|
|
|
|
/// 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;
|
|
|
|
/// True once a discovery has been started, so the UI can tell "no search yet"
|
|
/// from "searched, found nothing".
|
|
final bool hasSearched;
|
|
|
|
/// Last error, in human terms for the UI (null when fine).
|
|
final String? error;
|
|
|
|
/// [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();
|
|
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,
|
|
String? Function()? error,
|
|
}) {
|
|
return OffersState(
|
|
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,
|
|
error: error != null ? error() : this.error,
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [
|
|
offers,
|
|
areaGeohash,
|
|
query,
|
|
typeFilter,
|
|
categoryFilter,
|
|
organicOnly,
|
|
searching,
|
|
publishing,
|
|
hasSearched,
|
|
error,
|
|
];
|
|
}
|
|
|
|
/// Drives offer discovery and publishing over an [OfferTransport]. Depends on
|
|
/// the interface, not the Nostr backend, so it unit-tests with a fake and the
|
|
/// UI stays offline-tolerant (a null transport = the social layer is unavailable
|
|
/// and the screen degrades gracefully).
|
|
class OffersCubit extends Cubit<OffersState> {
|
|
OffersCubit(this._transport, {Future<void> Function()? onDispose})
|
|
: _onDispose = onDispose,
|
|
super(const OffersState());
|
|
|
|
final OfferTransport? _transport;
|
|
|
|
/// Closes the owning [SocialSession]/connection when the cubit is disposed
|
|
/// (null in tests, where the transport is a fake with nothing to close).
|
|
final Future<void> Function()? _onDispose;
|
|
StreamSubscription<Offer>? _sub;
|
|
Timer? _searchTimeout;
|
|
|
|
/// Whether a live transport is available (relay configured and reachable).
|
|
bool get isOnline => _transport != null;
|
|
|
|
/// Starts (or restarts) discovery for [geohashPrefix]. Results stream in.
|
|
Future<void> discover(String geohashPrefix) async {
|
|
final transport = _transport;
|
|
if (transport == null) {
|
|
emit(state.copyWith(error: () => 'offline', hasSearched: true));
|
|
return;
|
|
}
|
|
await _sub?.cancel();
|
|
_searchTimeout?.cancel();
|
|
emit(OffersState(
|
|
areaGeohash: geohashPrefix,
|
|
// 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: _merge(state.offers, offer), searching: false)),
|
|
onError: (Object e) =>
|
|
emit(state.copyWith(searching: false, error: () => '$e')),
|
|
);
|
|
// The discover stream stays open for live offers and never signals "done",
|
|
// so stop the spinner after a beat: no results → show the empty state, not
|
|
// an endless "searching".
|
|
_searchTimeout = Timer(const Duration(seconds: 6), () {
|
|
if (!isClosed && state.searching) {
|
|
emit(state.copyWith(searching: false));
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 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 {
|
|
final transport = _transport;
|
|
if (transport == null) {
|
|
return const PublishResult(accepted: false, transportRef: '', message: 'offline');
|
|
}
|
|
emit(state.copyWith(publishing: true, error: () => null));
|
|
try {
|
|
final result = await transport.publish(offer);
|
|
emit(state.copyWith(
|
|
publishing: false,
|
|
error: () => result.accepted ? null : result.message,
|
|
));
|
|
return result;
|
|
} catch (e) {
|
|
emit(state.copyWith(publishing: false, error: () => '$e'));
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Publishes the user's [lots] as offers, each tagged with the coarse
|
|
/// [areaGeohash] and signed by [authorPubkeyHex]. Returns how many the relay
|
|
/// accepted. No-op (returns 0) when offline or with no area set.
|
|
Future<int> publishLots(
|
|
List<ShareableLot> lots, {
|
|
required String authorPubkeyHex,
|
|
required String areaGeohash,
|
|
}) async {
|
|
final transport = _transport;
|
|
if (transport == null || areaGeohash.isEmpty || lots.isEmpty) return 0;
|
|
emit(state.copyWith(publishing: true, error: () => null));
|
|
var accepted = 0;
|
|
try {
|
|
for (final lot in lots) {
|
|
final offer = OfferMapper.fromSharedLot(
|
|
lotId: lot.lotId,
|
|
authorPubkeyHex: authorPubkeyHex,
|
|
summary: lot.summary,
|
|
sharing: lot.offerStatus,
|
|
areaGeohash: areaGeohash,
|
|
category: lot.category,
|
|
isOrganic: lot.isOrganic,
|
|
);
|
|
final result = await transport.publish(offer);
|
|
if (result.accepted) accepted++;
|
|
}
|
|
emit(state.copyWith(publishing: false));
|
|
} catch (e) {
|
|
emit(state.copyWith(publishing: false, error: () => '$e'));
|
|
rethrow;
|
|
}
|
|
return accepted;
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
_searchTimeout?.cancel();
|
|
await _sub?.cancel();
|
|
await _onDispose?.call();
|
|
return super.close();
|
|
}
|
|
}
|
|
|
|
/// Opens an [OffersCubit] wired to the social layer, or an offline one that
|
|
/// degrades gracefully. Local-first: when no relay is configured (or connecting
|
|
/// fails), the cubit is created with a null transport and the screen still opens.
|
|
Future<OffersCubit> createOffersCubit(
|
|
SocialService social,
|
|
SocialSettings settings,
|
|
) async {
|
|
final relays = await settings.relayUrls();
|
|
if (relays.isEmpty) return OffersCubit(null);
|
|
try {
|
|
final session = await social.openSession(relays);
|
|
return OffersCubit(session.offers, onDispose: session.close);
|
|
} catch (_) {
|
|
return OffersCubit(null); // offline / no relay reachable
|
|
}
|
|
}
|
|
|
|
/// Publishes any queued (offline-parked) lots now that we're online, then clears
|
|
/// them from the [outbox]. Rebuilds each offer from the lot's CURRENT state, so
|
|
/// since-deleted or now-private lots simply drop out. Returns how many published.
|
|
Future<int> flushOutbox({
|
|
required OfferOutbox outbox,
|
|
required OffersCubit cubit,
|
|
required List<ShareableLot> shareableLots,
|
|
required String authorPubkeyHex,
|
|
required String areaGeohash,
|
|
}) async {
|
|
if (!cubit.isOnline || areaGeohash.isEmpty) return 0;
|
|
final queued = await outbox.pending();
|
|
if (queued.isEmpty) return 0;
|
|
final toPublish =
|
|
shareableLots.where((l) => queued.contains(l.lotId)).toList();
|
|
var published = 0;
|
|
if (toPublish.isNotEmpty) {
|
|
published = await cubit.publishLots(
|
|
toPublish,
|
|
authorPubkeyHex: authorPubkeyHex,
|
|
areaGeohash: areaGeohash,
|
|
);
|
|
}
|
|
// Clear everything attempted (or gone) so we don't loop on it.
|
|
await outbox.remove(queued);
|
|
return published;
|
|
}
|