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:
parent
4b71859416
commit
b56ce68167
14 changed files with 633 additions and 111 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue