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
|
||||
|
|
|
|||
|
|
@ -700,6 +700,25 @@
|
|||
"promiseGave": "They'll return me seed",
|
||||
"promiseReceived": "I'll return seed"
|
||||
},
|
||||
"history": {
|
||||
"title": "Story of this batch",
|
||||
"tooltip": "Story",
|
||||
"sowToday": "Sown today",
|
||||
"harvestToday": "Harvested today",
|
||||
"sownRecorded": "Sowing noted",
|
||||
"harvestRecorded": "Harvest noted",
|
||||
"movementReceived": "Received",
|
||||
"movementGiven": "Given away",
|
||||
"movementSown": "Sown",
|
||||
"movementHarvested": "Harvested",
|
||||
"movementGerminationTest": "Germination test",
|
||||
"movementSplit": "Split into batches",
|
||||
"movementDiscarded": "Discarded",
|
||||
"created": "Added to your collection",
|
||||
"from": "From {origin}",
|
||||
"germinationResult": "Germination test — {percent}%",
|
||||
"linkedEarlier": "Comes from an earlier batch"
|
||||
},
|
||||
"sale": {
|
||||
"title": "Sales",
|
||||
"help": "Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.",
|
||||
|
|
|
|||
|
|
@ -699,6 +699,25 @@
|
|||
"promiseGave": "Me devolverán semilla",
|
||||
"promiseReceived": "Devolveré semilla"
|
||||
},
|
||||
"history": {
|
||||
"title": "Historia de este lote",
|
||||
"tooltip": "Historia",
|
||||
"sowToday": "Sembrado hoy",
|
||||
"harvestToday": "Cosechado hoy",
|
||||
"sownRecorded": "Siembra anotada",
|
||||
"harvestRecorded": "Cosecha anotada",
|
||||
"movementReceived": "Recibido",
|
||||
"movementGiven": "Regalado",
|
||||
"movementSown": "Sembrado",
|
||||
"movementHarvested": "Cosechado",
|
||||
"movementGerminationTest": "Prueba de germinación",
|
||||
"movementSplit": "Repartido en lotes",
|
||||
"movementDiscarded": "Descartado",
|
||||
"created": "Entró en tu colección",
|
||||
"from": "De {origin}",
|
||||
"germinationResult": "Prueba de germinación — {percent}%",
|
||||
"linkedEarlier": "Viene de un lote anterior"
|
||||
},
|
||||
"sale": {
|
||||
"title": "Ventas",
|
||||
"help": "Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 7
|
||||
/// Strings: 3494 (499 per locale)
|
||||
/// Strings: 3528 (504 per locale)
|
||||
///
|
||||
/// Built on 2026-07-17 at 11:10 UTC
|
||||
/// Built on 2026-07-18 at 04:22 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root);
|
||||
late final Translations$plantare$en plantare = Translations$plantare$en.internal(_root);
|
||||
late final Translations$handover$en handover = Translations$handover$en.internal(_root);
|
||||
late final Translations$history$en history = Translations$history$en.internal(_root);
|
||||
late final Translations$sale$en sale = Translations$sale$en.internal(_root);
|
||||
late final Translations$legal$en legal = Translations$legal$en.internal(_root);
|
||||
late final Translations$marketGate$en marketGate = Translations$marketGate$en.internal(_root);
|
||||
|
|
@ -1980,6 +1981,66 @@ class Translations$handover$en {
|
|||
String get promiseReceived => 'I\'ll return seed';
|
||||
}
|
||||
|
||||
// Path: history
|
||||
class Translations$history$en {
|
||||
Translations$history$en.internal(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Story of this batch'
|
||||
String get title => 'Story of this batch';
|
||||
|
||||
/// en: 'Story'
|
||||
String get tooltip => 'Story';
|
||||
|
||||
/// en: 'Sown today'
|
||||
String get sowToday => 'Sown today';
|
||||
|
||||
/// en: 'Harvested today'
|
||||
String get harvestToday => 'Harvested today';
|
||||
|
||||
/// en: 'Sowing noted'
|
||||
String get sownRecorded => 'Sowing noted';
|
||||
|
||||
/// en: 'Harvest noted'
|
||||
String get harvestRecorded => 'Harvest noted';
|
||||
|
||||
/// en: 'Received'
|
||||
String get movementReceived => 'Received';
|
||||
|
||||
/// en: 'Given away'
|
||||
String get movementGiven => 'Given away';
|
||||
|
||||
/// en: 'Sown'
|
||||
String get movementSown => 'Sown';
|
||||
|
||||
/// en: 'Harvested'
|
||||
String get movementHarvested => 'Harvested';
|
||||
|
||||
/// en: 'Germination test'
|
||||
String get movementGerminationTest => 'Germination test';
|
||||
|
||||
/// en: 'Split into batches'
|
||||
String get movementSplit => 'Split into batches';
|
||||
|
||||
/// en: 'Discarded'
|
||||
String get movementDiscarded => 'Discarded';
|
||||
|
||||
/// en: 'Added to your collection'
|
||||
String get created => 'Added to your collection';
|
||||
|
||||
/// en: 'From {origin}'
|
||||
String from({required Object origin}) => 'From ${origin}';
|
||||
|
||||
/// en: 'Germination test — {percent}%'
|
||||
String germinationResult({required Object percent}) => 'Germination test — ${percent}%';
|
||||
|
||||
/// en: 'Comes from an earlier batch'
|
||||
String get linkedEarlier => 'Comes from an earlier batch';
|
||||
}
|
||||
|
||||
// Path: sale
|
||||
class Translations$sale$en {
|
||||
Translations$sale$en.internal(this._root);
|
||||
|
|
@ -3199,6 +3260,23 @@ extension on Translations {
|
|||
'handover.paymentChip' => 'Money changed hands',
|
||||
'handover.promiseGave' => 'They\'ll return me seed',
|
||||
'handover.promiseReceived' => 'I\'ll return seed',
|
||||
'history.title' => 'Story of this batch',
|
||||
'history.tooltip' => 'Story',
|
||||
'history.sowToday' => 'Sown today',
|
||||
'history.harvestToday' => 'Harvested today',
|
||||
'history.sownRecorded' => 'Sowing noted',
|
||||
'history.harvestRecorded' => 'Harvest noted',
|
||||
'history.movementReceived' => 'Received',
|
||||
'history.movementGiven' => 'Given away',
|
||||
'history.movementSown' => 'Sown',
|
||||
'history.movementHarvested' => 'Harvested',
|
||||
'history.movementGerminationTest' => 'Germination test',
|
||||
'history.movementSplit' => 'Split into batches',
|
||||
'history.movementDiscarded' => 'Discarded',
|
||||
'history.created' => 'Added to your collection',
|
||||
'history.from' => ({required Object origin}) => 'From ${origin}',
|
||||
'history.germinationResult' => ({required Object percent}) => 'Germination test — ${percent}%',
|
||||
'history.linkedEarlier' => 'Comes from an earlier batch',
|
||||
'sale.title' => 'Sales',
|
||||
'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.',
|
||||
'sale.add' => 'Record a sale',
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@override late final _Translations$notifications$es notifications = _Translations$notifications$es._(_root);
|
||||
@override late final _Translations$plantare$es plantare = _Translations$plantare$es._(_root);
|
||||
@override late final _Translations$handover$es handover = _Translations$handover$es._(_root);
|
||||
@override late final _Translations$history$es history = _Translations$history$es._(_root);
|
||||
@override late final _Translations$sale$es sale = _Translations$sale$es._(_root);
|
||||
@override late final _Translations$legal$es legal = _Translations$legal$es._(_root);
|
||||
@override late final _Translations$marketGate$es marketGate = _Translations$marketGate$es._(_root);
|
||||
|
|
@ -1021,6 +1022,32 @@ class _Translations$handover$es extends Translations$handover$en {
|
|||
@override String get promiseReceived => 'Devolveré semilla';
|
||||
}
|
||||
|
||||
// Path: history
|
||||
class _Translations$history$es extends Translations$history$en {
|
||||
_Translations$history$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsEs _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Historia de este lote';
|
||||
@override String get tooltip => 'Historia';
|
||||
@override String get sowToday => 'Sembrado hoy';
|
||||
@override String get harvestToday => 'Cosechado hoy';
|
||||
@override String get sownRecorded => 'Siembra anotada';
|
||||
@override String get harvestRecorded => 'Cosecha anotada';
|
||||
@override String get movementReceived => 'Recibido';
|
||||
@override String get movementGiven => 'Regalado';
|
||||
@override String get movementSown => 'Sembrado';
|
||||
@override String get movementHarvested => 'Cosechado';
|
||||
@override String get movementGerminationTest => 'Prueba de germinación';
|
||||
@override String get movementSplit => 'Repartido en lotes';
|
||||
@override String get movementDiscarded => 'Descartado';
|
||||
@override String get created => 'Entró en tu colección';
|
||||
@override String from({required Object origin}) => 'De ${origin}';
|
||||
@override String germinationResult({required Object percent}) => 'Prueba de germinación — ${percent}%';
|
||||
@override String get linkedEarlier => 'Viene de un lote anterior';
|
||||
}
|
||||
|
||||
// Path: sale
|
||||
class _Translations$sale$es extends Translations$sale$en {
|
||||
_Translations$sale$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
|
@ -2011,6 +2038,23 @@ extension on TranslationsEs {
|
|||
'handover.paymentChip' => 'Hubo dinero por medio',
|
||||
'handover.promiseGave' => 'Me devolverán semilla',
|
||||
'handover.promiseReceived' => 'Devolveré semilla',
|
||||
'history.title' => 'Historia de este lote',
|
||||
'history.tooltip' => 'Historia',
|
||||
'history.sowToday' => 'Sembrado hoy',
|
||||
'history.harvestToday' => 'Cosechado hoy',
|
||||
'history.sownRecorded' => 'Siembra anotada',
|
||||
'history.harvestRecorded' => 'Cosecha anotada',
|
||||
'history.movementReceived' => 'Recibido',
|
||||
'history.movementGiven' => 'Regalado',
|
||||
'history.movementSown' => 'Sembrado',
|
||||
'history.movementHarvested' => 'Cosechado',
|
||||
'history.movementGerminationTest' => 'Prueba de germinación',
|
||||
'history.movementSplit' => 'Repartido en lotes',
|
||||
'history.movementDiscarded' => 'Descartado',
|
||||
'history.created' => 'Entró en tu colección',
|
||||
'history.from' => ({required Object origin}) => 'De ${origin}',
|
||||
'history.germinationResult' => ({required Object percent}) => 'Prueba de germinación — ${percent}%',
|
||||
'history.linkedEarlier' => 'Viene de un lote anterior',
|
||||
'sale.title' => 'Ventas',
|
||||
'sale.help' => 'Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.',
|
||||
'sale.add' => 'Registrar venta',
|
||||
|
|
|
|||
214
apps/app_seeds/lib/ui/lot_history_sheet.dart
Normal file
214
apps/app_seeds/lib/ui/lot_history_sheet.dart
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../data/variety_repository.dart';
|
||||
import '../db/enums.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'quantity_kind_l10n.dart';
|
||||
|
||||
/// Opens the batch's story — a read-only timeline of everything already
|
||||
/// recorded about it (movements, germination tests, storage checks, and how it
|
||||
/// entered the collection), plus two one-tap chips to note "sown today" /
|
||||
/// "harvested today". The happy path is literally one tap; there is no form.
|
||||
Future<void> showLotHistorySheet(
|
||||
BuildContext context, {
|
||||
required VarietyRepository repository,
|
||||
required VarietyLot lot,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _LotHistorySheet(repository: repository, lot: lot),
|
||||
);
|
||||
}
|
||||
|
||||
class _LotHistorySheet extends StatefulWidget {
|
||||
const _LotHistorySheet({required this.repository, required this.lot});
|
||||
|
||||
final VarietyRepository repository;
|
||||
final VarietyLot lot;
|
||||
|
||||
@override
|
||||
State<_LotHistorySheet> createState() => _LotHistorySheetState();
|
||||
}
|
||||
|
||||
class _LotHistorySheetState extends State<_LotHistorySheet> {
|
||||
// One-shot future (data-model rule: a transient sheet must never hold a
|
||||
// live subscription). Re-assigned after a quick record to refresh the list.
|
||||
late Future<List<LotHistoryEntry>> _history;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_history = widget.repository.lotHistory(widget.lot.id);
|
||||
}
|
||||
|
||||
Future<void> _record(MovementType type) async {
|
||||
if (_saving) return;
|
||||
setState(() => _saving = true);
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await widget.repository.recordCultivation(
|
||||
lotId: widget.lot.id,
|
||||
type: type,
|
||||
);
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
type == MovementType.sown
|
||||
? t.history.sownRecorded
|
||||
: t.history.harvestRecorded,
|
||||
),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_history = widget.repository.lotHistory(widget.lot.id);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.history.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (widget.lot.type == LotType.seed)
|
||||
ActionChip(
|
||||
key: const Key('history.sowToday'),
|
||||
avatar: const Icon(Icons.grass_outlined, size: 18),
|
||||
label: Text(t.history.sowToday),
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () => _record(MovementType.sown),
|
||||
),
|
||||
ActionChip(
|
||||
key: const Key('history.harvestToday'),
|
||||
avatar: const Icon(Icons.agriculture_outlined, size: 18),
|
||||
label: Text(t.history.harvestToday),
|
||||
onPressed: _saving
|
||||
? null
|
||||
: () => _record(MovementType.harvested),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: FutureBuilder<List<LotHistoryEntry>>(
|
||||
future: _history,
|
||||
builder: (context, snapshot) {
|
||||
final entries = snapshot.data;
|
||||
if (entries == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: entries.length,
|
||||
itemBuilder: (context, i) =>
|
||||
_HistoryTile(entry: entries[i]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryTile extends StatelessWidget {
|
||||
const _HistoryTile({required this.entry});
|
||||
|
||||
final LotHistoryEntry entry;
|
||||
|
||||
IconData get _icon => switch (entry.kind) {
|
||||
LotHistoryKind.created => Icons.spa_outlined,
|
||||
LotHistoryKind.germinationTest => Icons.science_outlined,
|
||||
LotHistoryKind.conditionCheck => Icons.inventory_2_outlined,
|
||||
LotHistoryKind.movement => switch (entry.movementType!) {
|
||||
MovementType.received => Icons.call_received,
|
||||
MovementType.given => Icons.redeem_outlined,
|
||||
MovementType.sown => Icons.grass_outlined,
|
||||
MovementType.harvested => Icons.agriculture_outlined,
|
||||
MovementType.germinationTest => Icons.science_outlined,
|
||||
MovementType.split => Icons.call_split,
|
||||
MovementType.discarded => Icons.delete_outline,
|
||||
},
|
||||
};
|
||||
|
||||
String _title(Translations t) => switch (entry.kind) {
|
||||
LotHistoryKind.created => t.history.created,
|
||||
LotHistoryKind.conditionCheck => t.conditionCheck.title,
|
||||
LotHistoryKind.germinationTest => entry.germinationRate == null
|
||||
? t.germination.title
|
||||
: t.history.germinationResult(
|
||||
percent: (entry.germinationRate! * 100).round(),
|
||||
),
|
||||
LotHistoryKind.movement => switch (entry.movementType!) {
|
||||
MovementType.received => t.history.movementReceived,
|
||||
MovementType.given => t.history.movementGiven,
|
||||
MovementType.sown => t.history.movementSown,
|
||||
MovementType.harvested => t.history.movementHarvested,
|
||||
MovementType.germinationTest => t.history.movementGerminationTest,
|
||||
MovementType.split => t.history.movementSplit,
|
||||
MovementType.discarded => t.history.movementDiscarded,
|
||||
},
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final theme = Theme.of(context);
|
||||
final when = entry.when;
|
||||
final date = when == null
|
||||
? null
|
||||
// Format via the Localizations locale, not the raw app locale (see
|
||||
// backup_section.dart: `ast` has no intl symbols, mapped to `es`).
|
||||
: DateFormat.yMMMd(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
).format(DateTime.fromMillisecondsSinceEpoch(when));
|
||||
|
||||
final origin = [
|
||||
entry.originName,
|
||||
entry.originPlace,
|
||||
].whereType<String>().where((s) => s.trim().isNotEmpty).join(' · ');
|
||||
|
||||
final details = [
|
||||
if (entry.kind == LotHistoryKind.created && origin.isNotEmpty)
|
||||
t.history.from(origin: origin),
|
||||
if (entry.quantity != null) quantityDisplay(t, entry.quantity!),
|
||||
?entry.counterpartyName,
|
||||
if (entry.hasParentLink) t.history.linkedEarlier,
|
||||
if (entry.notes case final n? when n.trim().isNotEmpty) n.trim(),
|
||||
].join('\n');
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(_icon, size: 20, color: theme.colorScheme.primary),
|
||||
title: Text(_title(t)),
|
||||
subtitle: details.isEmpty ? null : Text(details),
|
||||
trailing: date == null
|
||||
? null
|
||||
: Text(date, style: theme.textTheme.bodySmall),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import '../i18n/strings.g.dart';
|
|||
import '../state/variety_detail_cubit.dart';
|
||||
import 'currency_quick_picks.dart';
|
||||
import 'handover_sheet.dart';
|
||||
import 'lot_history_sheet.dart';
|
||||
import 'harvest_date_picker.dart';
|
||||
import 'photo_pick.dart';
|
||||
import 'quantity_kind_l10n.dart';
|
||||
|
|
@ -434,6 +435,16 @@ class _LotTile extends StatelessWidget {
|
|||
children: [
|
||||
if (lot.type == LotType.seed && lot.latestGerminationRate != null)
|
||||
_GerminationBadge(rate: lot.latestGerminationRate!),
|
||||
IconButton(
|
||||
key: Key('lot.history.${lot.id}'),
|
||||
icon: const Icon(Icons.history),
|
||||
tooltip: t.history.tooltip,
|
||||
onPressed: () => showLotHistorySheet(
|
||||
context,
|
||||
repository: context.read<VarietyRepository>(),
|
||||
lot: lot,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: Key('lot.handover.${lot.id}'),
|
||||
icon: const Icon(Icons.handshake_outlined),
|
||||
|
|
|
|||
153
apps/app_seeds/test/services/lot_history_test.dart
Normal file
153
apps/app_seeds/test/services/lot_history_test.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/data/variety_repository.dart';
|
||||
import 'package:tane/db/database.dart';
|
||||
import 'package:tane/db/enums.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late VarietyRepository repo;
|
||||
|
||||
setUp(() {
|
||||
db = newTestDatabase();
|
||||
repo = newTestRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() async => db.close());
|
||||
|
||||
Future<String> newLot({String? originName, String? originPlace}) async {
|
||||
final varietyId = await repo.addQuickVariety(label: 'Tomate rosa');
|
||||
return repo.addLot(
|
||||
varietyId: varietyId,
|
||||
harvestYear: 2025,
|
||||
originName: originName,
|
||||
originPlace: originPlace,
|
||||
);
|
||||
}
|
||||
|
||||
group('recordCultivation', () {
|
||||
test('records a sown movement with defaults', () async {
|
||||
final lotId = await newLot();
|
||||
final id = await repo.recordCultivation(
|
||||
lotId: lotId,
|
||||
type: MovementType.sown,
|
||||
);
|
||||
expect(id, isNotEmpty);
|
||||
final history = await repo.lotHistory(lotId);
|
||||
final sown = history.where(
|
||||
(e) => e.movementType == MovementType.sown,
|
||||
);
|
||||
expect(sown, hasLength(1));
|
||||
expect(sown.first.kind, LotHistoryKind.movement);
|
||||
expect(sown.first.when, isNotNull);
|
||||
});
|
||||
|
||||
test('records a harvested movement with a note and date', () async {
|
||||
final lotId = await newLot();
|
||||
await repo.recordCultivation(
|
||||
lotId: lotId,
|
||||
type: MovementType.harvested,
|
||||
occurredOn: DateTime(2026, 7, 1).millisecondsSinceEpoch,
|
||||
notes: 'great year',
|
||||
);
|
||||
final history = await repo.lotHistory(lotId);
|
||||
final harvested = history.singleWhere(
|
||||
(e) => e.movementType == MovementType.harvested,
|
||||
);
|
||||
expect(harvested.notes, 'great year');
|
||||
expect(
|
||||
harvested.when,
|
||||
DateTime(2026, 7, 1).millisecondsSinceEpoch,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-cultivation movement types', () async {
|
||||
final lotId = await newLot();
|
||||
expect(
|
||||
() => repo.recordCultivation(lotId: lotId, type: MovementType.given),
|
||||
throwsA(isA<ArgumentError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('lotHistory', () {
|
||||
test('starts with the lot creation entry carrying its origin', () async {
|
||||
final lotId = await newLot(originName: 'María', originPlace: 'Aiako');
|
||||
final history = await repo.lotHistory(lotId);
|
||||
expect(history, isNotEmpty);
|
||||
// Most-recent first: creation is the last entry.
|
||||
final created = history.last;
|
||||
expect(created.kind, LotHistoryKind.created);
|
||||
expect(created.originName, 'María');
|
||||
expect(created.originPlace, 'Aiako');
|
||||
});
|
||||
|
||||
test('merges movements, germination tests and condition checks', () async {
|
||||
final lotId = await newLot();
|
||||
await repo.addGerminationTest(
|
||||
lotId: lotId,
|
||||
testedOn: DateTime(2026, 2, 1).millisecondsSinceEpoch,
|
||||
sampleSize: 10,
|
||||
germinatedCount: 8,
|
||||
);
|
||||
await repo.addConditionCheck(
|
||||
lotId: lotId,
|
||||
checkedOn: DateTime(2026, 3, 1).millisecondsSinceEpoch,
|
||||
containerCount: 2,
|
||||
desiccantState: DesiccantState.dry,
|
||||
);
|
||||
await repo.recordCultivation(
|
||||
lotId: lotId,
|
||||
type: MovementType.sown,
|
||||
occurredOn: DateTime(2026, 4, 1).millisecondsSinceEpoch,
|
||||
);
|
||||
await repo.recordHandover(
|
||||
lotId: lotId,
|
||||
direction: HandoverDirection.iGave,
|
||||
counterparty: 'Pepe',
|
||||
);
|
||||
|
||||
final history = await repo.lotHistory(lotId);
|
||||
expect(history.map((e) => e.kind).toSet(), {
|
||||
LotHistoryKind.created,
|
||||
LotHistoryKind.movement,
|
||||
LotHistoryKind.germinationTest,
|
||||
LotHistoryKind.conditionCheck,
|
||||
});
|
||||
|
||||
final germination = history.singleWhere(
|
||||
(e) => e.kind == LotHistoryKind.germinationTest,
|
||||
);
|
||||
expect(germination.germinationRate, closeTo(0.8, 1e-9));
|
||||
|
||||
final check = history.singleWhere(
|
||||
(e) => e.kind == LotHistoryKind.conditionCheck,
|
||||
);
|
||||
expect(check.containerCount, 2);
|
||||
|
||||
// Most-recent first: the handover (today) leads, creation closes.
|
||||
expect(history.first.movementType, MovementType.given);
|
||||
expect(history.last.kind, LotHistoryKind.created);
|
||||
|
||||
// The middle entries are ordered by their explicit dates, descending.
|
||||
final dated = history
|
||||
.where((e) => e.kind != LotHistoryKind.created)
|
||||
.toList();
|
||||
for (var i = 0; i + 1 < dated.length; i++) {
|
||||
expect(dated[i].when ?? 0, greaterThanOrEqualTo(dated[i + 1].when ?? 0));
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores other lots', () async {
|
||||
final lotId = await newLot();
|
||||
final otherLot = await newLot();
|
||||
await repo.recordCultivation(lotId: otherLot, type: MovementType.sown);
|
||||
final history = await repo.lotHistory(lotId);
|
||||
expect(
|
||||
history.where((e) => e.kind == LotHistoryKind.movement),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
103
apps/app_seeds/test/ui/lot_history_sheet_test.dart
Normal file
103
apps/app_seeds/test/ui/lot_history_sheet_test.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/db/database.dart';
|
||||
import 'package:tane/db/enums.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
|
||||
setUp(() => db = newTestDatabase());
|
||||
tearDown(() => db.close());
|
||||
|
||||
testWidgets('the lot tile opens the batch story, closed by its origin', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = newTestRepository(db);
|
||||
final id = await repo.addQuickVariety(label: 'Maize');
|
||||
final lotId = await repo.addLot(
|
||||
varietyId: id,
|
||||
originName: 'María',
|
||||
originPlace: 'Aiako',
|
||||
);
|
||||
await repo.addGerminationTest(
|
||||
lotId: lotId,
|
||||
sampleSize: 10,
|
||||
germinatedCount: 9,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapDetail(
|
||||
repository: repo,
|
||||
varietyId: id,
|
||||
species: newTestSpeciesRepository(db),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(Key('lot.history.$lotId')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The story: germination test (90%) first, creation-with-origin last.
|
||||
expect(find.text('Germination test — 90%'), findsOneWidget);
|
||||
expect(find.text('Added to your collection'), findsOneWidget);
|
||||
expect(find.text('From María · Aiako'), findsOneWidget);
|
||||
await disposeTree(tester);
|
||||
});
|
||||
|
||||
testWidgets('one tap records sown today and it shows up in the story', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = newTestRepository(db);
|
||||
final id = await repo.addQuickVariety(label: 'Maize');
|
||||
final lotId = await repo.addLot(varietyId: id);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapDetail(
|
||||
repository: repo,
|
||||
varietyId: id,
|
||||
species: newTestSpeciesRepository(db),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(Key('lot.history.$lotId')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('history.sowToday')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// One-shot .get() (NOT watch().first — hangs under the fake-async clock).
|
||||
final movements = await db.select(db.movements).get();
|
||||
expect(movements.single.type, MovementType.sown);
|
||||
expect(movements.single.lotId, lotId);
|
||||
expect(movements.single.occurredOn, isNotNull);
|
||||
|
||||
// And the story refreshed to show it.
|
||||
expect(find.text('Sown'), findsOneWidget);
|
||||
await disposeTree(tester);
|
||||
});
|
||||
|
||||
testWidgets('a plant lot offers harvest but not sowing', (tester) async {
|
||||
final repo = newTestRepository(db);
|
||||
final id = await repo.addQuickVariety(label: 'Rosemary');
|
||||
final lotId = await repo.addLot(varietyId: id, type: LotType.plant);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapDetail(
|
||||
repository: repo,
|
||||
varietyId: id,
|
||||
species: newTestSpeciesRepository(db),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(Key('lot.history.$lotId')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('history.sowToday')), findsNothing);
|
||||
expect(find.byKey(const Key('history.harvestToday')), findsOneWidget);
|
||||
await disposeTree(tester);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue