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

@ -32,7 +32,7 @@ class AppDatabase extends _$AppDatabase {
/// Current schema version; also stamped into interchange exports so an
/// importer knows which app generation wrote the file (data-model §7).
static const int currentSchemaVersion = 13;
static const int currentSchemaVersion = 14;
@override
int get schemaVersion => currentSchemaVersion;
@ -196,9 +196,40 @@ class AppDatabase extends _$AppDatabase {
await m.createTable(gardenOutcomes);
}
}
// v14: scalability for large inventories. A local, regenerable [thumbnail]
// BLOB on attachments (so the list never decodes full-resolution photos)
// plus indexes on the columns the inventory list filters/joins on. All
// additive and guarded, so a half-migrated dev database re-runs cleanly
// (see the v7 note above). Existing thumbnails are backfilled lazily in
// the background, not here (image decoding in a migration is slow/fragile).
if (from < 14) {
if (!await _hasColumn('attachments', 'thumbnail')) {
await m.addColumn(attachments, attachments.thumbnail);
}
// Declared as `@TableIndex` on the tables, so a fresh install gets them
// via createAll(); existing databases get them here. Guarded against a
// half-migrated dev database (see the v7 note above).
for (final index in [
idxVarietiesDeletedDraft,
idxAttachmentsParent,
idxLotsVariety,
]) {
if (!await _hasIndex(index.entityName)) await m.create(index);
}
}
},
);
/// Whether an index named [name] already exists keeps the additive v14
/// index creation idempotent against partially-migrated databases.
Future<bool> _hasIndex(String name) async {
final rows = await customSelect(
"SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?",
variables: [Variable.withString(name)],
).get();
return rows.isNotEmpty;
}
/// Whether a table named [table] already exists. Keeps the additive v8
/// table creation idempotent against partially-migrated databases.
Future<bool> _hasTable(String table) async {