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:
parent
11cbdf3022
commit
92fd84590b
10 changed files with 835 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue