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 f8f0547..bddcffc 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -482,7 +482,8 @@ "sharedBy": "Compartíu por", "noProfile": "Esta persona entá nun compartió'l so perfil", "copyId": "Copiar códigu", - "idCopied": "Códigu copiáu" + "idCopied": "Códigu copiáu", + "photo": "Semeya" }, "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 40930b9..1a66028 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -485,7 +485,8 @@ "sharedBy": "Shared by", "noProfile": "This person hasn't shared a profile yet", "copyId": "Copy code", - "idCopied": "Code copied" + "idCopied": "Code copied", + "photo": "Photo" }, "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 49f1dd0..65c41b1 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -484,7 +484,8 @@ "sharedBy": "Compartido por", "noProfile": "Esta persona aún no ha compartido su perfil", "copyId": "Copiar código", - "idCopied": "Código copiado" + "idCopied": "Código copiado", + "photo": "Foto" }, "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 942dda6..ba8c6e8 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -481,7 +481,8 @@ "sharedBy": "Partilhado por", "noProfile": "Esta pessoa ainda não partilhou o seu perfil", "copyId": "Copiar código", - "idCopied": "Código copiado" + "idCopied": "Código copiado", + "photo": "Foto" }, "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 5346695..d9c532b 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: 1664 (416 per locale) +/// Strings: 1668 (417 per locale) /// -/// Built on 2026-07-10 at 19:36 UTC +/// Built on 2026-07-10 at 19:42 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 39cae18..de571b8 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -693,6 +693,7 @@ class _Translations$market$ast extends Translations$market$en { @override String get noProfile => 'Esta persona entá nun compartió\'l so perfil'; @override String get copyId => 'Copiar códigu'; @override String get idCopied => 'Códigu copiáu'; + @override String get photo => 'Semeya'; } // Path: profile @@ -1505,6 +1506,7 @@ extension on TranslationsAst { 'market.noProfile' => 'Esta persona entá nun compartió\'l so perfil', 'market.copyId' => 'Copiar códigu', 'market.idCopied' => 'Códigu copiáu', + 'market.photo' => 'Semeya', '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 aa1b2ff..097d5d2 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1284,6 +1284,9 @@ class Translations$market$en { /// en: 'Code copied' String get idCopied => 'Code copied'; + + /// en: 'Photo' + String get photo => 'Photo'; } // Path: profile @@ -2327,6 +2330,7 @@ extension on Translations { 'market.noProfile' => 'This person hasn\'t shared a profile yet', 'market.copyId' => 'Copy code', 'market.idCopied' => 'Code copied', + 'market.photo' => 'Photo', '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 1f94a9f..0e72da2 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -695,6 +695,7 @@ class _Translations$market$es extends Translations$market$en { @override String get noProfile => 'Esta persona aún no ha compartido su perfil'; @override String get copyId => 'Copiar código'; @override String get idCopied => 'Código copiado'; + @override String get photo => 'Foto'; } // Path: profile @@ -1509,6 +1510,7 @@ extension on TranslationsEs { 'market.noProfile' => 'Esta persona aún no ha compartido su perfil', 'market.copyId' => 'Copiar código', 'market.idCopied' => 'Código copiado', + 'market.photo' => 'Foto', '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 5e09b54..f5663ac 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -692,6 +692,7 @@ class _Translations$market$pt extends Translations$market$en { @override String get noProfile => 'Esta pessoa ainda não partilhou o seu perfil'; @override String get copyId => 'Copiar código'; @override String get idCopied => 'Código copiado'; + @override String get photo => 'Foto'; } // Path: profile @@ -1503,6 +1504,7 @@ extension on TranslationsPt { 'market.noProfile' => 'Esta pessoa ainda não partilhou o seu perfil', 'market.copyId' => 'Copiar código', 'market.idCopied' => 'Código copiado', + 'market.photo' => 'Foto', 'profile.title' => 'O teu perfil', 'profile.name' => 'Nome', 'profile.nameHint' => 'Como os outros te veem', diff --git a/apps/app_seeds/lib/services/offer_thumbnail.dart b/apps/app_seeds/lib/services/offer_thumbnail.dart new file mode 100644 index 0000000..81e756a --- /dev/null +++ b/apps/app_seeds/lib/services/offer_thumbnail.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; + +/// Builds a tiny `data:image/jpeg;base64,…` thumbnail from a full photo, small +/// enough to ride *inside* a Nostr offer event — no media server, fully +/// decentralized. The full-resolution photo stays in the encrypted local +/// inventory; only this shrunken preview is published so peers can see the seed. +/// +/// Relays cap event size, so we downscale (longest edge) and re-encode until the +/// base64 fits under [maxBytes]. Returns null when the bytes aren't a decodable +/// image or no size small enough is reachable — the offer then publishes without +/// a photo (graceful, never a hard failure). +String? offerThumbnailDataUri( + Uint8List bytes, { + int maxBytes = 40000, + List edges = const [320, 256, 192, 128], + int quality = 68, +}) { + try { + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + + for (final edge in edges) { + final resized = _fitWithin(decoded, edge); + final jpeg = img.encodeJpg(resized, quality: quality); + final b64 = base64.encode(jpeg); + if (b64.length <= maxBytes) return 'data:image/jpeg;base64,$b64'; + } + return null; // even the smallest edge didn't fit — skip the image + } catch (_) { + return null; // undecodable/corrupt bytes — publish without a photo + } +} + +/// Extracts the raw bytes from a `data:...;base64,…` URI, or null when [uri] is +/// not a base64 data URI. Used by the UI to render an inline thumbnail with +/// `Image.memory` instead of a network fetch. +Uint8List? decodeDataUri(String uri) { + if (!uri.startsWith('data:')) return null; + try { + return Uri.parse(uri).data?.contentAsBytes(); + } on FormatException { + return null; + } +} + +/// Resizes [image] so its longest edge is at most [edge], preserving aspect +/// ratio. Never upscales a photo that's already smaller. +img.Image _fitWithin(img.Image image, int edge) { + if (image.width <= edge && image.height <= edge) return image; + return image.width >= image.height + ? img.copyResize(image, width: edge) + : img.copyResize(image, height: edge); +} diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index 021def8..c312571 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'; @@ -7,6 +8,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../data/variety_repository.dart'; import '../services/offer_mapper.dart'; import '../services/offer_outbox.dart'; +import '../services/offer_thumbnail.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -134,12 +136,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, { + Future Function(String varietyId)? coverPhoto, + String? Function(Uint8List bytes)? thumbnail, + Future Function()? onDispose, + }) : _coverPhoto = coverPhoto, + _thumbnail = thumbnail, + _onDispose = onDispose, super(const OffersState()); final OfferTransport? _transport; + /// Fetches a variety's cover photo bytes; null in tests or when no inventory + /// repo is wired. + final Future Function(String varietyId)? _coverPhoto; + + /// Turns full photo bytes into a small `data:` thumbnail embedded in the offer + /// (no media server). Null → offers publish without a photo. + final String? Function(Uint8List bytes)? _thumbnail; + /// 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 +285,7 @@ class OffersCubit extends Cubit { areaGeohash: areaGeohash, category: lot.category, isOrganic: lot.isOrganic, + imageUrl: await _coverThumbnail(lot.varietyId), ); final result = await transport.publish(offer); if (result.accepted) accepted++; @@ -281,6 +298,22 @@ class OffersCubit extends Cubit { return accepted; } + /// Builds a small inline `data:` thumbnail from the lot's cover photo to embed + /// in the offer, or null when there's no photo, no thumbnailer, or it can't be + /// shrunk to fit. Best-effort: a missing image never blocks publishing. + Future _coverThumbnail(String varietyId) async { + final coverPhoto = _coverPhoto; + final thumbnail = _thumbnail; + if (coverPhoto == null || thumbnail == null) return null; + try { + final Uint8List? bytes = await coverPhoto(varietyId); + if (bytes == null || bytes.isEmpty) return null; + return thumbnail(bytes); + } catch (_) { + return null; // degrade: publish the offer without a photo + } + } + @override Future close() async { _searchTimeout?.cancel(); @@ -295,13 +328,19 @@ 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); + return OffersCubit( + session.offers, + coverPhoto: repository?.coverPhotoFor, + thumbnail: offerThumbnailDataUri, + onDispose: session.close, + ); } catch (_) { return OffersCubit(null); // offline / no relay reachable } diff --git a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart index ac81f23..0f408ed 100644 --- a/apps/app_seeds/lib/ui/market_offer_detail_screen.dart +++ b/apps/app_seeds/lib/ui/market_offer_detail_screen.dart @@ -123,6 +123,10 @@ class _MarketOfferDetailScreenState extends State { body: ListView( padding: const EdgeInsets.all(16), children: [ + if (o.imageUrl != null) ...[ + OfferHeroImage(url: o.imageUrl!, semanticLabel: t.market.photo), + const SizedBox(height: 16), + ], Row( children: [ Expanded( diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index 0c405a2..a919286 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(); @@ -380,6 +384,13 @@ class _OfferCard extends StatelessWidget { children: [ Row( children: [ + if (offer.imageUrl != null) ...[ + OfferThumbnail( + url: offer.imageUrl!, + semanticLabel: t.market.photo, + ), + const SizedBox(width: 12), + ], Expanded( child: Text( offer.summary, diff --git a/apps/app_seeds/lib/ui/market_widgets.dart b/apps/app_seeds/lib/ui/market_widgets.dart index 155eefa..20c9f31 100644 --- a/apps/app_seeds/lib/ui/market_widgets.dart +++ b/apps/app_seeds/lib/ui/market_widgets.dart @@ -2,6 +2,7 @@ import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import '../i18n/strings.g.dart'; +import '../services/offer_thumbnail.dart'; import 'theme.dart'; /// Human words for an offer's reciprocity mode. Shared by the market list and @@ -14,6 +15,122 @@ String offerTypeLabel(Translations t, OfferType type) => switch (type) { OfferType.lend => t.share.exchange, }; +/// A small square thumbnail of an offer's photo for the market list card. +/// The image usually rides inline in the offer as a `data:` URI (decentralized, +/// no server), so it renders straight from memory; an `http(s)` URL is still +/// supported for interop and degrades gracefully (spinner, then placeholder). +class OfferThumbnail extends StatelessWidget { + const OfferThumbnail({ + required this.url, + required this.semanticLabel, + this.size = 56, + super.key, + }); + + final String url; + final String semanticLabel; + final double size; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: _offerImage( + url, + semanticLabel: semanticLabel, + width: size, + height: size, + ), + ); + } +} + +/// Full-width "hero" image atop the offer detail page. Same source handling as +/// [OfferThumbnail]; hidden entirely when the offer has no photo. +class OfferHeroImage extends StatelessWidget { + const OfferHeroImage({ + required this.url, + required this.semanticLabel, + super.key, + }); + + final String url; + final String semanticLabel; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(14), + child: AspectRatio( + aspectRatio: 4 / 3, + child: _offerImage(url, semanticLabel: semanticLabel), + ), + ); + } +} + +/// Renders an offer image from either an inline `data:` URI (via memory) or a +/// remote URL (via network), always cropping to fill and falling back to a +/// neutral placeholder — a broken image must never block the card. +Widget _offerImage( + String url, { + required String semanticLabel, + double? width, + double? height, +}) { + final inline = decodeDataUri(url); + if (inline != null) { + return Image.memory( + inline, + width: width, + height: height, + fit: BoxFit.cover, + semanticLabel: semanticLabel, + errorBuilder: (context, _, _) => + _OfferImagePlaceholder(width: width, height: height), + ); + } + return Image.network( + url, + width: width, + height: height, + fit: BoxFit.cover, + semanticLabel: semanticLabel, + loadingBuilder: (context, child, progress) => progress == null + ? child + : _OfferImagePlaceholder(width: width, height: height, loading: true), + errorBuilder: (context, _, _) => + _OfferImagePlaceholder(width: width, height: height), + ); +} + +/// Neutral fill shown while a remote offer image loads or when it can't be +/// decoded/fetched — a marketplace is useless if a broken image blocks the card. +class _OfferImagePlaceholder extends StatelessWidget { + const _OfferImagePlaceholder({this.width, this.height, this.loading = false}); + + final double? width; + final double? height; + final bool loading; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + color: seedPrimaryContainer.withValues(alpha: 0.4), + alignment: Alignment.center, + child: loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.image_not_supported_outlined, color: seedMuted), + ); + } +} + /// The small pill showing an offer's reciprocity mode (give/swap/sell/wanted). class OfferTypeChip extends StatelessWidget { const OfferTypeChip({required this.label, super.key}); diff --git a/apps/app_seeds/test/services/offer_thumbnail_test.dart b/apps/app_seeds/test/services/offer_thumbnail_test.dart new file mode 100644 index 0000000..a0e9530 --- /dev/null +++ b/apps/app_seeds/test/services/offer_thumbnail_test.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:tane/services/offer_thumbnail.dart'; + +void main() { + /// A real, decodable photo far bigger than a thumbnail, with varied pixels so + /// JPEG can't compress it to nothing. + Uint8List bigPhoto() { + final image = img.Image(width: 1280, height: 960); + for (var y = 0; y < image.height; y++) { + for (var x = 0; x < image.width; x++) { + image.setPixelRgb(x, y, (x * 7) % 256, (y * 5) % 256, (x + y) % 256); + } + } + return img.encodeJpg(image, quality: 90); + } + + test('produces a small jpeg data-URI that fits the byte budget', () { + final uri = offerThumbnailDataUri(bigPhoto(), maxBytes: 40000); + + expect(uri, isNotNull); + expect(uri!, startsWith('data:image/jpeg;base64,')); + final b64 = uri.substring('data:image/jpeg;base64,'.length); + expect(b64.length, lessThanOrEqualTo(40000)); + + // The payload really is a smaller image than the source. + final decoded = img.decodeJpg(base64.decode(b64))!; + expect(decoded.width, lessThanOrEqualTo(320)); + expect(decoded.height, lessThanOrEqualTo(320)); + }); + + test('never upscales an already-tiny image', () { + final small = img.encodeJpg(img.Image(width: 64, height: 48), quality: 80); + final uri = offerThumbnailDataUri(small); + + expect(uri, isNotNull); + final b64 = uri!.substring('data:image/jpeg;base64,'.length); + final decoded = img.decodeJpg(base64.decode(b64))!; + expect(decoded.width, 64); + expect(decoded.height, 48); + }); + + test('returns null for bytes that are not an image', () { + expect(offerThumbnailDataUri(Uint8List.fromList([1, 2, 3, 4])), isNull); + }); + + group('decodeDataUri', () { + test('round-trips a base64 data URI to its bytes', () { + final bytes = Uint8List.fromList([9, 8, 7, 6]); + final uri = 'data:image/jpeg;base64,${base64.encode(bytes)}'; + expect(decodeDataUri(uri), bytes); + }); + + test('returns null for a plain http url', () { + expect(decodeDataUri('https://example.org/a.jpg'), isNull); + }); + }); +} diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index 2698c49..ce8a3f1 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'; @@ -269,12 +270,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 +294,88 @@ void main() { await cubit.close(); }); + test('publishLots embeds an inline thumbnail data-URI on the offer', + () async { + final transport = FakeOfferTransport(); + var thumbnailed = 0; + final cubit = OffersCubit( + transport, + coverPhoto: (id) async => Uint8List.fromList([1, 2, 3]), + thumbnail: (bytes) { + thumbnailed++; + return 'data:image/jpeg;base64,AAAA'; + }, + ); + 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(thumbnailed, 1); + expect(cubit.state.offers.single.imageUrl, 'data:image/jpeg;base64,AAAA'); + await cubit.close(); + }); + + test('publishLots still publishes (without image) when thumbnailing fails', + () async { + final transport = FakeOfferTransport(); + final cubit = OffersCubit( + transport, + coverPhoto: (id) async => Uint8List.fromList([1, 2, 3]), + thumbnail: (bytes) => throw Exception('boom'), + ); + 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 thumbnailer publishes no image', () async { + final transport = FakeOfferTransport(); + final cubit = OffersCubit(transport); // no thumbnail, 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 +383,7 @@ void main() { const [ ShareableLot( lotId: 'x', + varietyId: 'vx', summary: 'x', offerStatus: OfferStatus.shared, ), @@ -315,6 +401,7 @@ void main() { const [ ShareableLot( lotId: 'x', + varietyId: 'vx', summary: 'x', offerStatus: OfferStatus.shared, ), @@ -336,13 +423,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/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart b/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart index 63fa9b4..d431faa 100644 --- a/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/market_offer_detail_screen_test.dart @@ -6,6 +6,7 @@ import 'package:tane/i18n/strings.g.dart'; import 'package:tane/services/social_service.dart'; import 'package:tane/services/social_settings.dart'; import 'package:tane/ui/market_offer_detail_screen.dart'; +import 'package:tane/ui/market_widgets.dart'; import '../support/test_support.dart'; @@ -25,7 +26,7 @@ Widget _wrap(MarketOfferDetailScreen screen) { ); } -Offer _offer({bool organic = false}) => Offer( +Offer _offer({bool organic = false, String? imageUrl}) => Offer( id: 'lot-1', authorPubkeyHex: 'ab' * 32, summary: 'Tomate rosa de Barbastro', @@ -33,6 +34,7 @@ Offer _offer({bool organic = false}) => Offer( category: 'Solanaceae', approxGeohash: 'sp3e9', isOrganic: organic, + imageUrl: imageUrl, ); void main() { @@ -70,4 +72,34 @@ void main() { expect(find.byKey(const Key('offerDetail.contact')), findsNothing); }); + + testWidgets('shows a hero image when the offer has a photo', (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(imageUrl: 'https://media.example/abc.jpg'), + social: social, + settings: settings, + ))); + await tester.pumpAndSettle(); + + expect(find.byType(OfferHeroImage), findsOneWidget); + }); + + testWidgets('shows no hero image when the offer has none', (tester) async { + final social = await SocialService.fromRootSeedHex('00' * 32); + final settings = SocialSettings(InMemorySecretStore()); + await settings.setRelayUrls(const []); + + await tester.pumpWidget(_wrap(MarketOfferDetailScreen( + offer: _offer(), + social: social, + settings: settings, + ))); + await tester.pumpAndSettle(); + + expect(find.byType(OfferHeroImage), findsNothing); + }); } diff --git a/apps/app_seeds/test/ui/market_widgets_test.dart b/apps/app_seeds/test/ui/market_widgets_test.dart new file mode 100644 index 0000000..7a57f23 --- /dev/null +++ b/apps/app_seeds/test/ui/market_widgets_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/ui/market_widgets.dart'; + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: Center(child: child))); + +/// A 1×1 PNG as an inline data URI — the shape offers actually publish. +const _dataUri = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lE' + 'QVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + +void main() { + testWidgets('OfferThumbnail renders an inline data-URI from memory', + (tester) async { + await tester.pumpWidget( + _wrap(const OfferThumbnail(url: _dataUri, semanticLabel: 'Photo')), + ); + await tester.pump(); + + expect(find.byType(Image), findsOneWidget); + // Decoded fine — no broken-image placeholder. + expect(find.byIcon(Icons.image_not_supported_outlined), findsNothing); + }); + + testWidgets('OfferThumbnail builds a network image', (tester) async { + await tester.pumpWidget( + _wrap(const OfferThumbnail( + url: 'https://media.example/abc.jpg', + semanticLabel: 'Photo', + )), + ); + // The image widget is present; its loadingBuilder/errorBuilder handle the + // remote fetch, so we only assert the widget is wired (no network in tests). + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('OfferThumbnail falls back to a placeholder when the load fails', + (tester) async { + await tester.pumpWidget( + _wrap(const OfferThumbnail( + url: 'https://media.example/missing.jpg', + semanticLabel: 'Photo', + )), + ); + // Let the (failed) network fetch resolve; the errorBuilder then shows a + // neutral icon instead of a broken image. + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(find.byIcon(Icons.image_not_supported_outlined), findsOneWidget); + }); + + testWidgets('OfferHeroImage builds a network image', (tester) async { + await tester.pumpWidget( + _wrap(const OfferHeroImage( + url: 'https://media.example/abc.jpg', + semanticLabel: 'Photo', + )), + ); + expect(find.byType(Image), findsOneWidget); + }); +} 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();