tane/apps/app_seeds/lib/state/offers_cubit.dart
vjrj b9492cb65a refactor(social): one shared relay connection per identity
Every feature (offers, messaging, trust, profile, the inbox listener) used
to open its OWN RelayPool via social.openSession — several sockets to the
same relays, more battery, and reconnection logic living only in the inbox
listener.

Add SocialConnection: ONE shared session per identity, lazily connected and
reused by all. It watches connectivity — dropping the session when the
network goes and reconnecting when it returns, announcing each change on a
sessions stream so the inbox listener re-subscribes automatically. Callers
get the session via connection.session() and never close it (the connection
owns its lifecycle); recreated/disposed on an identity switch.

- InboxService now consumes the shared connection (subscribes on its
  sessions stream) instead of owning its own socket + connectivity code.
- createOffersCubit + the chat/market/profile screens use the shared
  connection; dead createMessagesCubit/createTrustCubit factories removed.
- DI registers SocialConnection per identity; Bootstrap starts it (after
  the inbox subscribes); switchSocialAccount disposes + recreates it.

Tests: SocialConnection lifecycle (reuse, concurrent connect, offline drop +
reconnect, unreachable retry) with a fake opener + online stream; inbox test
updated. nostr added as a dev_dependency for the channel fake.
2026-07-10 21:50:34 +02:00

367 lines
13 KiB
Dart

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.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;
/// 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<OfferType> typeFilter;
/// Active category filter (free-text categories); empty = all.
final Set<String> categoryFilter;
/// When true, show only offers the grower declared organic ("eco").
final bool organicOnly;
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<Offer> get visibleOffers {
final q = query.trim().toLowerCase();
return offers.where((o) {
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<String> get categories {
final set = <String>{
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<Offer>? offers,
String? areaGeohash,
String? query,
Set<OfferType>? typeFilter,
Set<String>? categoryFilter,
bool? organicOnly,
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,
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,
query,
typeFilter,
categoryFilter,
organicOnly,
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<Uint8List?> Function(String varietyId)? coverPhoto,
String? Function(Uint8List bytes)? thumbnail,
Future<void> 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<Uint8List?> 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<void> Function()? _onDispose;
StreamSubscription<Offer>? _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<void> 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,
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<Offer> _merge(List<Offer> 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));
/// 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<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;
}
}
/// 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<int> publishLots(
List<ShareableLot> 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,
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<String?> _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<void> 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<OffersCubit> 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<int> flushOutbox({
required OfferOutbox outbox,
required OffersCubit cubit,
required List<ShareableLot> 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;
}