feat(inventory): photo gallery + reactive avatars (+ harvest month/year)

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.
This commit is contained in:
vjrj 2026-07-08 12:12:51 +02:00
parent 5d4053157f
commit 17073dd560
3 changed files with 73 additions and 35 deletions

View file

@ -186,32 +186,45 @@ class VarietyRepository {
/// Emits the non-deleted inventory, ordered by category then label, each with /// Emits the non-deleted inventory, ordered by category then label, each with
/// its first photo for the avatar. /// 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<List<VarietyListItem>> watchInventory() { Stream<List<VarietyListItem>> watchInventory() {
final query = _db.select(_db.varieties) final triggers = StreamGroup.merge<void>([
..where((v) => v.isDeleted.equals(false)) (_db.select(
..orderBy([ _db.varieties,
(v) => OrderingTerm(expression: v.category), )..where((v) => v.isDeleted.equals(false))).watch().map((_) {}),
(v) => OrderingTerm(expression: v.label), _db.select(_db.attachments).watch().map((_) {}),
]); _db.select(_db.species).watch().map((_) {}),
return query.watch().asyncMap((rows) async { ]);
final photos = await _firstPhotosFor(rows.map((v) => v.id).toList()); return triggers.asyncMap((_) => _loadInventory());
final sciNames = await _scientificNamesFor( }
rows.map((v) => v.speciesId).whereType<String>().toSet(),
); Future<List<VarietyListItem>> _loadInventory() async {
return rows final rows =
.map( await (_db.select(_db.varieties)
(v) => VarietyListItem( ..where((v) => v.isDeleted.equals(false))
id: v.id, ..orderBy([
label: v.label, (v) => OrderingTerm(expression: v.category),
category: v.category, (v) => OrderingTerm(expression: v.label),
scientificName: v.speciesId == null ]))
? null .get();
: sciNames[v.speciesId], final photos = await _firstPhotosFor(rows.map((v) => v.id).toList());
photo: photos[v.id], final sciNames = await _scientificNamesFor(
), rows.map((v) => v.speciesId).whereType<String>().toSet(),
) );
.toList(); 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). /// Loads the first photo BLOB for each of [varietyIds] (one query).

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:async/async.dart'; import 'package:async/async.dart';
import 'package:commons_core/commons_core.dart'; import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -184,4 +186,30 @@ void main() {
await repo.removeVernacularName(nameId); await repo.removeVernacularName(nameId);
expect((await repo.watchVariety(id).first)!.vernacularNames, isEmpty); 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();
});
} }

View file

@ -18,16 +18,13 @@ void main() {
await db.close(); await db.close();
}); });
test( test('upgrades v1 → v3 (adds Lot.type, then harvestMonth) and matches the '
'upgrades v1 → v3 (adds Lot.type, then harvestMonth) and matches the ' 'fresh schema', () async {
'fresh schema', final connection = await verifier.startAt(1);
() async { final db = AppDatabase(connection);
final connection = await verifier.startAt(1); await verifier.migrateAndValidate(db, 3);
final db = AppDatabase(connection); await db.close();
await verifier.migrateAndValidate(db, 3); });
await db.close();
},
);
test( test(
'upgrades v2 → v3 (adds Lot.harvestMonth) and matches the fresh schema', 'upgrades v2 → v3 (adds Lot.harvestMonth) and matches the fresh schema',