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 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 typeFilter; /// Active category filter (free-text categories); empty = all. final Set 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 blockedAuthors; /// Individual offers this user reported and hid ("author:id" keys, see /// SocialSettings.offerKey). Same idea as [blockedAuthors], finer grain. final Set 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 get visibleOffers { final q = query.trim().toLowerCase(); return offers.where((o) { if (blockedAuthors.contains(o.authorPubkeyHex)) return false; if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) { return false; } 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 get categories { final set = { 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? offers, String? areaGeohash, String? query, Set? typeFilter, Set? categoryFilter, bool? organicOnly, Set? blockedAuthors, Set? 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 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 { OffersCubit( this._transport, { Future Function(String varietyId)? coverPhoto, String? Function(Uint8List bytes)? thumbnail, Future 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 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 Function()? _onDispose; StreamSubscription? _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 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 _merge(List 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 pubkeys) => emit(state.copyWith(blockedAuthors: pubkeys)); /// Replaces the set of locally hidden (reported) offers. void setHiddenOffers(Set offerKeys) => emit(state.copyWith(hiddenOfferKeys: offerKeys)); /// 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 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 publishLots( List 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 _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 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 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 flushOutbox({ required OfferOutbox outbox, required OffersCubit cubit, required List 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; }