feat(app): show each batch's story as a composite timeline

The Movement log (history + provenance DAG) was recorded but invisible.
A new history sheet on every lot tile narrates the batch: movements,
germination tests, storage checks and how it entered the collection
(origin attached), most-recent first. Two one-tap chips record 'sown
today' / 'harvested today' — the first UI door to the sown/harvested
movement types. No schema change; one-shot reads only (no live
subscriptions in a transient sheet).
This commit is contained in:
vjrj 2026-07-18 06:25:55 +02:00
parent 11cbdf3022
commit 92fd84590b
10 changed files with 835 additions and 2 deletions

View file

@ -326,6 +326,74 @@ class ConditionEntry extends Equatable {
];
}
/// What kind of event a [LotHistoryEntry] narrates.
enum LotHistoryKind { created, movement, germinationTest, conditionCheck }
/// One line of a lot's composite history — the batch's own story, merged from
/// what is already recorded (the lot's creation with its origin, movements,
/// germination tests and condition checks). Read-only; built by
/// [VarietyRepository.lotHistory].
class LotHistoryEntry extends Equatable {
const LotHistoryEntry({
required this.kind,
this.when,
this.movementType,
this.notes,
this.quantity,
this.counterpartyName,
this.originName,
this.originPlace,
this.germinationRate,
this.containerCount,
this.desiccantState,
this.hasParentLink = false,
});
final LotHistoryKind kind;
/// When it happened (ms since epoch): the event's own date when recorded,
/// falling back to the row's `createdAt`. Null only when neither exists.
final int? when;
/// Set for [LotHistoryKind.movement] entries.
final MovementType? movementType;
final String? notes;
final Quantity? quantity;
/// Display name of the movement's counterparty, when one was recorded.
final String? counterpartyName;
/// Provenance carried by the creation entry (who grew/gave it, where from).
final String? originName;
final String? originPlace;
/// Set for [LotHistoryKind.germinationTest] entries (0..1), when computable.
final double? germinationRate;
/// Set for [LotHistoryKind.conditionCheck] entries.
final int? containerCount;
final DesiccantState? desiccantState;
/// True when the movement links back to a source batch (provenance DAG).
final bool hasParentLink;
@override
List<Object?> get props => [
kind,
when,
movementType,
notes,
quantity,
counterpartyName,
originName,
originPlace,
germinationRate,
containerCount,
desiccantState,
hasParentLink,
];
}
/// One held batch of a variety, for the detail view. [germinationTests] and
/// [conditionChecks] are ordered most-recent first, so `.first` is the latest.
class VarietyLot extends Equatable {
@ -1870,6 +1938,130 @@ class VarietyRepository {
return id;
}
/// Records a one-tap cultivation event on a lot the grower sowed from it or
/// harvested into it. Only [MovementType.sown] and [MovementType.harvested]
/// are cultivation events; exchanges go through [recordHandover]. Returns the
/// new movement id. [occurredOn] defaults to now.
Future<String> recordCultivation({
required String lotId,
required MovementType type,
int? occurredOn,
String? notes,
}) async {
if (type != MovementType.sown && type != MovementType.harvested) {
throw ArgumentError.value(
type,
'type',
'only sown/harvested are cultivation events',
);
}
final (created, _) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.movements)
.insert(
MovementsCompanion.insert(
id: id,
createdAt: created,
lastAuthor: nodeId,
lotId: lotId,
type: type,
occurredOn: Value(occurredOn ?? created),
notes: Value(_hasText(notes) ? notes!.trim() : null),
),
);
return id;
}
/// The lot's composite history, most-recent first: every movement,
/// germination test and condition check already recorded, closed by the
/// lot's own creation entry (carrying its origin, so "received from…" shows
/// even when no movement was ever logged). One-shot Future a transient
/// sheet must never hold a live subscription.
Future<List<LotHistoryEntry>> lotHistory(String lotId) async {
final lot = await (_db.select(
_db.lots,
)..where((l) => l.id.equals(lotId))).getSingleOrNull();
if (lot == null) return const [];
final movements = await (_db.select(
_db.movements,
)..where((m) => m.lotId.equals(lotId))).get();
final tests = await (_db.select(
_db.germinationTests,
)..where((g) => g.lotId.equals(lotId) & g.isDeleted.equals(false))).get();
final checks = await (_db.select(
_db.conditionChecks,
)..where((c) => c.lotId.equals(lotId) & c.isDeleted.equals(false))).get();
// Resolve counterparty display names in one go (usually zero or one).
final partyIds = movements
.map((m) => m.counterpartyId)
.whereType<String>()
.toSet();
final partyNames = <String, String>{};
if (partyIds.isNotEmpty) {
final parties = await (_db.select(
_db.parties,
)..where((p) => p.id.isIn(partyIds))).get();
for (final p in parties) {
partyNames[p.id] = p.displayName;
}
}
final entries = <LotHistoryEntry>[
for (final m in movements)
LotHistoryEntry(
kind: LotHistoryKind.movement,
when: m.occurredOn ?? m.createdAt,
movementType: m.type,
notes: m.notes,
quantity: m.quantityKind == null && m.quantityLabel == null
? null
: Quantity(
kind: _parseKind(m.quantityKind),
count: m.quantityPrecise,
label: m.quantityLabel,
),
counterpartyName: m.counterpartyId == null
? null
: partyNames[m.counterpartyId],
hasParentLink: m.parentMovementId != null,
),
for (final g in tests)
LotHistoryEntry(
kind: LotHistoryKind.germinationTest,
when: g.testedOn ?? g.createdAt,
notes: g.notes,
germinationRate: GerminationEntry(
id: g.id,
sampleSize: g.sampleSize,
germinatedCount: g.germinatedCount,
).rate,
),
for (final c in checks)
LotHistoryEntry(
kind: LotHistoryKind.conditionCheck,
when: c.checkedOn ?? c.createdAt,
notes: c.notes,
containerCount: c.containerCount,
desiccantState: c.desiccantState,
),
]..sort((a, b) => (b.when ?? 0).compareTo(a.when ?? 0));
return [
...entries,
// Creation closes the story (oldest), whatever its stamp: it reads as
// "this batch entered your collection", origin attached.
LotHistoryEntry(
kind: LotHistoryKind.created,
when: lot.createdAt,
originName: lot.originName,
originPlace: lot.originPlace,
),
];
}
// --- Plantares: reproduction commitments (data-model §2.7) ----------------
/// Records a Plantare a promise to reproduce seed and return some (or a