feat(app): hint isolation distance when sowing a cross-pollinating crop
Some checks failed
ci / test-commons-core (push) Successful in 58s
ci / analyze (push) Successful in 1m49s
site / deploy (push) Successful in 38s
ci / test-app-seeds (push) Has been cancelled

One passive line in the batch story after a 'sown today' tap — 'this
species crosses, grow it ~400 m from others' — sourced from the bundled
seed-saving guidance (family default + species override). Selfers get
nothing: the trap is recording isolation religiously for beans where it
never mattered. No input fields, no schema change.
This commit is contained in:
vjrj 2026-07-18 12:31:42 +02:00
parent f1aa8f8e66
commit 1a826477f2
8 changed files with 116 additions and 7 deletions

View file

@ -737,7 +737,8 @@
"ratedMixed": "It did so-so", "ratedMixed": "It did so-so",
"ratedPoor": "It did poorly", "ratedPoor": "It did poorly",
"outcomeTitle": "Season note", "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": { "sale": {
"title": "Sales", "title": "Sales",

View file

@ -736,7 +736,8 @@
"ratedMixed": "Se dio regular", "ratedMixed": "Se dio regular",
"ratedPoor": "Se dio mal", "ratedPoor": "Se dio mal",
"outcomeTitle": "Nota de temporada", "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": { "sale": {
"title": "Ventas", "title": "Ventas",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 7 /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -2103,6 +2103,9 @@ class Translations$history$en {
/// en: 'Season {year}' /// en: 'Season {year}'
String outcomeYear({required Object year}) => '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 // Path: sale
@ -3359,6 +3362,7 @@ extension on Translations {
'history.ratedPoor' => 'It did poorly', 'history.ratedPoor' => 'It did poorly',
'history.outcomeTitle' => 'Season note', 'history.outcomeTitle' => 'Season note',
'history.outcomeYear' => ({required Object year}) => 'Season ${year}', '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.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.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', 'sale.add' => 'Record a sale',

View file

@ -1074,6 +1074,7 @@ class _Translations$history$es extends Translations$history$en {
@override String get ratedPoor => 'Se dio mal'; @override String get ratedPoor => 'Se dio mal';
@override String get outcomeTitle => 'Nota de temporada'; @override String get outcomeTitle => 'Nota de temporada';
@override String outcomeYear({required Object year}) => 'Temporada ${year}'; @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 // Path: sale
@ -2101,6 +2102,7 @@ extension on TranslationsEs {
'history.ratedPoor' => 'Se dio mal', 'history.ratedPoor' => 'Se dio mal',
'history.outcomeTitle' => 'Nota de temporada', 'history.outcomeTitle' => 'Nota de temporada',
'history.outcomeYear' => ({required Object year}) => 'Temporada ${year}', '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.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.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', 'sale.add' => 'Registrar venta',

View file

@ -3,6 +3,7 @@ import 'package:intl/intl.dart';
import '../data/variety_repository.dart'; import '../data/variety_repository.dart';
import '../db/enums.dart'; import '../db/enums.dart';
import '../domain/seed_saving.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import 'quantity_kind_l10n.dart'; import 'quantity_kind_l10n.dart';
@ -14,20 +15,31 @@ Future<void> showLotHistorySheet(
BuildContext context, { BuildContext context, {
required VarietyRepository repository, required VarietyRepository repository,
required VarietyLot lot, required VarietyLot lot,
SeedSavingGuide? guide,
}) { }) {
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => _LotHistorySheet(repository: repository, lot: lot), builder: (_) =>
_LotHistorySheet(repository: repository, lot: lot, guide: guide),
); );
} }
class _LotHistorySheet extends StatefulWidget { 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 VarietyRepository repository;
final VarietyLot lot; 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 @override
State<_LotHistorySheet> createState() => _LotHistorySheetState(); State<_LotHistorySheet> createState() => _LotHistorySheetState();
} }
@ -43,6 +55,10 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
bool _askOutcome = false; bool _askOutcome = false;
final _outcomeNote = TextEditingController(); 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@ -74,9 +90,14 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
), ),
), ),
); );
final guide = widget.guide;
setState(() { setState(() {
_saving = false; _saving = false;
_askOutcome = type == MovementType.harvested; _askOutcome = type == MovementType.harvested;
_showSowingHint =
type == MovementType.sown &&
guide?.pollination == Pollination.cross &&
guide?.isolationMinM != null;
_history = widget.repository.lotHistory(widget.lot.id); _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) ...[ if (_askOutcome) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_OutcomeQuestion( _OutcomeQuestion(

View file

@ -237,6 +237,10 @@ class _DetailView extends StatelessWidget {
cubit: cubit, cubit: cubit,
lot: lot, lot: lot,
viabilityYears: detail.viabilityYears, viabilityYears: detail.viabilityYears,
guide: SeedSavingCatalog.guideFor(
scientificName: detail.scientificName,
family: detail.family ?? detail.category,
),
), ),
_PlantaresSection(varietyId: detail.id), _PlantaresSection(varietyId: detail.id),
], ],
@ -375,6 +379,7 @@ class _LotTile extends StatelessWidget {
required this.cubit, required this.cubit,
required this.lot, required this.lot,
required this.viabilityYears, required this.viabilityYears,
this.guide,
}); });
final VarietyDetailCubit cubit; final VarietyDetailCubit cubit;
@ -383,6 +388,10 @@ class _LotTile extends StatelessWidget {
/// Typical seed longevity of the variety's species (years), or null. /// Typical seed longevity of the variety's species (years), or null.
final int? viabilityYears; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = context.t; final t = context.t;
@ -443,6 +452,7 @@ class _LotTile extends StatelessWidget {
context, context,
repository: context.read<VarietyRepository>(), repository: context.read<VarietyRepository>(),
lot: lot, lot: lot,
guide: guide,
), ),
), ),
IconButton( IconButton(

View file

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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/database.dart';
import 'package:tane/db/enums.dart'; import 'package:tane/db/enums.dart';
import 'package:tane/domain/seed_saving.dart';
import '../support/test_support.dart'; import '../support/test_support.dart';
@ -9,7 +11,10 @@ void main() {
late AppDatabase db; late AppDatabase db;
setUp(() => db = newTestDatabase()); 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', ( testWidgets('the lot tile opens the batch story, closed by its origin', (
tester, tester,
@ -153,6 +158,52 @@ void main() {
await disposeTree(tester); 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<void> 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 { testWidgets('a plant lot offers harvest but not sowing', (tester) async {
final repo = newTestRepository(db); final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Rosemary'); final id = await repo.addQuickVariety(label: 'Rosemary');