perf(inventory): lazy list, photo thumbnails, indexes, debounced reload

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.
This commit is contained in:
vjrj 2026-07-20 17:48:37 +02:00
parent 2884ddd3c7
commit 3de01bd948
12 changed files with 5114 additions and 34 deletions

View file

@ -582,27 +582,47 @@ class _InventoryBody extends StatelessWidget {
}
// Items arrive ordered by category then label; insert a header whenever the
// category changes.
final rows = <Widget>[];
// category changes. Precompute a flat row model (header or item) so the
// ListView can build tiles lazily with a large inventory, building every
// tile upfront (the old `ListView(children:)`) stalled the first paint.
final rows = <_InventoryRow>[];
String? currentCategory;
for (final item in items) {
final category = item.category ?? t.inventory.uncategorized;
if (category != currentCategory) {
currentCategory = category;
rows.add(_CategoryHeader(title: category));
rows.add(_InventoryRow.header(category));
}
rows.add(
_VarietyTile(
item: item,
selectionMode: selectionMode,
selected: selectedIds.contains(item.id),
),
);
rows.add(_InventoryRow.variety(item));
}
return ListView(children: rows);
return ListView.builder(
itemCount: rows.length,
itemBuilder: (context, i) {
final row = rows[i];
final header = row.header;
if (header != null) {
return _CategoryHeader(title: header);
}
return _VarietyTile(
item: row.item!,
selectionMode: selectionMode,
selected: selectedIds.contains(row.item!.id),
);
},
);
}
}
/// A single row in the flattened inventory list: either a category [header] or
/// a variety [item] (exactly one is non-null).
class _InventoryRow {
const _InventoryRow.header(this.header) : item = null;
const _InventoryRow.variety(this.item) : header = null;
final String? header;
final VarietyListItem? item;
}
class _CategoryHeader extends StatelessWidget {
const _CategoryHeader({required this.title});
@ -767,10 +787,21 @@ class _Avatar extends StatelessWidget {
// Decorative: the tile title already announces the variety name, so keep
// the thumbnail / initial out of the semantics tree.
if (photo != null) {
// Decode straight to the 48px avatar size (× device pixel ratio) instead
// of holding the full-resolution photo in the image cache decisive for
// memory with a large inventory of photographed varieties.
final cachePx = (48 * MediaQuery.devicePixelRatioOf(context)).round();
return ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover),
child: Image.memory(
photo,
width: 48,
height: 48,
cacheWidth: cachePx,
cacheHeight: cachePx,
fit: BoxFit.cover,
),
),
);
}