feat(market): show offer photos in the market list and detail

Render offer.imageUrl (already carried by NIP-99) as a thumbnail on each
market card and a hero image on the offer detail page. Remote images
degrade gracefully: a spinner while loading, a neutral placeholder when
the media server is unreachable. Publishing the photo comes next.
This commit is contained in:
vjrj 2026-07-10 20:58:45 +02:00
parent 4b71859416
commit b56ce68167
14 changed files with 633 additions and 111 deletions

View file

@ -214,8 +214,18 @@
"anyMonth": "Cualquier mes",
"noDate": "Indicar data de coyecha",
"monthNames": [
"xineru", "febreru", "marzu", "abril", "mayu", "xunu",
"xunetu", "agostu", "setiembre", "ochobre", "payares", "avientu"
"xineru",
"febreru",
"marzu",
"abril",
"mayu",
"xunu",
"xunetu",
"agostu",
"setiembre",
"ochobre",
"payares",
"avientu"
]
},
"lotType": {
@ -330,30 +340,102 @@
"some": "dalgunes",
"plenty": "munches",
"pinch": "una migaya",
"handful": { "singular": "puñáu", "plural": "puñaos" },
"teaspoon": { "singular": "cuyaradina", "plural": "cuyaradines" },
"spoon": { "singular": "cuyar", "plural": "cuyares" },
"cup": { "singular": "taza", "plural": "tazes" },
"jar": { "singular": "bote", "plural": "botes" },
"sack": { "singular": "sacu", "plural": "sacos" },
"packet": { "singular": "sobre", "plural": "sobres" },
"cob": { "singular": "panoya", "plural": "panoyes" },
"pod": { "singular": "vaina", "plural": "vaines" },
"ear": { "singular": "espiga", "plural": "espigues" },
"head": { "singular": "cabezuela", "plural": "cabezueles" },
"fruit": { "singular": "frutu", "plural": "frutos" },
"bulb": { "singular": "bulbu", "plural": "bulbos" },
"tuber": { "singular": "tubérculu", "plural": "tubérculos" },
"seedHead": { "singular": "cabeza de simiente", "plural": "cabeces de simiente" },
"bunch": { "singular": "manoyu", "plural": "manoyos" },
"plant": { "singular": "planta", "plural": "plantes" },
"pot": { "singular": "tiestu", "plural": "tiestos" },
"tray": { "singular": "bandexa", "plural": "bandexes" },
"seedling": { "singular": "plántula", "plural": "plántules" },
"tree": { "singular": "árbol", "plural": "árboles" },
"cutting": { "singular": "esqueje", "plural": "esquejes" },
"grams": { "singular": "gramu", "plural": "gramos" },
"count": { "singular": "grana", "plural": "granes" }
"handful": {
"singular": "puñáu",
"plural": "puñaos"
},
"teaspoon": {
"singular": "cuyaradina",
"plural": "cuyaradines"
},
"spoon": {
"singular": "cuyar",
"plural": "cuyares"
},
"cup": {
"singular": "taza",
"plural": "tazes"
},
"jar": {
"singular": "bote",
"plural": "botes"
},
"sack": {
"singular": "sacu",
"plural": "sacos"
},
"packet": {
"singular": "sobre",
"plural": "sobres"
},
"cob": {
"singular": "panoya",
"plural": "panoyes"
},
"pod": {
"singular": "vaina",
"plural": "vaines"
},
"ear": {
"singular": "espiga",
"plural": "espigues"
},
"head": {
"singular": "cabezuela",
"plural": "cabezueles"
},
"fruit": {
"singular": "frutu",
"plural": "frutos"
},
"bulb": {
"singular": "bulbu",
"plural": "bulbos"
},
"tuber": {
"singular": "tubérculu",
"plural": "tubérculos"
},
"seedHead": {
"singular": "cabeza de simiente",
"plural": "cabeces de simiente"
},
"bunch": {
"singular": "manoyu",
"plural": "manoyos"
},
"plant": {
"singular": "planta",
"plural": "plantes"
},
"pot": {
"singular": "tiestu",
"plural": "tiestos"
},
"tray": {
"singular": "bandexa",
"plural": "bandexes"
},
"seedling": {
"singular": "plántula",
"plural": "plántules"
},
"tree": {
"singular": "árbol",
"plural": "árboles"
},
"cutting": {
"singular": "esqueje",
"plural": "esquejes"
},
"grams": {
"singular": "gramu",
"plural": "gramos"
},
"count": {
"singular": "grana",
"plural": "granes"
}
},
"market": {
"title": "Simiente cerca de ti",
@ -400,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",

View file

@ -215,8 +215,18 @@
"anyMonth": "Any month",
"noDate": "Set harvest date",
"monthNames": [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
]
},
"lotType": {
@ -331,30 +341,102 @@
"some": "some",
"plenty": "plenty",
"pinch": "a pinch",
"handful": { "singular": "handful", "plural": "handfuls" },
"teaspoon": { "singular": "teaspoon", "plural": "teaspoons" },
"spoon": { "singular": "spoon", "plural": "spoons" },
"cup": { "singular": "cup", "plural": "cups" },
"jar": { "singular": "jar", "plural": "jars" },
"sack": { "singular": "sack", "plural": "sacks" },
"packet": { "singular": "packet", "plural": "packets" },
"cob": { "singular": "cob", "plural": "cobs" },
"pod": { "singular": "pod", "plural": "pods" },
"ear": { "singular": "ear", "plural": "ears" },
"head": { "singular": "head", "plural": "heads" },
"fruit": { "singular": "fruit", "plural": "fruits" },
"bulb": { "singular": "bulb", "plural": "bulbs" },
"tuber": { "singular": "tuber", "plural": "tubers" },
"seedHead": { "singular": "seed head", "plural": "seed heads" },
"bunch": { "singular": "bunch", "plural": "bunches" },
"plant": { "singular": "plant", "plural": "plants" },
"pot": { "singular": "pot", "plural": "pots" },
"tray": { "singular": "tray", "plural": "trays" },
"seedling": { "singular": "seedling", "plural": "seedlings" },
"tree": { "singular": "tree", "plural": "trees" },
"cutting": { "singular": "cutting", "plural": "cuttings" },
"grams": { "singular": "gram", "plural": "grams" },
"count": { "singular": "seed", "plural": "seeds" }
"handful": {
"singular": "handful",
"plural": "handfuls"
},
"teaspoon": {
"singular": "teaspoon",
"plural": "teaspoons"
},
"spoon": {
"singular": "spoon",
"plural": "spoons"
},
"cup": {
"singular": "cup",
"plural": "cups"
},
"jar": {
"singular": "jar",
"plural": "jars"
},
"sack": {
"singular": "sack",
"plural": "sacks"
},
"packet": {
"singular": "packet",
"plural": "packets"
},
"cob": {
"singular": "cob",
"plural": "cobs"
},
"pod": {
"singular": "pod",
"plural": "pods"
},
"ear": {
"singular": "ear",
"plural": "ears"
},
"head": {
"singular": "head",
"plural": "heads"
},
"fruit": {
"singular": "fruit",
"plural": "fruits"
},
"bulb": {
"singular": "bulb",
"plural": "bulbs"
},
"tuber": {
"singular": "tuber",
"plural": "tubers"
},
"seedHead": {
"singular": "seed head",
"plural": "seed heads"
},
"bunch": {
"singular": "bunch",
"plural": "bunches"
},
"plant": {
"singular": "plant",
"plural": "plants"
},
"pot": {
"singular": "pot",
"plural": "pots"
},
"tray": {
"singular": "tray",
"plural": "trays"
},
"seedling": {
"singular": "seedling",
"plural": "seedlings"
},
"tree": {
"singular": "tree",
"plural": "trees"
},
"cutting": {
"singular": "cutting",
"plural": "cuttings"
},
"grams": {
"singular": "gram",
"plural": "grams"
},
"count": {
"singular": "seed",
"plural": "seeds"
}
},
"market": {
"title": "Seeds near you",
@ -403,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",

View file

@ -214,8 +214,18 @@
"anyMonth": "Cualquier mes",
"noDate": "Indicar fecha de cosecha",
"monthNames": [
"enero", "febrero", "marzo", "abril", "mayo", "junio",
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"
"enero",
"febrero",
"marzo",
"abril",
"mayo",
"junio",
"julio",
"agosto",
"septiembre",
"octubre",
"noviembre",
"diciembre"
]
},
"lotType": {
@ -330,30 +340,102 @@
"some": "algunas",
"plenty": "muchas",
"pinch": "una pizca",
"handful": { "singular": "puñado", "plural": "puñados" },
"teaspoon": { "singular": "cucharadita", "plural": "cucharaditas" },
"spoon": { "singular": "cuchara", "plural": "cucharas" },
"cup": { "singular": "taza", "plural": "tazas" },
"jar": { "singular": "bote", "plural": "botes" },
"sack": { "singular": "saco", "plural": "sacos" },
"packet": { "singular": "sobre", "plural": "sobres" },
"cob": { "singular": "mazorca", "plural": "mazorcas" },
"pod": { "singular": "vaina", "plural": "vainas" },
"ear": { "singular": "espiga", "plural": "espigas" },
"head": { "singular": "cabezuela", "plural": "cabezuelas" },
"fruit": { "singular": "fruto", "plural": "frutos" },
"bulb": { "singular": "bulbo", "plural": "bulbos" },
"tuber": { "singular": "tubérculo", "plural": "tubérculos" },
"seedHead": { "singular": "cabeza de semillas", "plural": "cabezas de semillas" },
"bunch": { "singular": "manojo", "plural": "manojos" },
"plant": { "singular": "planta", "plural": "plantas" },
"pot": { "singular": "maceta", "plural": "macetas" },
"tray": { "singular": "bandeja", "plural": "bandejas" },
"seedling": { "singular": "plántula", "plural": "plántulas" },
"tree": { "singular": "árbol", "plural": "árboles" },
"cutting": { "singular": "esqueje", "plural": "esquejes" },
"grams": { "singular": "gramo", "plural": "gramos" },
"count": { "singular": "semilla", "plural": "semillas" }
"handful": {
"singular": "puñado",
"plural": "puñados"
},
"teaspoon": {
"singular": "cucharadita",
"plural": "cucharaditas"
},
"spoon": {
"singular": "cuchara",
"plural": "cucharas"
},
"cup": {
"singular": "taza",
"plural": "tazas"
},
"jar": {
"singular": "bote",
"plural": "botes"
},
"sack": {
"singular": "saco",
"plural": "sacos"
},
"packet": {
"singular": "sobre",
"plural": "sobres"
},
"cob": {
"singular": "mazorca",
"plural": "mazorcas"
},
"pod": {
"singular": "vaina",
"plural": "vainas"
},
"ear": {
"singular": "espiga",
"plural": "espigas"
},
"head": {
"singular": "cabezuela",
"plural": "cabezuelas"
},
"fruit": {
"singular": "fruto",
"plural": "frutos"
},
"bulb": {
"singular": "bulbo",
"plural": "bulbos"
},
"tuber": {
"singular": "tubérculo",
"plural": "tubérculos"
},
"seedHead": {
"singular": "cabeza de semillas",
"plural": "cabezas de semillas"
},
"bunch": {
"singular": "manojo",
"plural": "manojos"
},
"plant": {
"singular": "planta",
"plural": "plantas"
},
"pot": {
"singular": "maceta",
"plural": "macetas"
},
"tray": {
"singular": "bandeja",
"plural": "bandejas"
},
"seedling": {
"singular": "plántula",
"plural": "plántulas"
},
"tree": {
"singular": "árbol",
"plural": "árboles"
},
"cutting": {
"singular": "esqueje",
"plural": "esquejes"
},
"grams": {
"singular": "gramo",
"plural": "gramos"
},
"count": {
"singular": "semilla",
"plural": "semillas"
}
},
"market": {
"title": "Semillas cerca de ti",
@ -402,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",

View file

@ -211,8 +211,18 @@
"anyMonth": "Qualquer mês",
"noDate": "Definir data de colheita",
"monthNames": [
"janeiro", "fevereiro", "março", "abril", "maio", "junho",
"julho", "agosto", "setembro", "outubro", "novembro", "dezembro"
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro"
]
},
"lotType": {
@ -327,30 +337,102 @@
"some": "bastantes",
"plenty": "muitas",
"pinch": "uma pitada",
"handful": { "singular": "mão-cheia", "plural": "mãos-cheias" },
"teaspoon": { "singular": "colher de chá", "plural": "colheres de chá" },
"spoon": { "singular": "colher", "plural": "colheres" },
"cup": { "singular": "chávena", "plural": "chávenas" },
"jar": { "singular": "frasco", "plural": "frascos" },
"sack": { "singular": "saco", "plural": "sacos" },
"packet": { "singular": "pacote", "plural": "pacotes" },
"cob": { "singular": "maçaroca", "plural": "maçarocas" },
"pod": { "singular": "vagem", "plural": "vagens" },
"ear": { "singular": "espiga", "plural": "espigas" },
"head": { "singular": "cabeça", "plural": "cabeças" },
"fruit": { "singular": "fruto", "plural": "frutos" },
"bulb": { "singular": "bolbo", "plural": "bolbos" },
"tuber": { "singular": "tubérculo", "plural": "tubérculos" },
"seedHead": { "singular": "capítulo", "plural": "capítulos" },
"bunch": { "singular": "molho", "plural": "molhos" },
"plant": { "singular": "planta", "plural": "plantas" },
"pot": { "singular": "vaso", "plural": "vasos" },
"tray": { "singular": "tabuleiro", "plural": "tabuleiros" },
"seedling": { "singular": "plântula", "plural": "plântulas" },
"tree": { "singular": "árvore", "plural": "árvores" },
"cutting": { "singular": "estaca", "plural": "estacas" },
"grams": { "singular": "grama", "plural": "gramas" },
"count": { "singular": "semente", "plural": "sementes" }
"handful": {
"singular": "mão-cheia",
"plural": "mãos-cheias"
},
"teaspoon": {
"singular": "colher de chá",
"plural": "colheres de chá"
},
"spoon": {
"singular": "colher",
"plural": "colheres"
},
"cup": {
"singular": "chávena",
"plural": "chávenas"
},
"jar": {
"singular": "frasco",
"plural": "frascos"
},
"sack": {
"singular": "saco",
"plural": "sacos"
},
"packet": {
"singular": "pacote",
"plural": "pacotes"
},
"cob": {
"singular": "maçaroca",
"plural": "maçarocas"
},
"pod": {
"singular": "vagem",
"plural": "vagens"
},
"ear": {
"singular": "espiga",
"plural": "espigas"
},
"head": {
"singular": "cabeça",
"plural": "cabeças"
},
"fruit": {
"singular": "fruto",
"plural": "frutos"
},
"bulb": {
"singular": "bolbo",
"plural": "bolbos"
},
"tuber": {
"singular": "tubérculo",
"plural": "tubérculos"
},
"seedHead": {
"singular": "capítulo",
"plural": "capítulos"
},
"bunch": {
"singular": "molho",
"plural": "molhos"
},
"plant": {
"singular": "planta",
"plural": "plantas"
},
"pot": {
"singular": "vaso",
"plural": "vasos"
},
"tray": {
"singular": "tabuleiro",
"plural": "tabuleiros"
},
"seedling": {
"singular": "plântula",
"plural": "plântulas"
},
"tree": {
"singular": "árvore",
"plural": "árvores"
},
"cutting": {
"singular": "estaca",
"plural": "estacas"
},
"grams": {
"singular": "grama",
"plural": "gramas"
},
"count": {
"singular": "semente",
"plural": "sementes"
}
},
"market": {
"title": "Sementes perto de ti",
@ -399,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",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 4
/// Strings: 1548 (387 per locale)
/// Strings: 1552 (388 per locale)
///
/// Built on 2026-07-10 at 17:18 UTC
/// Built on 2026-07-10 at 18:55 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -691,6 +691,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
@ -1456,6 +1457,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',

View file

@ -1282,6 +1282,9 @@ class Translations$market$en {
/// en: 'Code copied'
String get idCopied => 'Code copied';
/// en: 'Photo'
String get photo => 'Photo';
}
// Path: profile
@ -2220,6 +2223,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',

View file

@ -693,6 +693,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
@ -1460,6 +1461,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',

View file

@ -690,6 +690,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
@ -1454,6 +1455,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',

View file

@ -123,6 +123,10 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
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(

View file

@ -380,6 +380,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,

View file

@ -14,6 +14,98 @@ 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.
/// Remote (published) image, so it degrades gracefully: a spinner while it
/// loads and a neutral placeholder when the media server can't be reached.
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: Image.network(
url,
width: size,
height: size,
fit: BoxFit.cover,
semanticLabel: semanticLabel,
loadingBuilder: (context, child, progress) => progress == null
? child
: _OfferImagePlaceholder(size: size, loading: true),
errorBuilder: (context, _, _) => _OfferImagePlaceholder(size: size),
),
);
}
}
/// Full-width "hero" image atop the offer detail page. Same graceful
/// degradation 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: Image.network(
url,
fit: BoxFit.cover,
semanticLabel: semanticLabel,
loadingBuilder: (context, child, progress) => progress == null
? child
: const _OfferImagePlaceholder(loading: true),
errorBuilder: (context, _, _) => const _OfferImagePlaceholder(),
),
),
);
}
}
/// Neutral fill shown while a remote offer image loads or when it can't be
/// fetched a marketplace is useless if a broken image blocks the card.
class _OfferImagePlaceholder extends StatelessWidget {
const _OfferImagePlaceholder({this.size, this.loading = false});
final double? size;
final bool loading;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
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});

View file

@ -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);
});
}

View file

@ -0,0 +1,45 @@
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)));
void main() {
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);
});
}