feat(block2): offers logic layer — OffersCubit + OfferMapper (no UI yet)
The testable core of the offers UI slice, transport-agnostic: - OfferMapper: pure app-sharing-intent (OfferStatus) -> network Offer/OfferType mapping; keeps price only for sales, refuses to publish private lots. - OffersCubit: discover(geohash) streams results; publish() reports the verdict; depends on the OfferTransport interface (fake in tests) and degrades gracefully offline (null transport = social layer unavailable, never throws). 8 tests green (fake in-memory transport, no relay). Screen/routing/i18n come next — they need two product decisions (coarse-area capture, relay list).
This commit is contained in:
parent
70905a0578
commit
7fd87e6e62
3 changed files with 319 additions and 0 deletions
118
apps/app_seeds/lib/state/offers_cubit.dart
Normal file
118
apps/app_seeds/lib/state/offers_cubit.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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<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) : super(const OffersState());
|
||||
|
||||
final OfferTransport? _transport;
|
||||
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();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue