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.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; 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; OffersState copyWith({ List? offers, String? areaGeohash, bool? searching, bool? publishing, bool? hasSearched, String? Function()? error, }) { return OffersState( offers: offers ?? this.offers, areaGeohash: areaGeohash ?? this.areaGeohash, searching: searching ?? this.searching, publishing: publishing ?? this.publishing, hasSearched: hasSearched ?? this.hasSearched, error: error != null ? error() : this.error, ); } @override List get props => [offers, areaGeohash, 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()? 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 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, searching: true, hasSearched: true, )); _sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen( (offer) => emit(state.copyWith(offers: [...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)); } }); } /// 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, ); 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 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 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 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; }