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

View file

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

View file

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

View file

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

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 4 /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // 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 noProfile => 'Esta persona entá nun compartió\'l so perfil';
@override String get copyId => 'Copiar códigu'; @override String get copyId => 'Copiar códigu';
@override String get idCopied => 'Códigu copiáu'; @override String get idCopied => 'Códigu copiáu';
@override String get photo => 'Semeya';
} }
// Path: profile // Path: profile
@ -1456,6 +1457,7 @@ extension on TranslationsAst {
'market.noProfile' => 'Esta persona entá nun compartió\'l so perfil', 'market.noProfile' => 'Esta persona entá nun compartió\'l so perfil',
'market.copyId' => 'Copiar códigu', 'market.copyId' => 'Copiar códigu',
'market.idCopied' => 'Códigu copiáu', 'market.idCopied' => 'Códigu copiáu',
'market.photo' => 'Semeya',
'profile.title' => 'El to perfil', 'profile.title' => 'El to perfil',
'profile.name' => 'Nome', 'profile.name' => 'Nome',
'profile.nameHint' => 'Cómo te ven los demás', 'profile.nameHint' => 'Cómo te ven los demás',

View file

@ -1282,6 +1282,9 @@ class Translations$market$en {
/// en: 'Code copied' /// en: 'Code copied'
String get idCopied => 'Code copied'; String get idCopied => 'Code copied';
/// en: 'Photo'
String get photo => 'Photo';
} }
// Path: profile // Path: profile
@ -2220,6 +2223,7 @@ extension on Translations {
'market.noProfile' => 'This person hasn\'t shared a profile yet', 'market.noProfile' => 'This person hasn\'t shared a profile yet',
'market.copyId' => 'Copy code', 'market.copyId' => 'Copy code',
'market.idCopied' => 'Code copied', 'market.idCopied' => 'Code copied',
'market.photo' => 'Photo',
'profile.title' => 'Your profile', 'profile.title' => 'Your profile',
'profile.name' => 'Display name', 'profile.name' => 'Display name',
'profile.nameHint' => 'How others see you', '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 noProfile => 'Esta persona aún no ha compartido su perfil';
@override String get copyId => 'Copiar código'; @override String get copyId => 'Copiar código';
@override String get idCopied => 'Código copiado'; @override String get idCopied => 'Código copiado';
@override String get photo => 'Foto';
} }
// Path: profile // Path: profile
@ -1460,6 +1461,7 @@ extension on TranslationsEs {
'market.noProfile' => 'Esta persona aún no ha compartido su perfil', 'market.noProfile' => 'Esta persona aún no ha compartido su perfil',
'market.copyId' => 'Copiar código', 'market.copyId' => 'Copiar código',
'market.idCopied' => 'Código copiado', 'market.idCopied' => 'Código copiado',
'market.photo' => 'Foto',
'profile.title' => 'Tu perfil', 'profile.title' => 'Tu perfil',
'profile.name' => 'Nombre', 'profile.name' => 'Nombre',
'profile.nameHint' => 'Cómo te ven los demás', '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 noProfile => 'Esta pessoa ainda não partilhou o seu perfil';
@override String get copyId => 'Copiar código'; @override String get copyId => 'Copiar código';
@override String get idCopied => 'Código copiado'; @override String get idCopied => 'Código copiado';
@override String get photo => 'Foto';
} }
// Path: profile // Path: profile
@ -1454,6 +1455,7 @@ extension on TranslationsPt {
'market.noProfile' => 'Esta pessoa ainda não partilhou o seu perfil', 'market.noProfile' => 'Esta pessoa ainda não partilhou o seu perfil',
'market.copyId' => 'Copiar código', 'market.copyId' => 'Copiar código',
'market.idCopied' => 'Código copiado', 'market.idCopied' => 'Código copiado',
'market.photo' => 'Foto',
'profile.title' => 'O teu perfil', 'profile.title' => 'O teu perfil',
'profile.name' => 'Nome', 'profile.name' => 'Nome',
'profile.nameHint' => 'Como os outros te veem', 'profile.nameHint' => 'Como os outros te veem',

View file

@ -123,6 +123,10 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
if (o.imageUrl != null) ...[
OfferHeroImage(url: o.imageUrl!, semanticLabel: t.market.photo),
const SizedBox(height: 16),
],
Row( Row(
children: [ children: [
Expanded( Expanded(

View file

@ -380,6 +380,13 @@ class _OfferCard extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
if (offer.imageUrl != null) ...[
OfferThumbnail(
url: offer.imageUrl!,
semanticLabel: t.market.photo,
),
const SizedBox(width: 12),
],
Expanded( Expanded(
child: Text( child: Text(
offer.summary, offer.summary,

View file

@ -14,6 +14,98 @@ String offerTypeLabel(Translations t, OfferType type) => switch (type) {
OfferType.lend => t.share.exchange, 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). /// The small pill showing an offer's reciprocity mode (give/swap/sell/wanted).
class OfferTypeChip extends StatelessWidget { class OfferTypeChip extends StatelessWidget {
const OfferTypeChip({required this.label, super.key}); 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_service.dart';
import 'package:tane/services/social_settings.dart'; import 'package:tane/services/social_settings.dart';
import 'package:tane/ui/market_offer_detail_screen.dart'; import 'package:tane/ui/market_offer_detail_screen.dart';
import 'package:tane/ui/market_widgets.dart';
import '../support/test_support.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', id: 'lot-1',
authorPubkeyHex: 'ab' * 32, authorPubkeyHex: 'ab' * 32,
summary: 'Tomate rosa de Barbastro', summary: 'Tomate rosa de Barbastro',
@ -33,6 +34,7 @@ Offer _offer({bool organic = false}) => Offer(
category: 'Solanaceae', category: 'Solanaceae',
approxGeohash: 'sp3e9', approxGeohash: 'sp3e9',
isOrganic: organic, isOrganic: organic,
imageUrl: imageUrl,
); );
void main() { void main() {
@ -70,4 +72,34 @@ void main() {
expect(find.byKey(const Key('offerDetail.contact')), findsNothing); 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);
});
}