feat(market): host and publish the cover photo with each offer

Wire the Blossom MediaTransport into publishing: shareable lots carry
their varietyId, publishLots uploads the lot's cover photo and puts the
returned URL on the offer (best-effort — a hosting failure still publishes
the offer, just without a photo). Add a configurable media server (Comunes
default, empty to opt out) to settings and the market advanced config.
This commit is contained in:
vjrj 2026-07-10 21:12:36 +02:00
parent 5bc1715637
commit fa53295439
17 changed files with 280 additions and 22 deletions

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:equatable/equatable.dart';
@ -134,12 +135,26 @@ class OffersState extends Equatable {
/// UI stays offline-tolerant (a null transport = the social layer is unavailable
/// and the screen degrades gracefully).
class OffersCubit extends Cubit<OffersState> {
OffersCubit(this._transport, {Future<void> Function()? onDispose})
: _onDispose = onDispose,
OffersCubit(
this._transport, {
MediaTransport? media,
Future<Uint8List?> Function(String varietyId)? coverPhoto,
Future<void> Function()? onDispose,
}) : _media = media,
_coverPhoto = coverPhoto,
_onDispose = onDispose,
super(const OffersState());
final OfferTransport? _transport;
/// Hosts an offer's cover photo so peers can see it; null when no media server
/// is configured, in which case offers publish without images.
final MediaTransport? _media;
/// Fetches a variety's cover photo bytes (for hosting); null in tests or when
/// no inventory repo is wired.
final Future<Uint8List?> Function(String varietyId)? _coverPhoto;
/// Closes the owning [SocialSession]/connection when the cubit is disposed
/// (null in tests, where the transport is a fake with nothing to close).
final Future<void> Function()? _onDispose;
@ -269,6 +284,7 @@ class OffersCubit extends Cubit<OffersState> {
areaGeohash: areaGeohash,
category: lot.category,
isOrganic: lot.isOrganic,
imageUrl: await _hostCoverPhoto(lot.varietyId),
);
final result = await transport.publish(offer);
if (result.accepted) accepted++;
@ -281,6 +297,23 @@ class OffersCubit extends Cubit<OffersState> {
return accepted;
}
/// Uploads the lot's cover photo to the media server and returns its URL, or
/// null when there's no media server, no photo, or the upload fails. Hosting
/// is best-effort: a missing image never blocks publishing the offer.
Future<String?> _hostCoverPhoto(String varietyId) async {
final media = _media;
final coverPhoto = _coverPhoto;
if (media == null || coverPhoto == null) return null;
try {
final Uint8List? bytes = await coverPhoto(varietyId);
if (bytes == null || bytes.isEmpty) return null;
final url = await media.upload(bytes, mimeType: 'image/jpeg');
return url.toString();
} catch (_) {
return null; // degrade: publish the offer without a photo
}
}
@override
Future<void> close() async {
_searchTimeout?.cancel();
@ -295,13 +328,21 @@ class OffersCubit extends Cubit<OffersState> {
/// fails), the cubit is created with a null transport and the screen still opens.
Future<OffersCubit> createOffersCubit(
SocialService social,
SocialSettings settings,
) async {
SocialSettings settings, {
VarietyRepository? repository,
}) async {
final relays = await settings.relayUrls();
if (relays.isEmpty) return OffersCubit(null);
try {
final session = await social.openSession(relays);
return OffersCubit(session.offers, onDispose: session.close);
final mediaServer = await settings.mediaServerUrl();
final session =
await social.openSession(relays, mediaServerUrl: mediaServer);
return OffersCubit(
session.offers,
media: session.media,
coverPhoto: repository?.coverPhotoFor,
onDispose: session.close,
);
} catch (_) {
return OffersCubit(null); // offline / no relay reachable
}