feat(app): hint isolation distance when sowing a cross-pollinating crop
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:
parent
f1aa8f8e66
commit
1a826477f2
8 changed files with 116 additions and 7 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue