feat(inventory): local sharing — per-lot share terms, filter and printable catalog

The local half of the original Fase 2 (no network, no Offer entity yet):
- Lot sheet gains a 'do you share it?' choice (gift/swap first, sale last,
  never the default); declaring plenty of abundance reveals it with a nudge.
- Lot lines and list tiles badge shared batches; an 'I share' filter and
  a printable PDF catalog (paper bridge for fairs) round out the view.
- Catalog PDF embeds DejaVu (Latin-ext/Cyrillic/Greek); fonts are a
  per-locale resource like the OCR packs.
This commit is contained in:
vjrj 2026-07-09 22:19:55 +02:00
parent e3ec855630
commit ba87bf2719
20 changed files with 982 additions and 36 deletions

View file

@ -22,6 +22,7 @@ class VarietyListItem extends Equatable {
this.isDraft = false,
this.isOrganic = false,
this.needsReproduction = false,
this.isShared = false,
this.viability = SeedViability.unknown,
});
@ -50,6 +51,10 @@ class VarietyListItem extends Equatable {
/// filterable, alongside the automatic viability warning.
final bool needsReproduction;
/// True when at least one lot is marked to give away, swap or sell the
/// "what I share" view filters on this.
final bool isShared;
/// The most-urgent viability status across this variety's seed lots
/// (expired ranks above expiringSoon), so the list can flag what to sow or
/// reproduce before it lapses. [SeedViability.unknown] when nothing is aging
@ -67,10 +72,50 @@ class VarietyListItem extends Equatable {
isDraft,
isOrganic,
needsReproduction,
isShared,
viability,
];
}
/// One shared lot for the printable catalog: the variety it belongs to plus
/// the batch facts a fair-goer needs (what, from when, how much, on what
/// terms). Built by [VarietyRepository.sharedCatalog].
class SharedCatalogEntry extends Equatable {
const SharedCatalogEntry({
required this.varietyLabel,
required this.offerStatus,
this.scientificName,
this.category,
this.type = LotType.seed,
this.harvestYear,
this.quantity,
this.abundance,
});
final String varietyLabel;
final String? scientificName;
final String? category;
final LotType type;
final int? harvestYear;
final Quantity? quantity;
final Abundance? abundance;
/// Never [OfferStatus.private] private lots don't enter the catalog.
final OfferStatus offerStatus;
@override
List<Object?> get props => [
varietyLabel,
scientificName,
category,
type,
harvestYear,
quantity,
abundance,
offerStatus,
];
}
/// One germination test on a lot; [rate] is derived (0..1), null when it can't
/// be computed.
class GerminationEntry extends Equatable {
@ -141,6 +186,7 @@ class VarietyLot extends Equatable {
this.originPlace,
this.abundance,
this.preservationFormat,
this.offerStatus = OfferStatus.private,
this.germinationTests = const [],
this.conditionChecks = const [],
});
@ -168,6 +214,11 @@ class VarietyLot extends Equatable {
/// How the (seed) lot is physically conserved; distinct from storage location.
final PreservationFormat? preservationFormat;
/// Whether (and how) this batch is offered to others: kept private, to give
/// away, to swap, or for sale. Local-only for now publishing an offer to
/// the network is Block 2.
final OfferStatus offerStatus;
final List<GerminationEntry> germinationTests;
/// Storage-condition checks, most-recent first (`conditionChecks.first` = last).
@ -190,6 +241,7 @@ class VarietyLot extends Equatable {
originPlace,
abundance,
preservationFormat,
offerStatus,
germinationTests,
conditionChecks,
];
@ -390,7 +442,9 @@ class VarietyRepository {
final rows =
await (_db.select(_db.varieties)
// Drafts live in the "to catalogue" tray, not the main list.
..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(false))
..where(
(v) => v.isDeleted.equals(false) & v.isDraft.equals(false),
)
..orderBy([
(v) => OrderingTerm(expression: v.category),
(v) => OrderingTerm(expression: v.label),
@ -404,9 +458,9 @@ class VarietyRepository {
/// photo changes.
Stream<List<VarietyListItem>> watchDrafts() {
final triggers = StreamGroup.merge<void>([
(_db.select(_db.varieties)..where((v) => v.isDraft.equals(true)))
.watch()
.map((_) {}),
(_db.select(
_db.varieties,
)..where((v) => v.isDraft.equals(true))).watch().map((_) {}),
_db.select(_db.attachments).watch().map((_) {}),
]);
return triggers.asyncMap((_) => _loadDrafts());
@ -415,9 +469,7 @@ class VarietyRepository {
Future<List<VarietyListItem>> _loadDrafts() async {
final rows =
await (_db.select(_db.varieties)
..where(
(v) => v.isDeleted.equals(false) & v.isDraft.equals(true),
)
..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(true))
// Packed HLC sorts monotonically, so newest capture is first even
// when two are stamped within the same wall-clock millisecond.
..orderBy([(v) => OrderingTerm.desc(v.updatedAt)]))
@ -432,6 +484,7 @@ class VarietyRepository {
final sciNames = await _scientificNamesFor(speciesIds);
final speciesViability = await _speciesViabilityFor(speciesIds);
final lotTypes = await _lotTypesFor(varietyIds);
final shared = await _sharedVarietyIdsFor(varietyIds);
final seedYears = await _seedHarvestYearsFor(varietyIds);
final currentYear = DateTime.fromMillisecondsSinceEpoch(_now()).year;
return rows
@ -446,6 +499,7 @@ class VarietyRepository {
isDraft: v.isDraft,
isOrganic: v.isOrganic,
needsReproduction: v.needsReproduction,
isShared: shared.contains(v.id),
viability: _worstViability(
harvestYears: seedYears[v.id] ?? const [],
viabilityYears: v.speciesId == null
@ -537,6 +591,21 @@ class VarietyRepository {
return byVariety;
}
/// The subset of [varietyIds] holding at least one lot offered to others
/// (to give away, swap or sell) one query.
Future<Set<String>> _sharedVarietyIdsFor(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) &
l.offerStatus.equalsValue(OfferStatus.private).not(),
))
.get();
return rows.map((l) => l.varietyId).toSet();
}
/// Loads the first photo BLOB for each of [varietyIds] (one query).
Future<Map<String, Uint8List>> _firstPhotosFor(
List<String> varietyIds,
@ -942,6 +1011,7 @@ class VarietyRepository {
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private,
}) async {
final (created, updated) = _stamp();
final id = idGen.newId();
@ -966,6 +1036,7 @@ class VarietyRepository {
originPlace: Value(originPlace),
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
offerStatus: Value(offerStatus),
),
);
return id;
@ -985,6 +1056,7 @@ class VarietyRepository {
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private,
}) async {
final (_, updated) = _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
@ -1001,12 +1073,70 @@ class VarietyRepository {
originPlace: Value(originPlace),
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
offerStatus: Value(offerStatus),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Every lot offered to others (to give away, swap or sell), joined with its
/// variety and ordered by label then harvest year the content of the
/// printable "what I share" catalog.
Future<List<SharedCatalogEntry>> sharedCatalog() async {
final lots =
await (_db.select(_db.lots)..where(
(l) =>
l.isDeleted.equals(false) &
l.offerStatus.equalsValue(OfferStatus.private).not(),
))
.get();
if (lots.isEmpty) return const [];
final varietyIds = lots.map((l) => l.varietyId).toSet();
final varieties =
await (_db.select(_db.varieties)..where(
(v) =>
v.id.isIn(varietyIds) &
v.isDeleted.equals(false) &
v.isDraft.equals(false),
))
.get();
final byId = {for (final v in varieties) v.id: v};
final sciNames = await _scientificNamesFor(
varieties.map((v) => v.speciesId).whereType<String>().toSet(),
);
final entries = <SharedCatalogEntry>[
for (final l in lots)
if (byId[l.varietyId] case final v?)
SharedCatalogEntry(
varietyLabel: v.label,
scientificName: v.speciesId == null ? null : sciNames[v.speciesId],
category: v.category,
type: l.type,
harvestYear: l.harvestYear,
quantity:
l.quantityKind != null ||
l.quantityPrecise != null ||
l.quantityLabel != null
? Quantity(
kind: _parseKind(l.quantityKind),
count: l.quantityPrecise,
label: l.quantityLabel,
)
: null,
abundance: l.abundance,
offerStatus: l.offerStatus,
),
];
entries.sort(
(a, b) =>
a.varietyLabel.toLowerCase().compareTo(b.varietyLabel.toLowerCase()),
);
return entries;
}
/// Soft-deletes a lot (tombstone); it leaves the variety's lot list.
Future<void> softDeleteLot(String lotId) async {
final (_, updated) = _stamp();
@ -1487,10 +1617,11 @@ class VarietyRepository {
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();
final local =
await (_db.select(_db.species)..where(
(s) => s.scientificName.equals(name) & s.isDeleted.equals(false),
))
.getSingleOrNull();
return local?.id;
}
@ -1501,12 +1632,11 @@ class VarietyRepository {
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),
))
await (_db.select(_db.species)..where(
(s) =>
s.scientificName.equals(entry.value) &
s.isDeleted.equals(false),
))
.getSingleOrNull();
resolved[entry.key] = local?.id;
}
@ -1529,7 +1659,9 @@ class VarietyRepository {
final existing = await (_db.select(
table,
)..where((_) => idColumn.isIn(byId.keys))).get();
final existingUpdatedAt = {for (final r in existing) idOf(r): updatedAtOf(r)};
final existingUpdatedAt = {
for (final r in existing) idOf(r): updatedAtOf(r),
};
var inserted = 0, updated = 0, skipped = 0;
for (final row in byId.values) {
@ -1548,7 +1680,11 @@ class VarietyRepository {
skipped++;
}
}
return ImportSummary(inserted: inserted, updated: updated, skipped: skipped);
return ImportSummary(
inserted: inserted,
updated: updated,
skipped: skipped,
);
}
/// Movements are append-only: insert unknown ids, never touch known ones.
@ -1598,6 +1734,7 @@ class VarietyRepository {
originPlace: l.originPlace,
abundance: l.abundance,
preservationFormat: l.preservationFormat,
offerStatus: l.offerStatus,
germinationTests: germinationTests,
conditionChecks: conditionChecks,
quantity: hasQuantity