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:
parent
e3ec855630
commit
ba87bf2719
20 changed files with 982 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue