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

View file

@ -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.",

View file

@ -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.",

View file

@ -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

View file

@ -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',

View file

@ -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',

View 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),
);
}
}

View file

@ -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),

View 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,
);
});
});
}

View 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);
});
}