From 92fd84590be9e94abd11aadfc7955f5cc7926e75 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 18 Jul 2026 06:25:55 +0200 Subject: [PATCH] feat(app): show each batch's story as a composite timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../lib/data/variety_repository.dart | 192 ++++++++++++++++ apps/app_seeds/lib/i18n/en.i18n.json | 19 ++ apps/app_seeds/lib/i18n/es.i18n.json | 19 ++ apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 78 +++++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 44 ++++ apps/app_seeds/lib/ui/lot_history_sheet.dart | 214 ++++++++++++++++++ .../lib/ui/variety_detail_screen.dart | 11 + .../test/services/lot_history_test.dart | 153 +++++++++++++ .../test/ui/lot_history_sheet_test.dart | 103 +++++++++ 10 files changed, 835 insertions(+), 2 deletions(-) create mode 100644 apps/app_seeds/lib/ui/lot_history_sheet.dart create mode 100644 apps/app_seeds/test/services/lot_history_test.dart create mode 100644 apps/app_seeds/test/ui/lot_history_sheet_test.dart diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index dfb9a4f..d10af25 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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 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 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> 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() + .toSet(); + final partyNames = {}; + 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 = [ + 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 diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 7b047f8..8a439d3 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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.", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index c491536..840ba26 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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.", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index b62d4ec..6dbf028 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -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 diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 5fb786c..42c5c29 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -86,6 +86,7 @@ class Translations with BaseTranslations { 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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index f0ecbc0..1d1c138 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -85,6 +85,7 @@ class TranslationsEs extends Translations with BaseTranslations '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', diff --git a/apps/app_seeds/lib/ui/lot_history_sheet.dart b/apps/app_seeds/lib/ui/lot_history_sheet.dart new file mode 100644 index 0000000..254ef29 --- /dev/null +++ b/apps/app_seeds/lib/ui/lot_history_sheet.dart @@ -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 showLotHistorySheet( + BuildContext context, { + required VarietyRepository repository, + required VarietyLot lot, +}) { + return showModalBottomSheet( + 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> _history; + bool _saving = false; + + @override + void initState() { + super.initState(); + _history = widget.repository.lotHistory(widget.lot.id); + } + + Future _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>( + 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().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), + ); + } +} diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 6eeeead..d2e22ea 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -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(), + lot: lot, + ), + ), IconButton( key: Key('lot.handover.${lot.id}'), icon: const Icon(Icons.handshake_outlined), diff --git a/apps/app_seeds/test/services/lot_history_test.dart b/apps/app_seeds/test/services/lot_history_test.dart new file mode 100644 index 0000000..4709f01 --- /dev/null +++ b/apps/app_seeds/test/services/lot_history_test.dart @@ -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 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()), + ); + }); + }); + + 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, + ); + }); + }); +} diff --git a/apps/app_seeds/test/ui/lot_history_sheet_test.dart b/apps/app_seeds/test/ui/lot_history_sheet_test.dart new file mode 100644 index 0000000..68b7790 --- /dev/null +++ b/apps/app_seeds/test/ui/lot_history_sheet_test.dart @@ -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); + }); +}