From fa53295439db6533ef9049ed200d28dc1eaac84b Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 21:12:36 +0200 Subject: [PATCH] feat(market): host and publish the cover photo with each offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the Blossom MediaTransport into publishing: shareable lots carry their varietyId, publishLots uploads the lot's cover photo and puts the returned URL on the offer (best-effort — a hosting failure still publishes the offer, just without a photo). Add a configurable media server (Comunes default, empty to opt out) to settings and the market advanced config. --- .../lib/data/variety_repository.dart | 14 ++- apps/app_seeds/lib/i18n/ast.i18n.json | 4 +- apps/app_seeds/lib/i18n/en.i18n.json | 4 +- apps/app_seeds/lib/i18n/es.i18n.json | 4 +- apps/app_seeds/lib/i18n/pt.i18n.json | 4 +- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 4 + apps/app_seeds/lib/i18n/strings_en.g.dart | 8 ++ apps/app_seeds/lib/i18n/strings_es.g.dart | 4 + apps/app_seeds/lib/i18n/strings_pt.g.dart | 4 + .../lib/services/social_service.dart | 33 ++++- .../lib/services/social_settings.dart | 18 +++ apps/app_seeds/lib/state/offers_cubit.dart | 53 +++++++- apps/app_seeds/lib/ui/market_screen.dart | 20 +++- .../test/services/social_settings_test.dart | 9 ++ .../test/state/offers_cubit_test.dart | 113 +++++++++++++++++- .../test/social/nostr_transports_test.dart | 2 + 17 files changed, 280 insertions(+), 22 deletions(-) diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 4c30010..21955d0 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -174,6 +174,7 @@ class SeedLabelEntry extends Equatable { class ShareableLot extends Equatable { const ShareableLot({ required this.lotId, + required this.varietyId, required this.summary, required this.offerStatus, this.category, @@ -183,6 +184,10 @@ class ShareableLot extends Equatable { final String lotId; + /// The lot's variety, so the publish step can fetch its cover photo to host + /// as the offer image. Not published itself — never leaves the device. + final String varietyId; + /// What to publish: the variety label (never the full inventory). final String summary; @@ -197,7 +202,7 @@ class ShareableLot extends Equatable { @override List get props => - [lotId, summary, offerStatus, category, harvestYear, isOrganic]; + [lotId, varietyId, summary, offerStatus, category, harvestYear, isOrganic]; } /// One germination test on a lot; [rate] is derived (0..1), null when it can't @@ -727,6 +732,12 @@ class VarietyRepository { return byVariety; } + /// The cover photo (lowest `sortOrder`) for a single variety, or null when it + /// has none. Used by the publish step to host an offer's image; reuses the + /// same "first photo" rule as the inventory avatar. + Future coverPhotoFor(String varietyId) async => + (await _firstPhotosFor([varietyId]))[varietyId]; + /// Maps each of [speciesIds] to its scientific name (one query). Future> _scientificNamesFor( Set speciesIds, @@ -1461,6 +1472,7 @@ class VarietyRepository { if (byId[l.varietyId] case final v?) ShareableLot( lotId: l.id, + varietyId: v.id, summary: v.label, offerStatus: l.offerStatus, category: v.category, diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 31dd7a0..4fc4cec 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -483,7 +483,9 @@ "noProfile": "Esta persona entá nun compartió'l so perfil", "copyId": "Copiar códigu", "idCopied": "Códigu copiáu", - "photo": "Semeya" + "photo": "Semeya", + "mediaServerLabel": "Sirvidor de semeyes", + "mediaServerHelp": "Ónde s'agospien les semeyes de les ufiertes pa que otres persones les vean. Déxalo baleru pa compartir ensin semeyes." }, "profile": { "title": "El to perfil", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index a746708..e663b1a 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -486,7 +486,9 @@ "noProfile": "This person hasn't shared a profile yet", "copyId": "Copy code", "idCopied": "Code copied", - "photo": "Photo" + "photo": "Photo", + "mediaServerLabel": "Photo server", + "mediaServerHelp": "Where offer photos are hosted so others can see them. Leave empty to share without photos." }, "profile": { "title": "Your profile", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 0f1e1a6..32c1257 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -485,7 +485,9 @@ "noProfile": "Esta persona aún no ha compartido su perfil", "copyId": "Copiar código", "idCopied": "Código copiado", - "photo": "Foto" + "photo": "Foto", + "mediaServerLabel": "Servidor de fotos", + "mediaServerHelp": "Dónde se alojan las fotos de las ofertas para que otras personas las vean. Déjalo vacío para compartir sin fotos." }, "profile": { "title": "Tu perfil", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 72a30db..e8fb01d 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -482,7 +482,9 @@ "noProfile": "Esta pessoa ainda não partilhou o seu perfil", "copyId": "Copiar código", "idCopied": "Código copiado", - "photo": "Foto" + "photo": "Foto", + "mediaServerLabel": "Servidor de fotos", + "mediaServerHelp": "Onde as fotos das ofertas ficam alojadas para que outras pessoas as vejam. Deixe vazio para partilhar sem fotos." }, "profile": { "title": "O teu perfil", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 0277a3c..c65da8b 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: 4 -/// Strings: 1552 (388 per locale) +/// Strings: 1560 (390 per locale) /// -/// Built on 2026-07-10 at 18:55 UTC +/// Built on 2026-07-10 at 19:08 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 1d7ac78..1c8c3f6 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -692,6 +692,8 @@ class _Translations$market$ast extends Translations$market$en { @override String get copyId => 'Copiar códigu'; @override String get idCopied => 'Códigu copiáu'; @override String get photo => 'Semeya'; + @override String get mediaServerLabel => 'Sirvidor de semeyes'; + @override String get mediaServerHelp => 'Ónde s\'agospien les semeyes de les ufiertes pa que otres persones les vean. Déxalo baleru pa compartir ensin semeyes.'; } // Path: profile @@ -1458,6 +1460,8 @@ extension on TranslationsAst { 'market.copyId' => 'Copiar códigu', 'market.idCopied' => 'Códigu copiáu', 'market.photo' => 'Semeya', + 'market.mediaServerLabel' => 'Sirvidor de semeyes', + 'market.mediaServerHelp' => 'Ónde s\'agospien les semeyes de les ufiertes pa que otres persones les vean. Déxalo baleru pa compartir ensin semeyes.', 'profile.title' => 'El to perfil', 'profile.name' => 'Nome', 'profile.nameHint' => 'Cómo te ven los demás', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 9ca3bab..47e7b21 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1285,6 +1285,12 @@ class Translations$market$en { /// en: 'Photo' String get photo => 'Photo'; + + /// en: 'Photo server' + String get mediaServerLabel => 'Photo server'; + + /// en: 'Where offer photos are hosted so others can see them. Leave empty to share without photos.' + String get mediaServerHelp => 'Where offer photos are hosted so others can see them. Leave empty to share without photos.'; } // Path: profile @@ -2224,6 +2230,8 @@ extension on Translations { 'market.copyId' => 'Copy code', 'market.idCopied' => 'Code copied', 'market.photo' => 'Photo', + 'market.mediaServerLabel' => 'Photo server', + 'market.mediaServerHelp' => 'Where offer photos are hosted so others can see them. Leave empty to share without photos.', 'profile.title' => 'Your profile', 'profile.name' => 'Display name', 'profile.nameHint' => 'How others see you', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 797f3a6..c1062a8 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -694,6 +694,8 @@ class _Translations$market$es extends Translations$market$en { @override String get copyId => 'Copiar código'; @override String get idCopied => 'Código copiado'; @override String get photo => 'Foto'; + @override String get mediaServerLabel => 'Servidor de fotos'; + @override String get mediaServerHelp => 'Dónde se alojan las fotos de las ofertas para que otras personas las vean. Déjalo vacío para compartir sin fotos.'; } // Path: profile @@ -1462,6 +1464,8 @@ extension on TranslationsEs { 'market.copyId' => 'Copiar código', 'market.idCopied' => 'Código copiado', 'market.photo' => 'Foto', + 'market.mediaServerLabel' => 'Servidor de fotos', + 'market.mediaServerHelp' => 'Dónde se alojan las fotos de las ofertas para que otras personas las vean. Déjalo vacío para compartir sin fotos.', 'profile.title' => 'Tu perfil', 'profile.name' => 'Nombre', 'profile.nameHint' => 'Cómo te ven los demás', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index f74354e..46e92de 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -691,6 +691,8 @@ class _Translations$market$pt extends Translations$market$en { @override String get copyId => 'Copiar código'; @override String get idCopied => 'Código copiado'; @override String get photo => 'Foto'; + @override String get mediaServerLabel => 'Servidor de fotos'; + @override String get mediaServerHelp => 'Onde as fotos das ofertas ficam alojadas para que outras pessoas as vejam. Deixe vazio para partilhar sem fotos.'; } // Path: profile @@ -1456,6 +1458,8 @@ extension on TranslationsPt { 'market.copyId' => 'Copiar código', 'market.idCopied' => 'Código copiado', 'market.photo' => 'Foto', + 'market.mediaServerLabel' => 'Servidor de fotos', + 'market.mediaServerHelp' => 'Onde as fotos das ofertas ficam alojadas para que outras pessoas as vejam. Deixe vazio para partilhar sem fotos.', 'profile.title' => 'O teu perfil', 'profile.name' => 'Nome', 'profile.nameHint' => 'Como os outros te veem', diff --git a/apps/app_seeds/lib/services/social_service.dart b/apps/app_seeds/lib/services/social_service.dart index 87463de..2a44e13 100644 --- a/apps/app_seeds/lib/services/social_service.dart +++ b/apps/app_seeds/lib/services/social_service.dart @@ -39,20 +39,34 @@ class SocialService { /// Opens a session against [relayUrls]: one fault-tolerant pool carrying all /// three transports (offers, messaging, trust). Relays that are down are /// skipped; throws only when none are reachable. Caller closes it. - Future openSession(List relayUrls) async { + /// + /// [mediaServerUrl] (a Blossom endpoint) enables offer photos: when set, the + /// session exposes a [MediaTransport] to host images. It rides HTTP, not the + /// relay pool, so it's independent of relay reachability. + Future openSession( + List relayUrls, { + String? mediaServerUrl, + }) async { final pool = await RelayPool.connect(relayUrls, identity: identity); - return SocialSession(pool); + return SocialSession(pool, mediaServerUrl: mediaServerUrl); } } /// One relay channel with the three transports on top — the -/// "one channel, three interfaces" shape, at the app layer. +/// "one channel, three interfaces" shape, at the app layer. Media rides its own +/// HTTP endpoint (Blossom), so it's a fourth transport but not on the channel. class SocialSession { - SocialSession(this._channel) + SocialSession(this._channel, {String? mediaServerUrl}) : offers = NostrOfferTransport(_channel), messages = NostrMessageTransport(_channel), trust = NostrTrustTransport(_channel), - profile = NostrProfileTransport(_channel); + profile = NostrProfileTransport(_channel), + media = (mediaServerUrl != null && mediaServerUrl.trim().isNotEmpty) + ? BlossomMediaTransport( + Uri.parse(mediaServerUrl.trim()), + _channel.privateKeyHex, + ) + : null; final NostrChannel _channel; @@ -61,7 +75,14 @@ class SocialSession { final TrustTransport trust; final ProfileTransport profile; - Future close() => _channel.close(); + /// Hosts offer photos, or null when no media server is configured (offers then + /// publish without images). + final MediaTransport? media; + + Future close() async { + await media?.close(); + await _channel.close(); + } } Uint8List _hexToBytes(String hex) { diff --git a/apps/app_seeds/lib/services/social_settings.dart b/apps/app_seeds/lib/services/social_settings.dart index 6cdcb7a..78cb8d8 100644 --- a/apps/app_seeds/lib/services/social_settings.dart +++ b/apps/app_seeds/lib/services/social_settings.dart @@ -17,6 +17,7 @@ class SocialSettings { static const _areaKey = 'tane.social.area_geohash'; static const _relaysKey = 'tane.social.relays'; static const _searchPrecisionKey = 'tane.social.search_precision'; + static const _mediaServerKey = 'tane.social.media_server'; /// How wide "your zone" searches, as a geohash prefix length: 5 ≈ ±2.4 km /// ("very close"), 4 ≈ ±20 km ("around here"), 3 ≈ ±78 km ("my region"). @@ -39,6 +40,11 @@ class SocialSettings { 'wss://relay.primal.net', ]; + /// Blossom media server used to host offer photos so peers can see them. The + /// Comunes-run server is the non-commercial default; the user can point this + /// at any Blossom server, or clear it to publish offers without photos. + static const String defaultMediaServer = 'https://blossom.comunes.org'; + /// The user's coarse area as a low-precision geohash (may be empty). Set /// manually or from device location; the transport coarsens it further. Future areaGeohash() async => (await _store.read(_areaKey)) ?? ''; @@ -73,6 +79,18 @@ class SocialSettings { Future setSearchPrecision(int precision) => _store.write(_searchPrecisionKey, '${_clampPrecision(precision)}'); + /// The Blossom media server for offer photos. Falls back to + /// [defaultMediaServer] until the user configures one; an explicitly saved + /// empty value turns photo hosting off (offers still publish, without images). + Future mediaServerUrl() async { + final raw = await _store.read(_mediaServerKey); + if (raw == null) return defaultMediaServer; // never configured + return raw.trim(); + } + + Future setMediaServerUrl(String url) => + _store.write(_mediaServerKey, url.trim()); + int _clampPrecision(int p) => p < minSearchPrecision ? minSearchPrecision : (p > maxSearchPrecision ? maxSearchPrecision : p); diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index 021def8..4768328 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:commons_core/commons_core.dart'; import 'package:equatable/equatable.dart'; @@ -134,12 +135,26 @@ class OffersState extends Equatable { /// UI stays offline-tolerant (a null transport = the social layer is unavailable /// and the screen degrades gracefully). class OffersCubit extends Cubit { - OffersCubit(this._transport, {Future Function()? onDispose}) - : _onDispose = onDispose, + OffersCubit( + this._transport, { + MediaTransport? media, + Future Function(String varietyId)? coverPhoto, + Future Function()? onDispose, + }) : _media = media, + _coverPhoto = coverPhoto, + _onDispose = onDispose, super(const OffersState()); final OfferTransport? _transport; + /// Hosts an offer's cover photo so peers can see it; null when no media server + /// is configured, in which case offers publish without images. + final MediaTransport? _media; + + /// Fetches a variety's cover photo bytes (for hosting); null in tests or when + /// no inventory repo is wired. + final Future Function(String varietyId)? _coverPhoto; + /// 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 Function()? _onDispose; @@ -269,6 +284,7 @@ class OffersCubit extends Cubit { areaGeohash: areaGeohash, category: lot.category, isOrganic: lot.isOrganic, + imageUrl: await _hostCoverPhoto(lot.varietyId), ); final result = await transport.publish(offer); if (result.accepted) accepted++; @@ -281,6 +297,23 @@ class OffersCubit extends Cubit { return accepted; } + /// Uploads the lot's cover photo to the media server and returns its URL, or + /// null when there's no media server, no photo, or the upload fails. Hosting + /// is best-effort: a missing image never blocks publishing the offer. + Future _hostCoverPhoto(String varietyId) async { + final media = _media; + final coverPhoto = _coverPhoto; + if (media == null || coverPhoto == null) return null; + try { + final Uint8List? bytes = await coverPhoto(varietyId); + if (bytes == null || bytes.isEmpty) return null; + final url = await media.upload(bytes, mimeType: 'image/jpeg'); + return url.toString(); + } catch (_) { + return null; // degrade: publish the offer without a photo + } + } + @override Future close() async { _searchTimeout?.cancel(); @@ -295,13 +328,21 @@ class OffersCubit extends Cubit { /// fails), the cubit is created with a null transport and the screen still opens. Future createOffersCubit( SocialService social, - SocialSettings settings, -) async { + SocialSettings settings, { + VarietyRepository? repository, +}) 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); + final mediaServer = await settings.mediaServerUrl(); + final session = + await social.openSession(relays, mediaServerUrl: mediaServer); + return OffersCubit( + session.offers, + media: session.media, + coverPhoto: repository?.coverPhotoFor, + onDispose: session.close, + ); } catch (_) { return OffersCubit(null); // offline / no relay reachable } diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index ef4a753..bee0e9f 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -58,7 +58,11 @@ class _MarketScreenState extends State { final repo = widget.outbox != null ? context.read() : null; setState(() => _loading = true); - final cubit = await createOffersCubit(widget.social, widget.settings); + final cubit = await createOffersCubit( + widget.social, + widget.settings, + repository: repo, + ); final area = await widget.settings.areaGeohash(); if (!mounted) { await cubit.close(); @@ -599,6 +603,7 @@ class _ConfigSheet extends StatefulWidget { class _ConfigSheetState extends State<_ConfigSheet> { final _area = TextEditingController(); final _servers = TextEditingController(); + final _mediaServer = TextEditingController(); bool _loading = true; bool _locationBusy = false; String? _locationError; @@ -613,6 +618,7 @@ class _ConfigSheetState extends State<_ConfigSheet> { Future _load() async { _area.text = await widget.settings.areaGeohash(); _servers.text = (await widget.settings.relayUrls()).join('\n'); + _mediaServer.text = await widget.settings.mediaServerUrl(); _precision = await widget.settings.searchPrecision(); if (mounted) setState(() => _loading = false); } @@ -620,6 +626,7 @@ class _ConfigSheetState extends State<_ConfigSheet> { Future _save() async { await widget.settings.setAreaGeohash(_area.text); await widget.settings.setRelayUrls(_servers.text.split('\n')); + await widget.settings.setMediaServerUrl(_mediaServer.text); await widget.settings.setSearchPrecision(_precision); if (mounted) Navigator.of(context).pop(true); } @@ -649,6 +656,7 @@ class _ConfigSheetState extends State<_ConfigSheet> { void dispose() { _area.dispose(); _servers.dispose(); + _mediaServer.dispose(); super.dispose(); } @@ -788,6 +796,16 @@ class _ConfigSheetState extends State<_ConfigSheet> { helperMaxLines: 2, ), ), + const SizedBox(height: 14), + TextField( + key: const Key('market.mediaServer'), + controller: _mediaServer, + decoration: InputDecoration( + labelText: t.market.mediaServerLabel, + helperText: t.market.mediaServerHelp, + helperMaxLines: 2, + ), + ), ], ), ), diff --git a/apps/app_seeds/test/services/social_settings_test.dart b/apps/app_seeds/test/services/social_settings_test.dart index e8c8673..7dd571e 100644 --- a/apps/app_seeds/test/services/social_settings_test.dart +++ b/apps/app_seeds/test/services/social_settings_test.dart @@ -27,6 +27,15 @@ void main() { expect(await settings.relayUrls(), isEmpty); }); + test('media server defaults, is configurable, and can be turned off', + () async { + expect(await settings.mediaServerUrl(), SocialSettings.defaultMediaServer); + await settings.setMediaServerUrl(' https://blossom.example '); + expect(await settings.mediaServerUrl(), 'https://blossom.example'); + await settings.setMediaServerUrl(''); // explicit empty = no photo hosting + expect(await settings.mediaServerUrl(), ''); + }); + test('stores and normalises the area geohash (trim + lowercase)', () async { await settings.setAreaGeohash(' SP3E9 '); expect(await settings.areaGeohash(), 'sp3e9'); diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index 2698c49..6545373 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -40,6 +41,24 @@ class FakeOfferTransport implements OfferTransport { Future close() async {} } +/// In-memory [MediaTransport]: hands back a fixed URL, or throws when [fail] is +/// set (to prove the publish step degrades to no-image instead of erroring). +class FakeMediaTransport implements MediaTransport { + FakeMediaTransport({this.fail = false}); + final bool fail; + int uploads = 0; + + @override + Future upload(Uint8List bytes, {required String mimeType}) async { + uploads++; + if (fail) throw const MediaUploadException('boom', 500); + return Uri.parse('https://media.test/hosted.jpg'); + } + + @override + Future close() async {} +} + /// Emits every published offer TWICE on discover — the way a relay legitimately /// resends an addressable event (a stored copy plus a live echo). Used to prove /// the cubit dedups instead of doubling the listing. @@ -269,12 +288,14 @@ void main() { const [ ShareableLot( lotId: 'lot-1', + varietyId: 'var-1', summary: 'Tomate', offerStatus: OfferStatus.shared, category: 'Solanaceae', ), ShareableLot( lotId: 'lot-2', + varietyId: 'var-2', summary: 'Judía', offerStatus: OfferStatus.exchange, ), @@ -291,6 +312,85 @@ void main() { await cubit.close(); }); + test('publishLots hosts the cover photo and puts its URL on the offer', + () async { + final transport = FakeOfferTransport(); + final media = FakeMediaTransport(); + final cubit = OffersCubit( + transport, + media: media, + coverPhoto: (id) async => Uint8List.fromList([1, 2, 3]), + ); + await cubit.publishLots( + const [ + ShareableLot( + lotId: 'lot-1', + varietyId: 'var-1', + summary: 'Tomate', + offerStatus: OfferStatus.shared, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(media.uploads, 1); + expect(cubit.state.offers.single.imageUrl, 'https://media.test/hosted.jpg'); + await cubit.close(); + }); + + test('publishLots still publishes (without image) when hosting fails', + () async { + final transport = FakeOfferTransport(); + final cubit = OffersCubit( + transport, + media: FakeMediaTransport(fail: true), + coverPhoto: (id) async => Uint8List.fromList([1, 2, 3]), + ); + final count = await cubit.publishLots( + const [ + ShareableLot( + lotId: 'lot-1', + varietyId: 'var-1', + summary: 'Tomate', + offerStatus: OfferStatus.shared, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + + expect(count, 1); // the offer still went out + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers.single.imageUrl, isNull); + await cubit.close(); + }); + + test('publishLots without a media server publishes no image', () async { + final transport = FakeOfferTransport(); + final cubit = OffersCubit(transport); // no media, no coverPhoto + await cubit.publishLots( + const [ + ShareableLot( + lotId: 'lot-1', + varietyId: 'var-1', + summary: 'Tomate', + offerStatus: OfferStatus.shared, + ), + ], + authorPubkeyHex: 'ab' * 32, + areaGeohash: 'sp3e9', + ); + + await cubit.discover('sp3'); + await pumpEventQueue(); + expect(cubit.state.offers.single.imageUrl, isNull); + await cubit.close(); + }); + test('publishLots is a no-op offline or with no area', () async { final offline = OffersCubit(null); expect( @@ -298,6 +398,7 @@ void main() { const [ ShareableLot( lotId: 'x', + varietyId: 'vx', summary: 'x', offerStatus: OfferStatus.shared, ), @@ -315,6 +416,7 @@ void main() { const [ ShareableLot( lotId: 'x', + varietyId: 'vx', summary: 'x', offerStatus: OfferStatus.shared, ), @@ -336,13 +438,20 @@ void main() { cubit: cubit, shareableLots: const [ ShareableLot( - lotId: 'lot-1', summary: 'Tomate', offerStatus: OfferStatus.shared), + lotId: 'lot-1', + varietyId: 'var-1', + summary: 'Tomate', + offerStatus: OfferStatus.shared), ShareableLot( lotId: 'lot-2', + varietyId: 'var-2', summary: 'Judía', offerStatus: OfferStatus.exchange), ShareableLot( - lotId: 'lot-3', summary: 'Otro', offerStatus: OfferStatus.shared), + lotId: 'lot-3', + varietyId: 'var-3', + summary: 'Otro', + offerStatus: OfferStatus.shared), ], authorPubkeyHex: 'ab' * 32, areaGeohash: 'sp3e9', diff --git a/packages/commons_core/test/social/nostr_transports_test.dart b/packages/commons_core/test/social/nostr_transports_test.dart index 4ba25cc..3ab4f8c 100644 --- a/packages/commons_core/test/social/nostr_transports_test.dart +++ b/packages/commons_core/test/social/nostr_transports_test.dart @@ -35,6 +35,7 @@ void main() { type: OfferType.gift, category: 'Solanaceae', approxGeohash: 'sp3e9xyz', + imageUrl: 'https://media.test/tomate.jpg', )); expect(published.accepted, isTrue, reason: published.message); @@ -43,6 +44,7 @@ void main() { expect(found, hasLength(1)); expect(found.single.summary, 'Tomate rosa de Barbastro'); expect(found.single.authorPubkeyHex, alice.publicKeyHex); + expect(found.single.imageUrl, 'https://media.test/tomate.jpg'); await aliceT.close(); await bobT.close();