tane/apps/app_seeds/lib/state/offers_cubit.dart
vjrj 08d8dc1172 feat(block2): offline outbox — share now, publish when connected
Sender-side offline delivery hardening.
- OfferOutbox: a keystore-backed (no plaintext) queue of lot ids to publish
  later. Stores ids, not offers, so on flush each offer is rebuilt from the
  lot's CURRENT state — an offline edit is respected, a deleted lot drops out.
- flushOutbox(): on reconnect, republishes queued lots and clears them (no-op
  offline; drops ids whose lot is gone).
- Market screen: sharing offline parks the lots ('we'll share these when you're
  connected'); opening the market online auto-flushes the queue first.
- Wired via DI + TaneApp(outbox); i18n en/es/pt.

Tests: 4 outbox + 3 flush + market UI, 20 green. Analyzer clean for new code.
2026-07-10 10:45:53 +02:00

209 lines
6.8 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 '../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<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;
}
}
/// 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,
);
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<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);
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<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;
}