import 'dart:async'; import 'package:commons_core/commons_core.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.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) : super(const OffersState()); final OfferTransport? _transport; StreamSubscription? _sub; /// 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(); 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')), ); } /// 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; } } @override Future close() async { await _sub?.cancel(); return super.close(); } }