Merge branch 'claude/nostalgic-wright-acfc25': inline offer photos

Market offers carry a small base64 data-URI thumbnail embedded in the Nostr
event (no media server); full photo stays in the encrypted inventory. Blossom
alternative parked on branch parked/offer-photos-blossom.

# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
This commit is contained in:
vjrj 2026-07-10 21:43:35 +02:00
commit e2b88b4f26
20 changed files with 520 additions and 16 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';
@ -7,6 +8,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/variety_repository.dart';
import '../services/offer_mapper.dart';
import '../services/offer_outbox.dart';
import '../services/offer_thumbnail.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
@ -134,12 +136,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, {
Future<Uint8List?> Function(String varietyId)? coverPhoto,
String? Function(Uint8List bytes)? thumbnail,
Future<void> Function()? onDispose,
}) : _coverPhoto = coverPhoto,
_thumbnail = thumbnail,
_onDispose = onDispose,
super(const OffersState());
final OfferTransport? _transport;
/// Fetches a variety's cover photo bytes; null in tests or when no inventory
/// repo is wired.
final Future<Uint8List?> Function(String varietyId)? _coverPhoto;
/// Turns full photo bytes into a small `data:` thumbnail embedded in the offer
/// (no media server). Null offers publish without a photo.
final String? Function(Uint8List bytes)? _thumbnail;
/// 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 +285,7 @@ class OffersCubit extends Cubit<OffersState> {
areaGeohash: areaGeohash,
category: lot.category,
isOrganic: lot.isOrganic,
imageUrl: await _coverThumbnail(lot.varietyId),
);
final result = await transport.publish(offer);
if (result.accepted) accepted++;
@ -281,6 +298,22 @@ class OffersCubit extends Cubit<OffersState> {
return accepted;
}
/// Builds a small inline `data:` thumbnail from the lot's cover photo to embed
/// in the offer, or null when there's no photo, no thumbnailer, or it can't be
/// shrunk to fit. Best-effort: a missing image never blocks publishing.
Future<String?> _coverThumbnail(String varietyId) async {
final coverPhoto = _coverPhoto;
final thumbnail = _thumbnail;
if (coverPhoto == null || thumbnail == null) return null;
try {
final Uint8List? bytes = await coverPhoto(varietyId);
if (bytes == null || bytes.isEmpty) return null;
return thumbnail(bytes);
} catch (_) {
return null; // degrade: publish the offer without a photo
}
}
@override
Future<void> close() async {
_searchTimeout?.cancel();
@ -295,13 +328,19 @@ 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);
return OffersCubit(
session.offers,
coverPhoto: repository?.coverPhotoFor,
thumbnail: offerThumbnailDataUri,
onDispose: session.close,
);
} catch (_) {
return OffersCubit(null); // offline / no relay reachable
}