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
d36fd05741
commit
0150a7ce02
14 changed files with 633 additions and 111 deletions
|
|
@ -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