Import a spreadsheet list (the same 21-column schema the CSV export already produces) as new varieties/lots. Tolerant reader: columns matched by header name in any order, only variety_label required, unknown enums fall back to a safe default, rows sharing variety_id collapse into one variety with several lots. Species re-linked by scientific name. Additive by design (fresh ids + HLC), so it never overwrites existing rows; JSON stays the canonical id-reconciling import. Adds a hand-rolled RFC 4180 parser (no new dependency), an importCsv path in VarietyRepository/ExportImportService, an 'Import from CSV' tile in the backup section, and en/es strings. Note: i18n .g.dart also carries pre-existing About/intro strings from the working tree (regenerated by slang).
1188 lines
38 KiB
Dart
1188 lines
38 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';
|
|
import 'export_import/import_reconciler.dart';
|
|
import 'export_import/inventory_csv_codec.dart';
|
|
import 'export_import/inventory_snapshot.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,
|
|
this.lotTypes = const {},
|
|
});
|
|
|
|
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;
|
|
|
|
/// Distinct lot forms this variety currently holds (seed, plant, tree…),
|
|
/// used to filter the list by form. Empty when it has no lots yet.
|
|
final Set<LotType> lotTypes;
|
|
|
|
@override
|
|
List<Object?> get props => [
|
|
id,
|
|
label,
|
|
category,
|
|
scientificName,
|
|
photo,
|
|
lotTypes,
|
|
];
|
|
}
|
|
|
|
/// 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.presentation,
|
|
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;
|
|
|
|
/// How living material is packaged (pot, tray, bare-root…); null for seeds.
|
|
final Presentation? presentation;
|
|
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,
|
|
presentation,
|
|
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];
|
|
}
|
|
|
|
/// An external URL attached to a variety (Wikipedia, a forum thread…).
|
|
class ExternalLinkItem extends Equatable {
|
|
const ExternalLinkItem({required this.id, required this.url, this.title});
|
|
|
|
final String id;
|
|
final String url;
|
|
final String? title;
|
|
|
|
/// What to show: the title if present, otherwise the URL.
|
|
String get display =>
|
|
(title != null && title!.trim().isNotEmpty) ? title! : url;
|
|
|
|
@override
|
|
List<Object?> get props => [id, url, title];
|
|
}
|
|
|
|
/// 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 [],
|
|
this.links = 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;
|
|
final List<ExternalLinkItem> links;
|
|
|
|
@override
|
|
List<Object?> get props => [
|
|
id,
|
|
label,
|
|
category,
|
|
notes,
|
|
speciesId,
|
|
scientificName,
|
|
lots,
|
|
vernacularNames,
|
|
photos,
|
|
links,
|
|
];
|
|
}
|
|
|
|
/// 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((_) {}),
|
|
// Lots drive the form filter, so re-emit when they change too.
|
|
_db.select(_db.lots).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 varietyIds = rows.map((v) => v.id).toList();
|
|
final photos = await _firstPhotosFor(varietyIds);
|
|
final sciNames = await _scientificNamesFor(
|
|
rows.map((v) => v.speciesId).whereType<String>().toSet(),
|
|
);
|
|
final lotTypes = await _lotTypesFor(varietyIds);
|
|
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],
|
|
lotTypes: lotTypes[v.id] ?? const {},
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
|
|
/// Maps each variety to the distinct lot forms it currently holds (one
|
|
/// query). Varieties without lots are simply absent from the map.
|
|
Future<Map<String, Set<LotType>>> _lotTypesFor(
|
|
List<String> varietyIds,
|
|
) async {
|
|
if (varietyIds.isEmpty) return const {};
|
|
final rows =
|
|
await (_db.select(_db.lots)..where(
|
|
(l) => l.varietyId.isIn(varietyIds) & l.isDeleted.equals(false),
|
|
))
|
|
.get();
|
|
final byVariety = <String, Set<LotType>>{};
|
|
for (final row in rows) {
|
|
(byVariety[row.varietyId] ??= <LotType>{}).add(row.type);
|
|
}
|
|
return byVariety;
|
|
}
|
|
|
|
/// 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),
|
|
)
|
|
..orderBy([
|
|
(a) => OrderingTerm(expression: a.sortOrder),
|
|
(a) => OrderingTerm(expression: a.createdAt),
|
|
]))
|
|
.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((_) {}),
|
|
(_db.select(
|
|
_db.externalLinks,
|
|
)..where((e) => e.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.sortOrder),
|
|
(a) => OrderingTerm(expression: a.createdAt),
|
|
]))
|
|
.get();
|
|
|
|
final links =
|
|
await (_db.select(_db.externalLinks)
|
|
..where(
|
|
(e) =>
|
|
e.parentId.equals(id) &
|
|
e.parentType.equalsValue(ParentType.variety) &
|
|
e.isDeleted.equals(false),
|
|
)
|
|
..orderBy([(e) => OrderingTerm(expression: e.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: links
|
|
.map((e) => ExternalLinkItem(id: e.id, url: e.url, title: e.title))
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
/// 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,
|
|
Presentation? presentation,
|
|
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),
|
|
presentation: Value(presentation),
|
|
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,
|
|
Presentation? presentation,
|
|
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),
|
|
presentation: Value(presentation),
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Makes [attachmentId] the cover (preferred) attachment of [varietyId] by
|
|
/// giving it a [sortOrder] below every current sibling. The cover is what the
|
|
/// inventory list shows as the avatar and the detail gallery shows first.
|
|
Future<void> setPreferredPhoto(String varietyId, String attachmentId) async {
|
|
final siblings =
|
|
await (_db.select(_db.attachments)..where(
|
|
(a) =>
|
|
a.parentId.equals(varietyId) &
|
|
a.parentType.equalsValue(ParentType.variety) &
|
|
a.isDeleted.equals(false),
|
|
))
|
|
.get();
|
|
var minOrder = 0;
|
|
for (final a in siblings) {
|
|
if (a.sortOrder < minOrder) minOrder = a.sortOrder;
|
|
}
|
|
final (_, updated) = _stamp();
|
|
await (_db.update(
|
|
_db.attachments,
|
|
)..where((a) => a.id.equals(attachmentId))).write(
|
|
AttachmentsCompanion(
|
|
sortOrder: Value(minOrder - 1),
|
|
updatedAt: Value(updated),
|
|
lastAuthor: Value(nodeId),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Adds an external link (URL, optional title) to a variety. Returns its id.
|
|
Future<String> addExternalLink(
|
|
String varietyId,
|
|
String url, {
|
|
String? title,
|
|
}) async {
|
|
final (created, updated) = _stamp();
|
|
final id = idGen.newId();
|
|
await _db
|
|
.into(_db.externalLinks)
|
|
.insert(
|
|
ExternalLinksCompanion.insert(
|
|
id: id,
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
parentType: ParentType.variety,
|
|
parentId: varietyId,
|
|
url: url,
|
|
title: Value(title),
|
|
),
|
|
);
|
|
return id;
|
|
}
|
|
|
|
/// Soft-deletes an external link (tombstone).
|
|
Future<void> removeExternalLink(String linkId) async {
|
|
final (_, updated) = _stamp();
|
|
await (_db.update(
|
|
_db.externalLinks,
|
|
)..where((e) => e.id.equals(linkId))).write(
|
|
ExternalLinksCompanion(
|
|
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;
|
|
}
|
|
|
|
/// Snapshots the live inventory (tombstones excluded) for the interchange
|
|
/// export — data-model §7. Includes photo bytes; the JSON codec embeds them
|
|
/// as base64 and the CSV codec ignores them.
|
|
Future<InventorySnapshot> exportInventory() async {
|
|
final varieties = await (_db.select(
|
|
_db.varieties,
|
|
)..where((v) => v.isDeleted.equals(false))).get();
|
|
final speciesNamesById = await _scientificNamesFor(
|
|
varieties.map((v) => v.speciesId).whereType<String>().toSet(),
|
|
);
|
|
return InventorySnapshot(
|
|
varieties: varieties,
|
|
speciesNamesById: speciesNamesById,
|
|
lots: await (_db.select(
|
|
_db.lots,
|
|
)..where((l) => l.isDeleted.equals(false))).get(),
|
|
vernacularNames: await (_db.select(
|
|
_db.varietyVernacularNames,
|
|
)..where((n) => n.isDeleted.equals(false))).get(),
|
|
externalLinks: await (_db.select(
|
|
_db.externalLinks,
|
|
)..where((e) => e.isDeleted.equals(false))).get(),
|
|
germinationTests: await (_db.select(
|
|
_db.germinationTests,
|
|
)..where((g) => g.isDeleted.equals(false))).get(),
|
|
movements: await _db.select(_db.movements).get(),
|
|
parties: await (_db.select(
|
|
_db.parties,
|
|
)..where((p) => p.isDeleted.equals(false))).get(),
|
|
attachments: await (_db.select(
|
|
_db.attachments,
|
|
)..where((a) => a.isDeleted.equals(false))).get(),
|
|
);
|
|
}
|
|
|
|
/// Imports a snapshot, reconciling by row id (UUIDv7) so nothing duplicates:
|
|
/// unknown id → insert preserving the original sync metadata; known mutable
|
|
/// id → last-writer-wins on the packed HLC `updatedAt`; movements are
|
|
/// append-only (insert-if-unknown). Runs in one transaction; afterwards the
|
|
/// local clock absorbs the newest imported stamp so it never runs behind.
|
|
///
|
|
/// Species ids are per-install, so incoming `speciesId`s are re-resolved
|
|
/// against the local catalog by scientific name (unmatched → null).
|
|
Future<ImportSummary> importInventory(InventorySnapshot snapshot) async {
|
|
const reconciler = ImportReconciler();
|
|
final localSpeciesIdByIncoming = await _resolveSpecies(
|
|
snapshot.speciesNamesById,
|
|
);
|
|
final varieties = [
|
|
for (final v in snapshot.varieties)
|
|
v.speciesId == null
|
|
? v
|
|
: v.copyWith(
|
|
speciesId: Value(localSpeciesIdByIncoming[v.speciesId]),
|
|
),
|
|
];
|
|
|
|
var summary = const ImportSummary();
|
|
await _db.transaction(() async {
|
|
summary += await _importMutableRows(
|
|
table: _db.varieties,
|
|
idColumn: _db.varieties.id,
|
|
rows: varieties,
|
|
idOf: (r) => r.id,
|
|
updatedAtOf: (r) => r.updatedAt,
|
|
reconciler: reconciler,
|
|
);
|
|
summary += await _importMutableRows(
|
|
table: _db.lots,
|
|
idColumn: _db.lots.id,
|
|
rows: snapshot.lots,
|
|
idOf: (r) => r.id,
|
|
updatedAtOf: (r) => r.updatedAt,
|
|
reconciler: reconciler,
|
|
);
|
|
summary += await _importMutableRows(
|
|
table: _db.varietyVernacularNames,
|
|
idColumn: _db.varietyVernacularNames.id,
|
|
rows: snapshot.vernacularNames,
|
|
idOf: (r) => r.id,
|
|
updatedAtOf: (r) => r.updatedAt,
|
|
reconciler: reconciler,
|
|
);
|
|
summary += await _importMutableRows(
|
|
table: _db.externalLinks,
|
|
idColumn: _db.externalLinks.id,
|
|
rows: snapshot.externalLinks,
|
|
idOf: (r) => r.id,
|
|
updatedAtOf: (r) => r.updatedAt,
|
|
reconciler: reconciler,
|
|
);
|
|
summary += await _importMutableRows(
|
|
table: _db.germinationTests,
|
|
idColumn: _db.germinationTests.id,
|
|
rows: snapshot.germinationTests,
|
|
idOf: (r) => r.id,
|
|
updatedAtOf: (r) => r.updatedAt,
|
|
reconciler: reconciler,
|
|
);
|
|
summary += await _importMutableRows(
|
|
table: _db.parties,
|
|
idColumn: _db.parties.id,
|
|
rows: snapshot.parties,
|
|
idOf: (r) => r.id,
|
|
updatedAtOf: (r) => r.updatedAt,
|
|
reconciler: reconciler,
|
|
);
|
|
summary += await _importMutableRows(
|
|
table: _db.attachments,
|
|
idColumn: _db.attachments.id,
|
|
rows: snapshot.attachments,
|
|
idOf: (r) => r.id,
|
|
updatedAtOf: (r) => r.updatedAt,
|
|
reconciler: reconciler,
|
|
);
|
|
summary += await _importMovements(snapshot.movements, reconciler);
|
|
});
|
|
|
|
final maxIncoming = reconciler.maxHlc([
|
|
for (final v in snapshot.varieties) v.updatedAt,
|
|
for (final l in snapshot.lots) l.updatedAt,
|
|
for (final n in snapshot.vernacularNames) n.updatedAt,
|
|
for (final e in snapshot.externalLinks) e.updatedAt,
|
|
for (final g in snapshot.germinationTests) g.updatedAt,
|
|
for (final p in snapshot.parties) p.updatedAt,
|
|
for (final a in snapshot.attachments) a.updatedAt,
|
|
]);
|
|
if (maxIncoming != null) {
|
|
_clock = _clock.receiveEvent(maxIncoming, _now());
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
/// Additive import of a spreadsheet [csv]: every parsed variety is inserted
|
|
/// fresh (new UUIDv7 + local HLC), so it never overwrites existing rows and
|
|
/// re-importing the same file adds it again. The canonical, id-reconciling
|
|
/// import is JSON ([importInventory]); this is the low-friction path for a
|
|
/// list a collective already keeps in a spreadsheet.
|
|
///
|
|
/// Species are re-linked by scientific name against the local catalog
|
|
/// (unmatched → null). Returns the number of varieties added.
|
|
Future<ImportSummary> importCsv(CsvImport csv) async {
|
|
var inserted = 0;
|
|
await _db.transaction(() async {
|
|
for (final v in csv.varieties) {
|
|
final speciesId = await _resolveSpeciesByName(v.scientificName);
|
|
final varietyId = idGen.newId();
|
|
final (created, updated) = _stamp();
|
|
await _db
|
|
.into(_db.varieties)
|
|
.insert(
|
|
VarietiesCompanion.insert(
|
|
id: varietyId,
|
|
label: v.label,
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
category: Value(v.category),
|
|
cultivarName: Value(v.cultivarName),
|
|
notes: Value(v.notes),
|
|
speciesId: Value(speciesId),
|
|
),
|
|
);
|
|
inserted++;
|
|
|
|
for (final lot in v.lots) {
|
|
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(lot.type),
|
|
harvestYear: Value(lot.harvestYear),
|
|
harvestMonth: Value(lot.harvestMonth),
|
|
quantityKind: Value(lot.quantityKind),
|
|
quantityPrecise: Value(lot.quantityPrecise),
|
|
quantityLabel: Value(lot.quantityLabel),
|
|
presentation: Value(lot.presentation),
|
|
storageLocation: Value(lot.storageLocation),
|
|
offerStatus: Value(lot.offerStatus),
|
|
),
|
|
);
|
|
}
|
|
|
|
for (final name in v.vernacularNames) {
|
|
final (created, updated) = _stamp();
|
|
await _db
|
|
.into(_db.varietyVernacularNames)
|
|
.insert(
|
|
VarietyVernacularNamesCompanion.insert(
|
|
id: idGen.newId(),
|
|
varietyId: varietyId,
|
|
name: name.name,
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
language: Value(name.language),
|
|
),
|
|
);
|
|
}
|
|
|
|
for (final link in v.links) {
|
|
final (created, updated) = _stamp();
|
|
await _db
|
|
.into(_db.externalLinks)
|
|
.insert(
|
|
ExternalLinksCompanion.insert(
|
|
id: idGen.newId(),
|
|
createdAt: created,
|
|
updatedAt: updated,
|
|
lastAuthor: nodeId,
|
|
parentType: ParentType.variety,
|
|
parentId: varietyId,
|
|
url: link.url,
|
|
title: Value(link.title),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
return ImportSummary(inserted: inserted);
|
|
}
|
|
|
|
/// Resolves a single scientific name to a local catalog species id, or null
|
|
/// when blank or unmatched.
|
|
Future<String?> _resolveSpeciesByName(String? scientificName) async {
|
|
final name = scientificName?.trim();
|
|
if (name == null || name.isEmpty) return null;
|
|
final local = await (_db.select(_db.species)..where(
|
|
(s) => s.scientificName.equals(name) & s.isDeleted.equals(false),
|
|
))
|
|
.getSingleOrNull();
|
|
return local?.id;
|
|
}
|
|
|
|
/// Maps incoming species ids to local catalog ids by scientific name.
|
|
Future<Map<String, String?>> _resolveSpecies(
|
|
Map<String, String> speciesNamesById,
|
|
) async {
|
|
final resolved = <String, String?>{};
|
|
for (final entry in speciesNamesById.entries) {
|
|
final local =
|
|
await (_db.select(_db.species)
|
|
..where(
|
|
(s) =>
|
|
s.scientificName.equals(entry.value) &
|
|
s.isDeleted.equals(false),
|
|
))
|
|
.getSingleOrNull();
|
|
resolved[entry.key] = local?.id;
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
/// LWW import of one mutable table. Existing rows are looked up including
|
|
/// tombstones, so a locally-deleted row only resurrects when the incoming
|
|
/// version is strictly newer — plain last-writer-wins.
|
|
Future<ImportSummary> _importMutableRows<T extends Table, R>({
|
|
required TableInfo<T, R> table,
|
|
required GeneratedColumn<String> idColumn,
|
|
required List<R> rows,
|
|
required String Function(R) idOf,
|
|
required String Function(R) updatedAtOf,
|
|
required ImportReconciler reconciler,
|
|
}) async {
|
|
if (rows.isEmpty) return const ImportSummary();
|
|
final byId = {for (final r in rows) idOf(r): r};
|
|
final existing = await (_db.select(
|
|
table,
|
|
)..where((_) => idColumn.isIn(byId.keys))).get();
|
|
final existingUpdatedAt = {for (final r in existing) idOf(r): updatedAtOf(r)};
|
|
|
|
var inserted = 0, updated = 0, skipped = 0;
|
|
for (final row in byId.values) {
|
|
final action = reconciler.reconcileMutable(
|
|
existingUpdatedAt: existingUpdatedAt[idOf(row)],
|
|
incomingUpdatedAt: updatedAtOf(row),
|
|
);
|
|
switch (action) {
|
|
case ReconcileAction.insert:
|
|
await _db.into(table).insert(row as Insertable<R>);
|
|
inserted++;
|
|
case ReconcileAction.update:
|
|
await _db.update(table).replace(row as Insertable<R>);
|
|
updated++;
|
|
case ReconcileAction.skip:
|
|
skipped++;
|
|
}
|
|
}
|
|
return ImportSummary(inserted: inserted, updated: updated, skipped: skipped);
|
|
}
|
|
|
|
/// Movements are append-only: insert unknown ids, never touch known ones.
|
|
Future<ImportSummary> _importMovements(
|
|
List<Movement> movements,
|
|
ImportReconciler reconciler,
|
|
) async {
|
|
if (movements.isEmpty) return const ImportSummary();
|
|
final ids = movements.map((m) => m.id).toList();
|
|
final existing = await (_db.select(
|
|
_db.movements,
|
|
)..where((m) => m.id.isIn(ids))).get();
|
|
final existingIds = existing.map((m) => m.id).toSet();
|
|
|
|
var inserted = 0, skipped = 0;
|
|
for (final movement in movements) {
|
|
final action = reconciler.reconcileAppendOnly(
|
|
exists: existingIds.contains(movement.id),
|
|
);
|
|
if (action == ReconcileAction.insert) {
|
|
await _db.into(_db.movements).insert(movement);
|
|
inserted++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
}
|
|
return ImportSummary(inserted: inserted, skipped: skipped);
|
|
}
|
|
|
|
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,
|
|
presentation: l.presentation,
|
|
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());
|
|
}
|
|
}
|