From a59adcca6bcab01b70c5e84a983720cb5ad392b9 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 02:49:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(block2):=20offers=20logic=20layer=20?= =?UTF-8?q?=E2=80=94=20OffersCubit=20+=20OfferMapper=20(no=20UI=20yet)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- apps/app_seeds/lib/services/offer_mapper.dart | 53 +++++++ apps/app_seeds/lib/state/offers_cubit.dart | 118 ++++++++++++++ .../test/state/offers_cubit_test.dart | 148 ++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 apps/app_seeds/lib/services/offer_mapper.dart create mode 100644 apps/app_seeds/lib/state/offers_cubit.dart create mode 100644 apps/app_seeds/test/state/offers_cubit_test.dart diff --git a/apps/app_seeds/lib/services/offer_mapper.dart b/apps/app_seeds/lib/services/offer_mapper.dart new file mode 100644 index 0000000..9d64233 --- /dev/null +++ b/apps/app_seeds/lib/services/offer_mapper.dart @@ -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, + ); + } +} diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart new file mode 100644 index 0000000..cfdc29b --- /dev/null +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -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 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? 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 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 { + OffersCubit(this._transport) : super(const OffersState()); + + final OfferTransport? _transport; + StreamSubscription? _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 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 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 close() async { + await _sub?.cancel(); + return super.close(); + } +} diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart new file mode 100644 index 0000000..1a2fe4e --- /dev/null +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -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 _offers = []; + + @override + Future publish(Offer offer) async { + _offers.removeWhere((o) => o.id == offer.id); + _offers.add(offer); + return PublishResult(accepted: true, transportRef: offer.id); + } + + @override + Stream discover(DiscoveryQuery query) { + final controller = StreamController(); + 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 retract(String offerId) async => + _offers.removeWhere((o) => o.id == offerId); + + @override + Future 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(); + }); + }); +}