tane/apps/app_seeds/lib/data/variety_repository.dart
vjrj 17073dd560 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.
2026-07-08 12:12:51 +02:00

705 lines
21 KiB
Dart

import 'package:async/async.dart';
import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart';
import 'package:equatable/equatable.dart';
import '../db/database.dart';
import '../db/enums.dart';
/// A lightweight row for the inventory list (only what the list renders).
class VarietyListItem extends Equatable {
const VarietyListItem({
required this.id,
required this.label,
this.category,
this.scientificName,
this.photo,
});
final String id;
final String label;
final String? category;
/// Scientific name of the linked species, shown as the tile subtitle.
final String? scientificName;
/// First photo (encrypted BLOB) for the avatar, or null → show an initial.
final Uint8List? photo;
@override
List<Object?> get props => [id, label, category, scientificName, photo];
}
/// One germination test on a lot; [rate] is derived (0..1), null when it can't
/// be computed.
class GerminationEntry extends Equatable {
const GerminationEntry({
required this.id,
this.testedOn,
this.sampleSize,
this.germinatedCount,
this.notes,
});
final String id;
final int? testedOn; // ms since epoch
final int? sampleSize;
final int? germinatedCount;
final String? notes;
double? get rate {
final sample = sampleSize;
final germinated = germinatedCount;
if (sample == null || sample <= 0 || germinated == null) return null;
return germinated / sample;
}
@override
List<Object?> get props => [id, testedOn, sampleSize, germinatedCount, notes];
}
/// One held batch of a variety, for the detail view. [germinationTests] are
/// ordered most-recent first, so `germinationTests.first` is the latest.
class VarietyLot extends Equatable {
const VarietyLot({
required this.id,
this.type = LotType.seed,
this.harvestYear,
this.harvestMonth,
this.quantity,
this.storageLocation,
this.germinationTests = const [],
});
final String id;
final LotType type;
final int? harvestYear;
/// Optional harvest month (1..12). Only meaningful when [harvestYear] is set.
final int? harvestMonth;
final Quantity? quantity;
final String? storageLocation;
final List<GerminationEntry> germinationTests;
/// The most recent germination rate (0..1), or null if there are no tests.
double? get latestGerminationRate =>
germinationTests.isEmpty ? null : germinationTests.first.rate;
@override
List<Object?> get props => [
id,
type,
harvestYear,
harvestMonth,
quantity,
storageLocation,
germinationTests,
];
}
/// One vernacular (common) name of a variety.
class VernacularName extends Equatable {
const VernacularName({
required this.id,
required this.name,
this.language,
this.region,
});
final String id;
final String name;
final String? language;
final String? region;
@override
List<Object?> get props => [id, name, language, region];
}
/// One stored photo of a variety (encrypted BLOB in the DB).
class VarietyPhoto extends Equatable {
const VarietyPhoto({required this.id, required this.bytes});
final String id;
final Uint8List bytes;
@override
List<Object?> get props => [id, bytes];
}
/// The full detail of one variety (identity + its lots, names and photos).
class VarietyDetail extends Equatable {
const VarietyDetail({
required this.id,
required this.label,
this.category,
this.notes,
this.speciesId,
this.scientificName,
this.lots = const [],
this.vernacularNames = const [],
this.photos = const [],
});
final String id;
final String label;
final String? category;
final String? notes;
final String? speciesId;
final String? scientificName;
final List<VarietyLot> lots;
final List<VernacularName> vernacularNames;
final List<VarietyPhoto> photos;
@override
List<Object?> get props => [
id,
label,
category,
notes,
speciesId,
scientificName,
lots,
vernacularNames,
photos,
];
}
/// Reads and writes the inventory. The encrypted Drift DB is the single source
/// of truth; [watchInventory] exposes a reactive stream the UI subscribes to.
///
/// Every write stamps the row with the local [Hlc] and [nodeId] so the CRDT
/// metadata is correct from day one, even though sync does not exist yet.
class VarietyRepository {
VarietyRepository(
this._db, {
required this.idGen,
required this.nodeId,
int Function()? nowMillis,
}) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch),
_clock = Hlc.zero(nodeId);
final AppDatabase _db;
final IdGen idGen;
final String nodeId;
final int Function() _now;
Hlc _clock;
/// 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<List<VarietyListItem>> watchInventory() {
final triggers = StreamGroup.merge<void>([
(_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<List<VarietyListItem>> _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<String>().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).
Future<Map<String, Uint8List>> _firstPhotosFor(
List<String> varietyIds,
) async {
if (varietyIds.isEmpty) return const {};
final rows =
await (_db.select(_db.attachments)..where(
(a) =>
a.parentId.isIn(varietyIds) &
a.parentType.equalsValue(ParentType.variety) &
a.kind.equalsValue(AttachmentKind.photo) &
a.isDeleted.equals(false),
))
.get();
final byVariety = <String, Uint8List>{};
for (final row in rows) {
final bytes = row.bytes;
if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes);
}
return byVariety;
}
/// Maps each of [speciesIds] to its scientific name (one query).
Future<Map<String, String>> _scientificNamesFor(
Set<String> speciesIds,
) async {
if (speciesIds.isEmpty) return const {};
final rows = await (_db.select(
_db.species,
)..where((s) => s.id.isIn(speciesIds))).get();
return {for (final s in rows) s.id: s.scientificName};
}
/// The 20-second quick-add: a [label] (required) plus an optional [category],
/// an optional [quantity] (creates a Lot) and an optional [photoBytes]
/// (stored as an encrypted BLOB Attachment). Returns the new Variety id.
Future<String> addQuickVariety({
required String label,
String? category,
LotType lotType = LotType.seed,
Quantity? quantity,
Uint8List? photoBytes,
}) async {
final varietyId = idGen.newId();
await _db.transaction(() async {
final (created, updated) = _stamp();
await _db
.into(_db.varieties)
.insert(
VarietiesCompanion.insert(
id: varietyId,
label: label,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
category: Value(category),
),
);
if (quantity != null) {
final (created, updated) = _stamp();
await _db
.into(_db.lots)
.insert(
LotsCompanion.insert(
id: idGen.newId(),
varietyId: varietyId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
type: Value(lotType),
quantityKind: Value(quantity.kind.name),
quantityPrecise: Value(quantity.count?.toDouble()),
quantityLabel: Value(quantity.label),
),
);
}
if (photoBytes != null) {
final (created, updated) = _stamp();
await _db
.into(_db.attachments)
.insert(
AttachmentsCompanion.insert(
id: idGen.newId(),
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
parentType: ParentType.variety,
parentId: varietyId,
kind: AttachmentKind.photo,
bytes: Value(photoBytes),
mimeType: const Value('image/jpeg'),
),
);
}
});
return varietyId;
}
/// Reactively watches one variety with its lots, vernacular names and first
/// photo. Emits `null` if the variety does not exist or is soft-deleted.
///
/// Re-emits whenever the variety or any related row changes: a merged trigger
/// stream over the four tables drives a full reload, so adding a lot (a change
/// to a *different* table) still refreshes the view.
Stream<VarietyDetail?> watchVariety(String id) {
final triggers = StreamGroup.merge<void>([
(_db.select(
_db.varieties,
)..where((v) => v.id.equals(id))).watch().map((_) {}),
(_db.select(
_db.lots,
)..where((l) => l.varietyId.equals(id))).watch().map((_) {}),
(_db.select(
_db.varietyVernacularNames,
)..where((n) => n.varietyId.equals(id))).watch().map((_) {}),
(_db.select(
_db.attachments,
)..where((a) => a.parentId.equals(id))).watch().map((_) {}),
// Coarse: any germination-test change re-emits (catalog of tests is tiny).
_db.select(_db.germinationTests).watch().map((_) {}),
]);
return triggers.asyncMap((_) => _loadVariety(id));
}
Future<VarietyDetail?> _loadVariety(String id) async {
final v =
await (_db.select(_db.varieties)
..where((t) => t.id.equals(id) & t.isDeleted.equals(false)))
.getSingleOrNull();
if (v == null) return null;
String? scientificName;
if (v.speciesId != null) {
final species = await (_db.select(
_db.species,
)..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull();
scientificName = species?.scientificName;
}
final lots =
await (_db.select(_db.lots)
..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false))
..orderBy([(l) => OrderingTerm.desc(l.harvestYear)]))
.get();
final lotIds = lots.map((l) => l.id).toList();
final testsByLot = <String, List<GerminationEntry>>{};
if (lotIds.isNotEmpty) {
final tests =
await (_db.select(_db.germinationTests)
..where((g) => g.lotId.isIn(lotIds) & g.isDeleted.equals(false))
..orderBy([(g) => OrderingTerm.desc(g.testedOn)]))
.get();
for (final t in tests) {
testsByLot
.putIfAbsent(t.lotId, () => [])
.add(
GerminationEntry(
id: t.id,
testedOn: t.testedOn,
sampleSize: t.sampleSize,
germinatedCount: t.germinatedCount,
notes: t.notes,
),
);
}
}
final names = await (_db.select(
_db.varietyVernacularNames,
)..where((n) => n.varietyId.equals(id) & n.isDeleted.equals(false))).get();
final photos =
await (_db.select(_db.attachments)
..where(
(a) =>
a.parentId.equals(id) &
a.parentType.equalsValue(ParentType.variety) &
a.kind.equalsValue(AttachmentKind.photo) &
a.isDeleted.equals(false),
)
..orderBy([(a) => OrderingTerm(expression: a.createdAt)]))
.get();
return VarietyDetail(
id: v.id,
label: v.label,
category: v.category,
notes: v.notes,
speciesId: v.speciesId,
scientificName: scientificName,
lots: lots.map((l) => _toLot(l, testsByLot[l.id] ?? const [])).toList(),
vernacularNames: names
.map(
(n) => VernacularName(
id: n.id,
name: n.name,
language: n.language,
region: n.region,
),
)
.toList(),
photos: [
for (final p in photos)
if (p.bytes != null) VarietyPhoto(id: p.id, bytes: p.bytes!),
],
);
}
/// Links [varietyId] to a catalog [speciesId]. If the variety has no category
/// yet, prefill it from the species' botanical family (data-model §6).
Future<void> linkSpecies(String varietyId, String speciesId) async {
final (_, updated) = _stamp();
final species = await (_db.select(
_db.species,
)..where((s) => s.id.equals(speciesId))).getSingleOrNull();
final variety = await (_db.select(
_db.varieties,
)..where((v) => v.id.equals(varietyId))).getSingleOrNull();
final categoryIsEmpty =
variety?.category == null || variety!.category!.trim().isEmpty;
final prefill = categoryIsEmpty ? species?.family : null;
await (_db.update(
_db.varieties,
)..where((v) => v.id.equals(varietyId))).write(
VarietiesCompanion(
speciesId: Value(speciesId),
category: prefill == null ? const Value.absent() : Value(prefill),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Updates a variety's scalar fields (LWW). Passing null clears [category]
/// and [notes]; a null [label] leaves the label unchanged.
Future<void> updateVariety({
required String id,
String? label,
String? category,
String? notes,
}) async {
final (_, updated) = _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion(
label: label == null ? const Value.absent() : Value(label),
category: Value(category),
notes: Value(notes),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Adds a lot (a held batch) to a variety. Returns the new lot id.
Future<String> addLot({
required String varietyId,
LotType type = LotType.seed,
int? harvestYear,
int? harvestMonth,
Quantity? quantity,
String? storageLocation,
}) async {
final (created, updated) = _stamp();
final id = idGen.newId();
await _db
.into(_db.lots)
.insert(
LotsCompanion.insert(
id: id,
varietyId: varietyId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
type: Value(type),
harvestYear: Value(harvestYear),
harvestMonth: Value(harvestMonth),
quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.count?.toDouble()),
quantityLabel: Value(quantity?.label),
storageLocation: Value(storageLocation),
),
);
return id;
}
/// Updates an existing lot's fields (LWW). The [quantity], [harvestYear] and
/// [harvestMonth] are set as given (null clears them).
Future<void> updateLot({
required String lotId,
required LotType type,
int? harvestYear,
int? harvestMonth,
Quantity? quantity,
String? storageLocation,
}) async {
final (_, updated) = _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
LotsCompanion(
type: Value(type),
harvestYear: Value(harvestYear),
harvestMonth: Value(harvestMonth),
quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.count?.toDouble()),
quantityLabel: Value(quantity?.label),
storageLocation: Value(storageLocation),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Soft-deletes a lot (tombstone); it leaves the variety's lot list.
Future<void> softDeleteLot(String lotId) async {
final (_, updated) = _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
LotsCompanion(
isDeleted: const Value(true),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Adds a vernacular (common) name to a variety. Returns the new row id.
Future<String> addVernacularName(
String varietyId,
String name, {
String? language,
String? region,
}) async {
final (created, updated) = _stamp();
final id = idGen.newId();
await _db
.into(_db.varietyVernacularNames)
.insert(
VarietyVernacularNamesCompanion.insert(
id: id,
varietyId: varietyId,
name: name,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
language: Value(language),
region: Value(region),
),
);
return id;
}
/// Soft-deletes a vernacular name (tombstone).
Future<void> removeVernacularName(String nameId) async {
final (_, updated) = _stamp();
await (_db.update(
_db.varietyVernacularNames,
)..where((n) => n.id.equals(nameId))).write(
VarietyVernacularNamesCompanion(
isDeleted: const Value(true),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Adds a photo (encrypted BLOB) to a variety. Returns the new attachment id.
Future<String> addPhoto(String varietyId, Uint8List bytes) async {
final (created, updated) = _stamp();
final id = idGen.newId();
await _db
.into(_db.attachments)
.insert(
AttachmentsCompanion.insert(
id: id,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
parentType: ParentType.variety,
parentId: varietyId,
kind: AttachmentKind.photo,
bytes: Value(bytes),
mimeType: const Value('image/jpeg'),
),
);
return id;
}
/// Soft-deletes a photo (tombstone).
Future<void> removePhoto(String attachmentId) async {
final (_, updated) = _stamp();
await (_db.update(
_db.attachments,
)..where((a) => a.id.equals(attachmentId))).write(
AttachmentsCompanion(
isDeleted: const Value(true),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Soft-deletes a variety (tombstone); it disappears from the inventory but
/// the row survives for correct CRDT merges later.
Future<void> softDeleteVariety(String id) async {
final (_, updated) = _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion(
isDeleted: const Value(true),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Records a germination test for a lot. Returns the new test id.
Future<String> addGerminationTest({
required String lotId,
int? testedOn,
int? sampleSize,
int? germinatedCount,
String? notes,
}) async {
final (created, updated) = _stamp();
final id = idGen.newId();
await _db
.into(_db.germinationTests)
.insert(
GerminationTestsCompanion.insert(
id: id,
lotId: lotId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
testedOn: Value(testedOn),
sampleSize: Value(sampleSize),
germinatedCount: Value(germinatedCount),
notes: Value(notes),
),
);
return id;
}
VarietyLot _toLot(Lot l, List<GerminationEntry> germinationTests) {
final hasQuantity =
l.quantityKind != null ||
l.quantityPrecise != null ||
l.quantityLabel != null;
return VarietyLot(
id: l.id,
type: l.type,
harvestYear: l.harvestYear,
harvestMonth: l.harvestMonth,
storageLocation: l.storageLocation,
germinationTests: germinationTests,
quantity: hasQuantity
? Quantity(
kind: _parseKind(l.quantityKind),
count: l.quantityPrecise,
label: l.quantityLabel,
)
: null,
);
}
QuantityKind _parseKind(String? name) =>
QuantityKind.values.asNameMap()[name] ?? QuantityKind.aFew;
/// Advances the local clock and returns `(createdAtMillis, packedHlc)`.
(int, String) _stamp() {
final now = _now();
_clock = _clock.localEvent(now);
return (now, _clock.pack());
}
}