Persistence + wiring the offers screen needs, per the product decisions: - SocialSettings (keystore-backed, no plaintext): coarse area geohash + configurable relay URLs, both empty by default (no bundled public relays — don't leak metadata to third parties). Registered in DI. - createOffersCubit(): opens a SocialSession on the first configured relay when online, else a null-transport cubit that degrades gracefully. OffersCubit now closes the owning session on dispose. 4 settings tests green; offers/social suites still green.
145 lines
4.6 KiB
Dart
145 lines
4.6 KiB
Dart
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<Offer> 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<Offer>? 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<Object?> 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<OffersState> {
|
|
OffersCubit(this._transport, {Future<void> 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<void> Function()? _onDispose;
|
|
StreamSubscription<Offer>? _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<void> 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<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;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> 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<OffersCubit> 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
|
|
}
|
|
}
|