Save the current market search (query + facets) and get notified when a matching offer appears in your zone while the app is open, with a catch-up scan on start. Adds a save affordance in the search bar, a saved-searches list (apply/delete) with a per-search new-match badge, an AppBar entry badged with the unseen total, and notification-tap routing to the list. Alerts evaluate against the user's current area, dedup via seen keys, and skip own/blocked/hidden/non-active offers. en+es strings.
412 lines
14 KiB
Dart
412 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
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/offer_thumbnail.dart';
|
|
import '../services/social_connection.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.blockedAuthors = const {},
|
|
this.hiddenOfferKeys = const {},
|
|
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;
|
|
|
|
/// Authors this user has blocked; their offers never surface. Kept in the
|
|
/// state (not dropped at merge time) so unblocking can resurface what was
|
|
/// already discovered.
|
|
final Set<String> blockedAuthors;
|
|
|
|
/// Individual offers this user reported and hid ("author:id" keys, see
|
|
/// SocialSettings.offerKey). Same idea as [blockedAuthors], finer grain.
|
|
final Set<String> hiddenOfferKeys;
|
|
|
|
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 {
|
|
return offers.where((o) {
|
|
// Moderation state (block/hide) is app-side, not part of the search facets.
|
|
if (blockedAuthors.contains(o.authorPubkeyHex)) return false;
|
|
if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) {
|
|
return false;
|
|
}
|
|
// The query/facet chain is shared with saved-search alerts so the two
|
|
// never disagree on what "matches".
|
|
return SavedSearch.matchesFilters(
|
|
o,
|
|
query: query,
|
|
types: typeFilter,
|
|
categories: categoryFilter,
|
|
organicOnly: organicOnly,
|
|
);
|
|
}).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,
|
|
Set<String>? blockedAuthors,
|
|
Set<String>? hiddenOfferKeys,
|
|
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,
|
|
blockedAuthors: blockedAuthors ?? this.blockedAuthors,
|
|
hiddenOfferKeys: hiddenOfferKeys ?? this.hiddenOfferKeys,
|
|
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,
|
|
blockedAuthors,
|
|
hiddenOfferKeys,
|
|
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<Uint8List?> Function(String varietyId)? coverPhoto,
|
|
String? Function(Uint8List bytes)? thumbnail,
|
|
Future<void> Function()? onDispose,
|
|
}) : _coverPhoto = coverPhoto,
|
|
_thumbnail = thumbnail,
|
|
_onDispose = onDispose,
|
|
super(const OffersState());
|
|
|
|
final OfferTransport? _transport;
|
|
|
|
/// Fetches a variety's cover photo bytes; null in tests or when no inventory
|
|
/// repo is wired.
|
|
final Future<Uint8List?> Function(String varietyId)? _coverPhoto;
|
|
|
|
/// Turns full photo bytes into a small `data:` thumbnail embedded in the offer
|
|
/// (no media server). Null → offers publish without a photo.
|
|
final String? Function(Uint8List bytes)? _thumbnail;
|
|
|
|
/// 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,
|
|
blockedAuthors: state.blockedAuthors,
|
|
hiddenOfferKeys: state.hiddenOfferKeys,
|
|
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));
|
|
|
|
/// Replaces the set of blocked authors (loaded from the local blocklist);
|
|
/// their offers disappear from the visible list at once.
|
|
void setBlockedAuthors(Set<String> pubkeys) =>
|
|
emit(state.copyWith(blockedAuthors: pubkeys));
|
|
|
|
/// Replaces the set of locally hidden (reported) offers.
|
|
void setHiddenOffers(Set<String> offerKeys) =>
|
|
emit(state.copyWith(hiddenOfferKeys: offerKeys));
|
|
|
|
/// Applies a saved search's query and facets to the current discoveries.
|
|
/// Purely local (does not re-hit the transport) — the area is unchanged, so
|
|
/// the same discovered offers are simply re-narrowed by the saved filters.
|
|
void applySavedSearch(SavedSearch search) => emit(state.copyWith(
|
|
query: search.query,
|
|
typeFilter: search.types,
|
|
categoryFilter: search.categories,
|
|
organicOnly: search.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,
|
|
priceAmount: lot.priceAmount,
|
|
priceCurrency: lot.priceCurrency,
|
|
imageUrl: await _coverThumbnail(lot.varietyId),
|
|
);
|
|
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;
|
|
}
|
|
|
|
/// Builds a small inline `data:` thumbnail from the lot's cover photo to embed
|
|
/// in the offer, or null when there's no photo, no thumbnailer, or it can't be
|
|
/// shrunk to fit. Best-effort: a missing image never blocks publishing.
|
|
Future<String?> _coverThumbnail(String varietyId) async {
|
|
final coverPhoto = _coverPhoto;
|
|
final thumbnail = _thumbnail;
|
|
if (coverPhoto == null || thumbnail == null) return null;
|
|
try {
|
|
final Uint8List? bytes = await coverPhoto(varietyId);
|
|
if (bytes == null || bytes.isEmpty) return null;
|
|
return thumbnail(bytes);
|
|
} catch (_) {
|
|
return null; // degrade: publish the offer without a photo
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
_searchTimeout?.cancel();
|
|
await _sub?.cancel();
|
|
await _onDispose?.call();
|
|
return super.close();
|
|
}
|
|
}
|
|
|
|
/// Opens an [OffersCubit] over the SHARED [SocialConnection], or an offline one
|
|
/// that degrades gracefully. Local-first: when the connection isn't up (no relay
|
|
/// configured / unreachable), the cubit gets a null transport and the screen
|
|
/// still opens. Does NOT close the session — the connection owns it.
|
|
Future<OffersCubit> createOffersCubit(
|
|
SocialConnection connection, {
|
|
VarietyRepository? repository,
|
|
}) async {
|
|
final session = await connection.session();
|
|
return OffersCubit(
|
|
session?.offers,
|
|
coverPhoto: repository?.coverPhotoFor,
|
|
thumbnail: offerThumbnailDataUri,
|
|
);
|
|
}
|
|
|
|
/// 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;
|
|
}
|