From f8f73c4153c845cef5f1cbc30359112b43338302 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 10:18:30 +0200 Subject: [PATCH] feat(block2): publish my seeds to the network (completes publish->discover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half of the market: share your marked lots as offers. - VarietyRepository.shareableLots(): non-private lots WITH their id (stable, updatable offer id) + the variety label. Private lots never leave the device. - OffersCubit.publishLots(): maps each via OfferMapper and publishes, tagged with the coarse area + signed by the derived key; counts acceptances; no-op offline or with no area. - Market screen: a 'share my seeds' action (online only) — publishes, then re-discovers to show them; 'mark some seeds first' when nothing's shared. - i18n en/es/pt. 14 tests green (repo query, cubit publish, market UI); analyzer clean for new code. --- .../lib/data/variety_repository.dart | 63 +++++++++++++++++ apps/app_seeds/lib/i18n/en.i18n.json | 5 +- apps/app_seeds/lib/i18n/es.i18n.json | 5 +- apps/app_seeds/lib/i18n/pt.i18n.json | 5 +- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 12 ++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 6 ++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 6 ++ apps/app_seeds/lib/state/offers_cubit.dart | 35 ++++++++++ apps/app_seeds/lib/ui/market_screen.dart | 38 +++++++++++ .../test/data/shareable_lots_test.dart | 43 ++++++++++++ .../test/state/offers_cubit_test.dart | 67 +++++++++++++++++++ 12 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 apps/app_seeds/test/data/shareable_lots_test.dart diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index b350a06..30e1ba4 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -117,6 +117,33 @@ class SharedCatalogEntry extends Equatable { ]; } +/// A lot the user has marked for sharing, carrying the lot [lotId] (so the +/// published offer has a stable, updatable id) plus the summary a network offer +/// needs. Distinct from [SharedCatalogEntry], which is for the printable local +/// catalog and has no id. +class ShareableLot extends Equatable { + const ShareableLot({ + required this.lotId, + required this.summary, + required this.offerStatus, + this.category, + this.harvestYear, + }); + + final String lotId; + + /// What to publish: the variety label (never the full inventory). + final String summary; + + /// Never [OfferStatus.private] — private lots don't leave the device. + final OfferStatus offerStatus; + final String? category; + final int? harvestYear; + + @override + List get props => [lotId, summary, offerStatus, category, harvestYear]; +} + /// One germination test on a lot; [rate] is derived (0..1), null when it can't /// be computed. class GerminationEntry extends Equatable { @@ -1210,6 +1237,42 @@ class VarietyRepository { return entries; } + /// Lots the user marked to share (not private), each with its id and the + /// variety label — the input for publishing offers to the network (Block 2). + Future> shareableLots() async { + final lots = + await (_db.select(_db.lots)..where( + (l) => + l.isDeleted.equals(false) & + l.offerStatus.equalsValue(OfferStatus.private).not(), + )) + .get(); + if (lots.isEmpty) return const []; + + final varietyIds = lots.map((l) => l.varietyId).toSet(); + final varieties = + await (_db.select(_db.varieties)..where( + (v) => + v.id.isIn(varietyIds) & + v.isDeleted.equals(false) & + v.isDraft.equals(false), + )) + .get(); + final byId = {for (final v in varieties) v.id: v}; + + return [ + for (final l in lots) + if (byId[l.varietyId] case final v?) + ShareableLot( + lotId: l.id, + summary: v.label, + offerStatus: l.offerStatus, + category: v.category, + harvestYear: l.harvestYear, + ), + ]; + } + /// Soft-deletes a lot (tombstone); it leaves the variety's lot list. Future softDeleteLot(String lotId) async { final (_, updated) = await _stamp(); diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 7d60beb..9110667 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -348,6 +348,9 @@ "serversLabel": "Web address", "save": "Save", "saved": "Saved", - "wanted": "Wanted" + "wanted": "Wanted", + "shareMine": "Share my seeds", + "sharedCount": "Shared {n} seeds nearby", + "nothingToShare": "Mark some seeds to give, swap or sell first" } } diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 5a86fc4..5957e90 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -348,6 +348,9 @@ "serversLabel": "Dirección web", "save": "Guardar", "saved": "Guardado", - "wanted": "Busco" + "wanted": "Busco", + "shareMine": "Compartir mis semillas", + "sharedCount": "Compartidas {n} semillas", + "nothingToShare": "Marca antes algunas semillas para regalar, cambiar o vender" } } diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 0937455..843b464 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -344,6 +344,9 @@ "serversLabel": "Endereço web", "save": "Guardar", "saved": "Guardado", - "wanted": "Procuro" + "wanted": "Procuro", + "shareMine": "Partilhar as minhas sementes", + "sharedCount": "Partilhadas {n} sementes", + "nothingToShare": "Marca primeiro algumas sementes para dar, trocar ou vender" } } diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index d704ee1..5157afc 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 3 -/// Strings: 929 (309 per locale) +/// Strings: 938 (312 per locale) /// -/// Built on 2026-07-10 at 01:15 UTC +/// Built on 2026-07-10 at 08:17 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 50de527..55b508a 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1105,6 +1105,15 @@ class Translations$market$en { /// en: 'Wanted' String get wanted => 'Wanted'; + + /// en: 'Share my seeds' + String get shareMine => 'Share my seeds'; + + /// en: 'Shared {n} seeds nearby' + String sharedCount({required Object n}) => 'Shared ${n} seeds nearby'; + + /// en: 'Mark some seeds to give, swap or sell first' + String get nothingToShare => 'Mark some seeds to give, swap or sell first'; } // Path: intro.slides @@ -1875,6 +1884,9 @@ extension on Translations { 'market.save' => 'Save', 'market.saved' => 'Saved', 'market.wanted' => 'Wanted', + 'market.shareMine' => 'Share my seeds', + 'market.sharedCount' => ({required Object n}) => 'Shared ${n} seeds nearby', + 'market.nothingToShare' => 'Mark some seeds to give, swap or sell first', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 979d1b4..fe5c80a 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -619,6 +619,9 @@ class _Translations$market$es extends Translations$market$en { @override String get save => 'Guardar'; @override String get saved => 'Guardado'; @override String get wanted => 'Busco'; + @override String get shareMine => 'Compartir mis semillas'; + @override String sharedCount({required Object n}) => 'Compartidas ${n} semillas'; + @override String get nothingToShare => 'Marca antes algunas semillas para regalar, cambiar o vender'; } // Path: intro.slides @@ -1273,6 +1276,9 @@ extension on TranslationsEs { 'market.save' => 'Guardar', 'market.saved' => 'Guardado', 'market.wanted' => 'Busco', + 'market.shareMine' => 'Compartir mis semillas', + 'market.sharedCount' => ({required Object n}) => 'Compartidas ${n} semillas', + 'market.nothingToShare' => 'Marca antes algunas semillas para regalar, cambiar o vender', _ => null, }; } diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index e00303a..6f649e5 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -615,6 +615,9 @@ class _Translations$market$pt extends Translations$market$en { @override String get save => 'Guardar'; @override String get saved => 'Guardado'; @override String get wanted => 'Procuro'; + @override String get shareMine => 'Partilhar as minhas sementes'; + @override String sharedCount({required Object n}) => 'Partilhadas ${n} sementes'; + @override String get nothingToShare => 'Marca primeiro algumas sementes para dar, trocar ou vender'; } // Path: intro.slides @@ -1265,6 +1268,9 @@ extension on TranslationsPt { 'market.save' => 'Guardar', 'market.saved' => 'Guardado', 'market.wanted' => 'Procuro', + 'market.shareMine' => 'Partilhar as minhas sementes', + 'market.sharedCount' => ({required Object n}) => 'Partilhadas ${n} sementes', + 'market.nothingToShare' => 'Marca primeiro algumas sementes para dar, trocar ou vender', _ => null, }; } diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index edf46cd..667fee0 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -4,6 +4,8 @@ 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/social_service.dart'; import '../services/social_settings.dart'; @@ -119,6 +121,39 @@ class OffersCubit extends Cubit { } } + /// 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 publishLots( + List 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 close() async { await _sub?.cancel(); diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index ea73486..165180c 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -2,6 +2,7 @@ import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -57,6 +58,36 @@ class _MarketScreenState extends State { if (changed == true) await _init(); } + /// Publishes the user's shared lots as offers to the network, tagged with the + /// coarse area. Prompts for setup when the area isn't set. + Future _shareMine() async { + final cubit = _cubit; + if (cubit == null || !cubit.isOnline) return; + final repo = context.read(); + final messenger = ScaffoldMessenger.of(context); + final t = context.t; + final area = await widget.settings.areaGeohash(); + if (!mounted) return; + if (area.isEmpty) { + await _openConfig(); + return; + } + final lots = await repo.shareableLots(); + if (!mounted) return; + if (lots.isEmpty) { + messenger.showSnackBar(SnackBar(content: Text(t.market.nothingToShare))); + return; + } + final count = await cubit.publishLots( + lots, + authorPubkeyHex: widget.social.publicKeyHex, + areaGeohash: area, + ); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(t.market.sharedCount(n: count)))); + await cubit.discover(area); // refresh — now including the just-shared lots + } + @override void dispose() { _cubit?.close(); @@ -71,6 +102,13 @@ class _MarketScreenState extends State { appBar: AppBar( title: Text(t.market.title), actions: [ + if (cubit != null && cubit.isOnline) + IconButton( + key: const Key('market.shareMine'), + icon: const Icon(Icons.ios_share_outlined), + tooltip: t.market.shareMine, + onPressed: _shareMine, + ), IconButton( key: const Key('market.config'), icon: const Icon(Icons.tune), diff --git a/apps/app_seeds/test/data/shareable_lots_test.dart b/apps/app_seeds/test/data/shareable_lots_test.dart new file mode 100644 index 0000000..69c6bdd --- /dev/null +++ b/apps/app_seeds/test/data/shareable_lots_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + test('shareableLots returns non-private lots with their id and label', + () async { + final repo = newTestRepository(db); + final varietyId = await repo.addQuickVariety( + label: 'Tomate rosa', + category: 'Solanaceae', + ); + final sharedId = await repo.addLot( + varietyId: varietyId, + offerStatus: OfferStatus.exchange, + ); + + final lots = await repo.shareableLots(); + final shared = lots.where((l) => l.lotId == sharedId).toList(); + expect(shared, hasLength(1)); + expect(shared.single.summary, 'Tomate rosa'); + expect(shared.single.offerStatus, OfferStatus.exchange); + expect(shared.single.category, 'Solanaceae'); + }); + + test('private lots are never shareable', () async { + final repo = newTestRepository(db); + final varietyId = await repo.addQuickVariety(label: 'Pimiento'); + final privateId = await repo.addLot( + varietyId: varietyId, + offerStatus: OfferStatus.private, + ); + + final lots = await repo.shareableLots(); + expect(lots.where((l) => l.lotId == privateId), isEmpty); + }); +} diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index 1a2fe4e..a867b49 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/enums.dart'; import 'package:tane/services/offer_mapper.dart'; import 'package:tane/state/offers_cubit.dart'; @@ -129,6 +130,72 @@ void main() { await cubit.close(); }); + test('publishLots publishes each shared lot and counts acceptances', + () async { + final transport = FakeOfferTransport(); + final cubit = OffersCubit(transport); + final count = await cubit.publishLots( + const [ + ShareableLot( + lotId: 'lot-1', + summary: 'Tomate', + offerStatus: OfferStatus.shared, + category: 'Solanaceae', + ), + ShareableLot( + lotId: 'lot-2', + summary: 'Judía', + offerStatus: OfferStatus.exchange, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + expect(count, 2); + expect(cubit.state.publishing, isFalse); + + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers, hasLength(2)); + await cubit.close(); + }); + + test('publishLots is a no-op offline or with no area', () async { + final offline = OffersCubit(null); + expect( + await offline.publishLots( + const [ + ShareableLot( + lotId: 'x', + summary: 'x', + offerStatus: OfferStatus.shared, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ), + 0, + ); + await offline.close(); + + final noArea = OffersCubit(FakeOfferTransport()); + expect( + await noArea.publishLots( + const [ + ShareableLot( + lotId: 'x', + summary: 'x', + offerStatus: OfferStatus.shared, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: '', + ), + 0, + ); + await noArea.close(); + }); + test('offline (no transport): degrades gracefully, never throws', () async { final cubit = OffersCubit(null); expect(cubit.isOnline, isFalse);