From 0150a7ce02762c589f5d15c64492e3258aeb7c1c Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 20:58:45 +0200 Subject: [PATCH] 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. --- apps/app_seeds/lib/i18n/ast.i18n.json | 137 ++++++++++++++---- apps/app_seeds/lib/i18n/en.i18n.json | 137 ++++++++++++++---- apps/app_seeds/lib/i18n/es.i18n.json | 137 ++++++++++++++---- apps/app_seeds/lib/i18n/pt.i18n.json | 137 ++++++++++++++---- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 2 + apps/app_seeds/lib/i18n/strings_en.g.dart | 4 + apps/app_seeds/lib/i18n/strings_es.g.dart | 2 + apps/app_seeds/lib/i18n/strings_pt.g.dart | 2 + .../lib/ui/market_offer_detail_screen.dart | 4 + apps/app_seeds/lib/ui/market_screen.dart | 7 + apps/app_seeds/lib/ui/market_widgets.dart | 92 ++++++++++++ .../ui/market_offer_detail_screen_test.dart | 34 ++++- .../test/ui/market_widgets_test.dart | 45 ++++++ 14 files changed, 633 insertions(+), 111 deletions(-) create mode 100644 apps/app_seeds/test/ui/market_widgets_test.dart diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 07cff98..31dd7a0 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index ee69b30..a746708 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 7c0e44a..0f1e1a6 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 8df35d2..72a30db 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index fb59798..0277a3c 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: 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 diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 2f83eb6..1d7ac78 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -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', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 7785576..9ca3bab 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 98d6f12..797f3a6 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -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', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 22249a5..f74354e 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -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', 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..ef4a753 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -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, diff --git a/apps/app_seeds/lib/ui/market_widgets.dart b/apps/app_seeds/lib/ui/market_widgets.dart index 155eefa..b743ada 100644 --- a/apps/app_seeds/lib/ui/market_widgets.dart +++ b/apps/app_seeds/lib/ui/market_widgets.dart @@ -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}); 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..1108049 --- /dev/null +++ b/apps/app_seeds/test/ui/market_widgets_test.dart @@ -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); + }); +}