Scale the local inventory to 10k+ varieties: - Render the list with ListView.builder over a flattened header/item model instead of building every tile upfront. - Store a small regenerable JPEG thumbnail per photo (schema v14) and use it for the 48px list avatar; full bytes stay for offer image hosting. Existing photos are backfilled lazily at startup. Thumbnail is local-only (excluded from CRDT sync and backups by the JSON codec). - Add indexes on varieties(is_deleted,is_draft), attachments(parent_type, parent_id,kind), lots(variety_id) via @TableIndex. - Debounce watchInventoryView (~250ms) so a burst of table writes triggers one reload, not seven. - cacheWidth/cacheHeight on the list avatar decode. - Scale test raised 3k -> 10k; migration test v13 -> v14.
76 lines
2.8 KiB
Dart
76 lines
2.8 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:image/image.dart' as img;
|
|
|
|
/// Builds a tiny `data:image/jpeg;base64,…` thumbnail from a full photo, small
|
|
/// enough to ride *inside* a Nostr offer event — no media server, fully
|
|
/// decentralized. The full-resolution photo stays in the encrypted local
|
|
/// inventory; only this shrunken preview is published so peers can see the seed.
|
|
///
|
|
/// Relays cap event size, so we downscale (longest edge) and re-encode until the
|
|
/// base64 fits under [maxBytes]. Returns null when the bytes aren't a decodable
|
|
/// image or no size small enough is reachable — the offer then publishes without
|
|
/// a photo (graceful, never a hard failure).
|
|
String? offerThumbnailDataUri(
|
|
Uint8List bytes, {
|
|
int maxBytes = 40000,
|
|
List<int> edges = const [320, 256, 192, 128],
|
|
int quality = 68,
|
|
}) {
|
|
try {
|
|
final decoded = img.decodeImage(bytes);
|
|
if (decoded == null) return null;
|
|
|
|
for (final edge in edges) {
|
|
final resized = _fitWithin(decoded, edge);
|
|
final jpeg = img.encodeJpg(resized, quality: quality);
|
|
final b64 = base64.encode(jpeg);
|
|
if (b64.length <= maxBytes) return 'data:image/jpeg;base64,$b64';
|
|
}
|
|
return null; // even the smallest edge didn't fit — skip the image
|
|
} catch (_) {
|
|
return null; // undecodable/corrupt bytes — publish without a photo
|
|
}
|
|
}
|
|
|
|
/// Builds a small JPEG thumbnail (longest edge [edge]px) for the inventory-list
|
|
/// avatar, so the list never has to decode the full-resolution photo. Returns
|
|
/// raw JPEG bytes, or null on undecodable input (the caller falls back to the
|
|
/// full photo). Kept as local bytes — regenerable, so it is excluded from sync
|
|
/// and backups.
|
|
Uint8List? inventoryThumbnailBytes(
|
|
Uint8List bytes, {
|
|
int edge = 96,
|
|
int quality = 72,
|
|
}) {
|
|
try {
|
|
final decoded = img.decodeImage(bytes);
|
|
if (decoded == null) return null;
|
|
final resized = _fitWithin(decoded, edge);
|
|
return Uint8List.fromList(img.encodeJpg(resized, quality: quality));
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Extracts the raw bytes from a `data:...;base64,…` URI, or null when [uri] is
|
|
/// not a base64 data URI. Used by the UI to render an inline thumbnail with
|
|
/// `Image.memory` instead of a network fetch.
|
|
Uint8List? decodeDataUri(String uri) {
|
|
if (!uri.startsWith('data:')) return null;
|
|
try {
|
|
return Uri.parse(uri).data?.contentAsBytes();
|
|
} on FormatException {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Resizes [image] so its longest edge is at most [edge], preserving aspect
|
|
/// ratio. Never upscales a photo that's already smaller.
|
|
img.Image _fitWithin(img.Image image, int edge) {
|
|
if (image.width <= edge && image.height <= edge) return image;
|
|
return image.width >= image.height
|
|
? img.copyResize(image, width: edge)
|
|
: img.copyResize(image, height: edge);
|
|
}
|