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
53
apps/app_seeds/lib/services/offer_mapper.dart
Normal file
53
apps/app_seeds/lib/services/offer_mapper.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
|
||||||
|
import '../db/enums.dart';
|
||||||
|
|
||||||
|
/// Turns a locally-shared lot into a publishable [Offer]. Pure — no I/O — so it
|
||||||
|
/// is easy to test and keeps the privacy seam explicit: only the fields passed
|
||||||
|
/// here ever reach the network (never the whole lot/inventory, never an exact
|
||||||
|
/// address; the area is a coarse geohash chosen by the user).
|
||||||
|
class OfferMapper {
|
||||||
|
const OfferMapper._();
|
||||||
|
|
||||||
|
/// Maps the app's local sharing intent ([OfferStatus]) to the network offer
|
||||||
|
/// type. `private` lots are never published.
|
||||||
|
static OfferType offerTypeFor(OfferStatus sharing) => switch (sharing) {
|
||||||
|
OfferStatus.shared => OfferType.gift,
|
||||||
|
OfferStatus.exchange => OfferType.exchange,
|
||||||
|
OfferStatus.sell => OfferType.sale,
|
||||||
|
OfferStatus.private =>
|
||||||
|
throw ArgumentError('private lots are not published to the network'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Builds the [Offer] for a shared lot. [lotId] gives the offer a stable,
|
||||||
|
/// addressable id (re-publishing updates in place). [areaGeohash] is the
|
||||||
|
/// user's coarse area — the transport coarsens it further on the wire.
|
||||||
|
static Offer fromSharedLot({
|
||||||
|
required String lotId,
|
||||||
|
required String authorPubkeyHex,
|
||||||
|
required String summary,
|
||||||
|
required OfferStatus sharing,
|
||||||
|
required String areaGeohash,
|
||||||
|
String? category,
|
||||||
|
num? priceAmount,
|
||||||
|
String? priceCurrency,
|
||||||
|
String? exchangeTerms,
|
||||||
|
String? imageUrl,
|
||||||
|
DateTime? expiresAt,
|
||||||
|
}) {
|
||||||
|
final type = offerTypeFor(sharing);
|
||||||
|
return Offer(
|
||||||
|
id: lotId,
|
||||||
|
authorPubkeyHex: authorPubkeyHex,
|
||||||
|
summary: summary,
|
||||||
|
type: type,
|
||||||
|
approxGeohash: areaGeohash,
|
||||||
|
category: category,
|
||||||
|
priceAmount: type == OfferType.sale ? priceAmount : null,
|
||||||
|
priceCurrency: type == OfferType.sale ? priceCurrency : null,
|
||||||
|
exchangeTerms: type == OfferType.exchange ? exchangeTerms : null,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
148
apps/app_seeds/test/state/offers_cubit_test.dart
Normal file
148
apps/app_seeds/test/state/offers_cubit_test.dart
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/db/enums.dart';
|
||||||
|
import 'package:tane/services/offer_mapper.dart';
|
||||||
|
import 'package:tane/state/offers_cubit.dart';
|
||||||
|
|
||||||
|
/// In-memory [OfferTransport] for tests: addressable by id, discover matches by
|
||||||
|
/// geohash prefix. No relay, no network.
|
||||||
|
class FakeOfferTransport implements OfferTransport {
|
||||||
|
final List<Offer> _offers = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PublishResult> publish(Offer offer) async {
|
||||||
|
_offers.removeWhere((o) => o.id == offer.id);
|
||||||
|
_offers.add(offer);
|
||||||
|
return PublishResult(accepted: true, transportRef: offer.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Offer> discover(DiscoveryQuery query) {
|
||||||
|
final controller = StreamController<Offer>();
|
||||||
|
for (final o in _offers) {
|
||||||
|
if (o.approxGeohash.startsWith(query.geohashPrefix)) controller.add(o);
|
||||||
|
}
|
||||||
|
return controller.stream; // left open (live), like a real subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> retract(String offerId) async =>
|
||||||
|
_offers.removeWhere((o) => o.id == offerId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('OfferMapper', () {
|
||||||
|
test('maps local sharing intent to the network offer type', () {
|
||||||
|
expect(OfferMapper.offerTypeFor(OfferStatus.shared), OfferType.gift);
|
||||||
|
expect(OfferMapper.offerTypeFor(OfferStatus.exchange), OfferType.exchange);
|
||||||
|
expect(OfferMapper.offerTypeFor(OfferStatus.sell), OfferType.sale);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never publishes a private lot', () {
|
||||||
|
expect(() => OfferMapper.offerTypeFor(OfferStatus.private),
|
||||||
|
throwsArgumentError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('builds an offer, keeping price only for a sale', () {
|
||||||
|
final gift = OfferMapper.fromSharedLot(
|
||||||
|
lotId: 'lot-1',
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: 'Tomate rosa',
|
||||||
|
sharing: OfferStatus.shared,
|
||||||
|
areaGeohash: 'sp3e9',
|
||||||
|
priceAmount: 5, // should be dropped for a gift
|
||||||
|
);
|
||||||
|
expect(gift.type, OfferType.gift);
|
||||||
|
expect(gift.id, 'lot-1');
|
||||||
|
expect(gift.priceAmount, isNull);
|
||||||
|
|
||||||
|
final sale = OfferMapper.fromSharedLot(
|
||||||
|
lotId: 'lot-2',
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: 'Judía',
|
||||||
|
sharing: OfferStatus.sell,
|
||||||
|
areaGeohash: 'sp3e9',
|
||||||
|
priceAmount: 3,
|
||||||
|
priceCurrency: 'G1',
|
||||||
|
);
|
||||||
|
expect(sale.type, OfferType.sale);
|
||||||
|
expect(sale.priceAmount, 3);
|
||||||
|
expect(sale.priceCurrency, 'G1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('OffersCubit', () {
|
||||||
|
test('discovers published offers by geohash prefix', () async {
|
||||||
|
final transport = FakeOfferTransport();
|
||||||
|
await transport.publish(OfferMapper.fromSharedLot(
|
||||||
|
lotId: 'lot-1',
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: 'Tomate rosa de Barbastro',
|
||||||
|
sharing: OfferStatus.shared,
|
||||||
|
areaGeohash: 'sp3e9',
|
||||||
|
));
|
||||||
|
final cubit = OffersCubit(transport);
|
||||||
|
|
||||||
|
await cubit.discover('sp3');
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(cubit.state.hasSearched, isTrue);
|
||||||
|
expect(cubit.state.searching, isFalse);
|
||||||
|
expect(cubit.state.offers, hasLength(1));
|
||||||
|
expect(cubit.state.offers.single.summary, 'Tomate rosa de Barbastro');
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a different area finds nothing', () async {
|
||||||
|
final transport = FakeOfferTransport();
|
||||||
|
await transport.publish(OfferMapper.fromSharedLot(
|
||||||
|
lotId: 'lot-1',
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: 'Pimiento',
|
||||||
|
sharing: OfferStatus.shared,
|
||||||
|
areaGeohash: 'sp3e9',
|
||||||
|
));
|
||||||
|
final cubit = OffersCubit(transport);
|
||||||
|
await cubit.discover('u09');
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(cubit.state.offers, isEmpty);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publish reports the transport verdict', () async {
|
||||||
|
final cubit = OffersCubit(FakeOfferTransport());
|
||||||
|
final result = await cubit.publish(OfferMapper.fromSharedLot(
|
||||||
|
lotId: 'lot-9',
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: 'Calabaza',
|
||||||
|
sharing: OfferStatus.exchange,
|
||||||
|
areaGeohash: 'sp3e9',
|
||||||
|
exchangeTerms: 'a cambio de legumbre',
|
||||||
|
));
|
||||||
|
expect(result.accepted, isTrue);
|
||||||
|
expect(cubit.state.publishing, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline (no transport): degrades gracefully, never throws', () async {
|
||||||
|
final cubit = OffersCubit(null);
|
||||||
|
expect(cubit.isOnline, isFalse);
|
||||||
|
await cubit.discover('sp3');
|
||||||
|
expect(cubit.state.error, 'offline');
|
||||||
|
final result = await cubit.publish(OfferMapper.fromSharedLot(
|
||||||
|
lotId: 'x',
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: 'x',
|
||||||
|
sharing: OfferStatus.shared,
|
||||||
|
areaGeohash: 'sp3e9',
|
||||||
|
));
|
||||||
|
expect(result.accepted, isFalse);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue