import 'dart:async'; import 'package:commons_core/commons_core.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.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; /// 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(); 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.first); return OffersCubit(session.offers, onDispose: session.close); } catch (_) { return OffersCubit(null); // offline / relay unreachable } }