diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 6c46bb1..5dd0816 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -737,7 +737,8 @@ "ratedMixed": "It did so-so", "ratedPoor": "It did poorly", "outcomeTitle": "Season note", - "outcomeYear": "Season {year}" + "outcomeYear": "Season {year}", + "isolationHint": "This species crosses with its neighbours — grow it about {meters} m away from others to keep it true" }, "sale": { "title": "Sales", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 8fcf97a..9516b8b 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -736,7 +736,8 @@ "ratedMixed": "Se dio regular", "ratedPoor": "Se dio mal", "outcomeTitle": "Nota de temporada", - "outcomeYear": "Temporada {year}" + "outcomeYear": "Temporada {year}", + "isolationHint": "Esta especie se cruza con sus vecinas — cultívala a unos {meters} m de otras para que se mantenga fiel" }, "sale": { "title": "Ventas", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 329f2c2..23374a5 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: 3564 (509 per locale) +/// Strings: 3566 (509 per locale) /// -/// Built on 2026-07-18 at 10:23 UTC +/// Built on 2026-07-18 at 10:28 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 671fddc..3f90a13 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -2103,6 +2103,9 @@ class Translations$history$en { /// en: 'Season {year}' String outcomeYear({required Object year}) => 'Season ${year}'; + + /// en: 'This species crosses with its neighbours — grow it about {meters} m away from others to keep it true' + String isolationHint({required Object meters}) => 'This species crosses with its neighbours — grow it about ${meters} m away from others to keep it true'; } // Path: sale @@ -3359,6 +3362,7 @@ extension on Translations { 'history.ratedPoor' => 'It did poorly', 'history.outcomeTitle' => 'Season note', 'history.outcomeYear' => ({required Object year}) => 'Season ${year}', + 'history.isolationHint' => ({required Object meters}) => 'This species crosses with its neighbours — grow it about ${meters} m away from others to keep it true', '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 c8a2f9e..752c4f5 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -1074,6 +1074,7 @@ class _Translations$history$es extends Translations$history$en { @override String get ratedPoor => 'Se dio mal'; @override String get outcomeTitle => 'Nota de temporada'; @override String outcomeYear({required Object year}) => 'Temporada ${year}'; + @override String isolationHint({required Object meters}) => 'Esta especie se cruza con sus vecinas — cultívala a unos ${meters} m de otras para que se mantenga fiel'; } // Path: sale @@ -2101,6 +2102,7 @@ extension on TranslationsEs { 'history.ratedPoor' => 'Se dio mal', 'history.outcomeTitle' => 'Nota de temporada', 'history.outcomeYear' => ({required Object year}) => 'Temporada ${year}', + 'history.isolationHint' => ({required Object meters}) => 'Esta especie se cruza con sus vecinas — cultívala a unos ${meters} m de otras para que se mantenga fiel', '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 index 8a631c8..a1b390a 100644 --- a/apps/app_seeds/lib/ui/lot_history_sheet.dart +++ b/apps/app_seeds/lib/ui/lot_history_sheet.dart @@ -3,6 +3,7 @@ import 'package:intl/intl.dart'; import '../data/variety_repository.dart'; import '../db/enums.dart'; +import '../domain/seed_saving.dart'; import '../i18n/strings.g.dart'; import 'quantity_kind_l10n.dart'; @@ -14,20 +15,31 @@ Future showLotHistorySheet( BuildContext context, { required VarietyRepository repository, required VarietyLot lot, + SeedSavingGuide? guide, }) { return showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => _LotHistorySheet(repository: repository, lot: lot), + builder: (_) => + _LotHistorySheet(repository: repository, lot: lot, guide: guide), ); } class _LotHistorySheet extends StatefulWidget { - const _LotHistorySheet({required this.repository, required this.lot}); + const _LotHistorySheet({ + required this.repository, + required this.lot, + this.guide, + }); final VarietyRepository repository; final VarietyLot lot; + /// Bundled species guidance, or null. Only read to drop the one-line + /// isolation hint when a cross-pollinating crop is sown — selfers get + /// nothing (the fields people record religiously where they don't matter). + final SeedSavingGuide? guide; + @override State<_LotHistorySheet> createState() => _LotHistorySheetState(); } @@ -43,6 +55,10 @@ class _LotHistorySheetState extends State<_LotHistorySheet> { bool _askOutcome = false; final _outcomeNote = TextEditingController(); + // One passive line after sowing a crosser ("this species crosses — isolate + // ~400 m"). Never shown for selfers, never an input. + bool _showSowingHint = false; + @override void initState() { super.initState(); @@ -74,9 +90,14 @@ class _LotHistorySheetState extends State<_LotHistorySheet> { ), ), ); + final guide = widget.guide; setState(() { _saving = false; _askOutcome = type == MovementType.harvested; + _showSowingHint = + type == MovementType.sown && + guide?.pollination == Pollination.cross && + guide?.isolationMinM != null; _history = widget.repository.lotHistory(widget.lot.id); }); } @@ -146,6 +167,25 @@ class _LotHistorySheetState extends State<_LotHistorySheet> { ), ], ), + if (_showSowingHint) + Padding( + key: const Key('history.isolationHint'), + padding: const EdgeInsetsDirectional.only(top: 8, start: 4), + child: Row( + children: [ + const Icon(Icons.lightbulb_outline, size: 16), + const SizedBox(width: 6), + Expanded( + child: Text( + context.t.history.isolationHint( + meters: widget.guide!.isolationMinM!, + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), if (_askOutcome) ...[ const SizedBox(height: 8), _OutcomeQuestion( diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index d2e22ea..740713b 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -237,6 +237,10 @@ class _DetailView extends StatelessWidget { cubit: cubit, lot: lot, viabilityYears: detail.viabilityYears, + guide: SeedSavingCatalog.guideFor( + scientificName: detail.scientificName, + family: detail.family ?? detail.category, + ), ), _PlantaresSection(varietyId: detail.id), ], @@ -375,6 +379,7 @@ class _LotTile extends StatelessWidget { required this.cubit, required this.lot, required this.viabilityYears, + this.guide, }); final VarietyDetailCubit cubit; @@ -383,6 +388,10 @@ class _LotTile extends StatelessWidget { /// Typical seed longevity of the variety's species (years), or null. final int? viabilityYears; + /// Bundled seed-saving guidance for the species, or null — lets the history + /// sheet drop a one-line isolation hint when a crosser is sown. + final SeedSavingGuide? guide; + @override Widget build(BuildContext context) { final t = context.t; @@ -443,6 +452,7 @@ class _LotTile extends StatelessWidget { context, repository: context.read(), lot: lot, + guide: guide, ), ), IconButton( diff --git a/apps/app_seeds/test/ui/lot_history_sheet_test.dart b/apps/app_seeds/test/ui/lot_history_sheet_test.dart index 467b354..be8ce0e 100644 --- a/apps/app_seeds/test/ui/lot_history_sheet_test.dart +++ b/apps/app_seeds/test/ui/lot_history_sheet_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/seed_saving_catalog.dart'; import 'package:tane/db/database.dart'; import 'package:tane/db/enums.dart'; +import 'package:tane/domain/seed_saving.dart'; import '../support/test_support.dart'; @@ -9,7 +11,10 @@ void main() { late AppDatabase db; setUp(() => db = newTestDatabase()); - tearDown(() => db.close()); + tearDown(() { + SeedSavingCatalog.data = null; + return db.close(); + }); testWidgets('the lot tile opens the batch story, closed by its origin', ( tester, @@ -153,6 +158,52 @@ void main() { await disposeTree(tester); }); + testWidgets('sowing a crosser drops the isolation hint; a selfer gets ' + 'nothing', (tester) async { + SeedSavingCatalog.data = SeedSavingData( + families: { + 'Cucurbitaceae': const SeedSavingGuide( + pollination: Pollination.cross, + isolationMinM: 400, + ), + 'Solanaceae': const SeedSavingGuide( + pollination: Pollination.self, + isolationMinM: 3, + ), + }, + taxa: const {}, + ); + final repo = newTestRepository(db); + + Future sowAndCheck(String category, {required bool hinted}) async { + final id = await repo.addQuickVariety(label: 'x-$category'); + await repo.updateVariety(id: id, label: 'x-$category', category: category); + 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(); + expect( + find.byKey(const Key('history.isolationHint')), + hinted ? findsOneWidget : findsNothing, + ); + await disposeTree(tester); + } + + // The family drives it: squash (crosses, 400 m) hints; tomato does not. + await sowAndCheck('Cucurbitaceae', hinted: true); + expect(find.textContaining('400'), findsNothing); // tree disposed + await sowAndCheck('Solanaceae', hinted: false); + }); + testWidgets('a plant lot offers harvest but not sowing', (tester) async { final repo = newTestRepository(db); final id = await repo.addQuickVariety(label: 'Rosemary');