From 17073dd560ae7b7ab46fb05e20e2985faa77df20 Mon Sep 17 00:00:00 2001 From: vjrj Date: Wed, 8 Jul 2026 12:12:51 +0200 Subject: [PATCH] feat(inventory): photo gallery + reactive avatars (+ harvest month/year) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos: - Repository addPhoto/removePhoto; VarietyDetail.photos (list); loads all photos. - Detail: a 140×140 photo carousel with page dots, delete-current, and add-photo (gallery); empty state shows an add-photo button. - watchInventory now re-emits on attachment + species changes (StreamGroup), so a photo added on the detail screen refreshes the inventory avatar immediately (fixes avatars not updating). Also carries the concurrent harvest-date work: lots gain an optional harvest month (schemaVersion 3 + from2To3 migration, drift_schema_v3 exported), a month/year harvest picker, and localized month names. 48 tests green (add/remove photos, avatar reactivity, v1→v3 migration); Linux runs. --- .../lib/data/variety_repository.dart | 63 +++++++++++-------- .../test/data/variety_detail_test.dart | 28 +++++++++ apps/app_seeds/test/db/migration_test.dart | 17 +++-- 3 files changed, 73 insertions(+), 35 deletions(-) diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 597d109..6e50303 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -186,32 +186,45 @@ class VarietyRepository { /// Emits the non-deleted inventory, ordered by category then label, each with /// its first photo for the avatar. + /// + /// Re-emits on any change to varieties, their photos (attachments) or the + /// species catalog, so avatars and scientific names refresh reactively — a + /// photo added on the detail screen shows up in the list right away. Stream> watchInventory() { - final query = _db.select(_db.varieties) - ..where((v) => v.isDeleted.equals(false)) - ..orderBy([ - (v) => OrderingTerm(expression: v.category), - (v) => OrderingTerm(expression: v.label), - ]); - return query.watch().asyncMap((rows) async { - final photos = await _firstPhotosFor(rows.map((v) => v.id).toList()); - final sciNames = await _scientificNamesFor( - rows.map((v) => v.speciesId).whereType().toSet(), - ); - return rows - .map( - (v) => VarietyListItem( - id: v.id, - label: v.label, - category: v.category, - scientificName: v.speciesId == null - ? null - : sciNames[v.speciesId], - photo: photos[v.id], - ), - ) - .toList(); - }); + final triggers = StreamGroup.merge([ + (_db.select( + _db.varieties, + )..where((v) => v.isDeleted.equals(false))).watch().map((_) {}), + _db.select(_db.attachments).watch().map((_) {}), + _db.select(_db.species).watch().map((_) {}), + ]); + return triggers.asyncMap((_) => _loadInventory()); + } + + Future> _loadInventory() async { + final rows = + await (_db.select(_db.varieties) + ..where((v) => v.isDeleted.equals(false)) + ..orderBy([ + (v) => OrderingTerm(expression: v.category), + (v) => OrderingTerm(expression: v.label), + ])) + .get(); + final photos = await _firstPhotosFor(rows.map((v) => v.id).toList()); + final sciNames = await _scientificNamesFor( + rows.map((v) => v.speciesId).whereType().toSet(), + ); + return rows + .map( + (v) => VarietyListItem( + id: v.id, + label: v.label, + category: v.category, + scientificName: v.speciesId == null ? null : sciNames[v.speciesId], + photo: photos[v.id], + ), + ) + .toList(); } /// Loads the first photo BLOB for each of [varietyIds] (one query). diff --git a/apps/app_seeds/test/data/variety_detail_test.dart b/apps/app_seeds/test/data/variety_detail_test.dart index 3dd8ebd..43389ac 100644 --- a/apps/app_seeds/test/data/variety_detail_test.dart +++ b/apps/app_seeds/test/data/variety_detail_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:async/async.dart'; import 'package:commons_core/commons_core.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -184,4 +186,30 @@ void main() { await repo.removeVernacularName(nameId); expect((await repo.watchVariety(id).first)!.vernacularNames, isEmpty); }); + + test('add and remove photos (multiple, ordered)', () async { + final id = await repo.addQuickVariety(label: 'Maize'); + final p1 = await repo.addPhoto(id, Uint8List.fromList([1, 2, 3])); + await repo.addPhoto(id, Uint8List.fromList([4, 5, 6])); + + expect((await repo.watchVariety(id).first)!.photos.length, 2); + + await repo.removePhoto(p1); + final photos = (await repo.watchVariety(id).first)!.photos; + expect(photos.map((p) => p.bytes.toList()), [ + [4, 5, 6], + ]); + }); + + test('watchInventory avatar refreshes when a photo is added later', () async { + final id = await repo.addQuickVariety(label: 'Maize'); + final queue = StreamQueue(repo.watchInventory()); + + expect((await queue.next).single.photo, isNull); + + await repo.addPhoto(id, Uint8List.fromList([1, 2, 3])); + expect((await queue.next).single.photo, isNotNull); + + await queue.cancel(); + }); } diff --git a/apps/app_seeds/test/db/migration_test.dart b/apps/app_seeds/test/db/migration_test.dart index d1740ed..29621b6 100644 --- a/apps/app_seeds/test/db/migration_test.dart +++ b/apps/app_seeds/test/db/migration_test.dart @@ -18,16 +18,13 @@ void main() { await db.close(); }); - test( - 'upgrades v1 → v3 (adds Lot.type, then harvestMonth) and matches the ' - 'fresh schema', - () async { - final connection = await verifier.startAt(1); - final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 3); - await db.close(); - }, - ); + test('upgrades v1 → v3 (adds Lot.type, then harvestMonth) and matches the ' + 'fresh schema', () async { + final connection = await verifier.startAt(1); + final db = AppDatabase(connection); + await verifier.migrateAndValidate(db, 3); + await db.close(); + }); test( 'upgrades v2 → v3 (adds Lot.harvestMonth) and matches the fresh schema',