feat(block1): germination tests per lot with derived rate
Record germination tests on a lot and surface the result. - GerminationEntry model with a derived rate (germinated / sample). VarietyLot carries its tests (most-recent first) and exposes latestGerminationRate. - VarietyRepository.addGerminationTest; watchVariety now also re-emits on germination-test changes, so the detail refreshes live. - Detail UI: each lot shows a germination % badge and a grass action that opens a sheet listing past tests and adding a new one (germinated + sample size). i18n strings (ES/EN). Tests: derived-rate + reactivity at the repository, and a widget test for the record-test → badge flow. Full suite: 35 passing, 0 skipped.
This commit is contained in:
parent
4e8b8293e0
commit
37427fa738
10 changed files with 405 additions and 6 deletions
|
|
@ -18,22 +18,63 @@ class VarietyListItem extends Equatable {
|
|||
List<Object?> get props => [id, label, category];
|
||||
}
|
||||
|
||||
/// One held batch of a variety, for the detail view.
|
||||
/// One germination test on a lot; [rate] is derived (0..1), null when it can't
|
||||
/// be computed.
|
||||
class GerminationEntry extends Equatable {
|
||||
const GerminationEntry({
|
||||
required this.id,
|
||||
this.testedOn,
|
||||
this.sampleSize,
|
||||
this.germinatedCount,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int? testedOn; // ms since epoch
|
||||
final int? sampleSize;
|
||||
final int? germinatedCount;
|
||||
final String? notes;
|
||||
|
||||
double? get rate {
|
||||
final sample = sampleSize;
|
||||
final germinated = germinatedCount;
|
||||
if (sample == null || sample <= 0 || germinated == null) return null;
|
||||
return germinated / sample;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, testedOn, sampleSize, germinatedCount, notes];
|
||||
}
|
||||
|
||||
/// One held batch of a variety, for the detail view. [germinationTests] are
|
||||
/// ordered most-recent first, so `germinationTests.first` is the latest.
|
||||
class VarietyLot extends Equatable {
|
||||
const VarietyLot({
|
||||
required this.id,
|
||||
this.harvestYear,
|
||||
this.quantity,
|
||||
this.storageLocation,
|
||||
this.germinationTests = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int? harvestYear;
|
||||
final Quantity? quantity;
|
||||
final String? storageLocation;
|
||||
final List<GerminationEntry> germinationTests;
|
||||
|
||||
/// The most recent germination rate (0..1), or null if there are no tests.
|
||||
double? get latestGerminationRate =>
|
||||
germinationTests.isEmpty ? null : germinationTests.first.rate;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, harvestYear, quantity, storageLocation];
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
harvestYear,
|
||||
quantity,
|
||||
storageLocation,
|
||||
germinationTests,
|
||||
];
|
||||
}
|
||||
|
||||
/// The full detail of one variety (identity + its lots, names and first photo).
|
||||
|
|
@ -197,6 +238,8 @@ class VarietyRepository {
|
|||
(_db.select(
|
||||
_db.attachments,
|
||||
)..where((a) => a.parentId.equals(id))).watch().map((_) {}),
|
||||
// Coarse: any germination-test change re-emits (catalog of tests is tiny).
|
||||
_db.select(_db.germinationTests).watch().map((_) {}),
|
||||
]);
|
||||
return triggers.asyncMap((_) => _loadVariety(id));
|
||||
}
|
||||
|
|
@ -221,6 +264,30 @@ class VarietyRepository {
|
|||
..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false))
|
||||
..orderBy([(l) => OrderingTerm.desc(l.harvestYear)]))
|
||||
.get();
|
||||
|
||||
final lotIds = lots.map((l) => l.id).toList();
|
||||
final testsByLot = <String, List<GerminationEntry>>{};
|
||||
if (lotIds.isNotEmpty) {
|
||||
final tests =
|
||||
await (_db.select(_db.germinationTests)
|
||||
..where((g) => g.lotId.isIn(lotIds) & g.isDeleted.equals(false))
|
||||
..orderBy([(g) => OrderingTerm.desc(g.testedOn)]))
|
||||
.get();
|
||||
for (final t in tests) {
|
||||
testsByLot
|
||||
.putIfAbsent(t.lotId, () => [])
|
||||
.add(
|
||||
GerminationEntry(
|
||||
id: t.id,
|
||||
testedOn: t.testedOn,
|
||||
sampleSize: t.sampleSize,
|
||||
germinatedCount: t.germinatedCount,
|
||||
notes: t.notes,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final names = await (_db.select(
|
||||
_db.varietyVernacularNames,
|
||||
)..where((n) => n.varietyId.equals(id) & n.isDeleted.equals(false))).get();
|
||||
|
|
@ -243,7 +310,7 @@ class VarietyRepository {
|
|||
notes: v.notes,
|
||||
speciesId: v.speciesId,
|
||||
scientificName: scientificName,
|
||||
lots: lots.map(_toLot).toList(),
|
||||
lots: lots.map((l) => _toLot(l, testsByLot[l.id] ?? const [])).toList(),
|
||||
vernacularNames: names.map((n) => n.name).toList(),
|
||||
photo: photos.isEmpty ? null : photos.first.bytes,
|
||||
);
|
||||
|
|
@ -337,7 +404,35 @@ class VarietyRepository {
|
|||
);
|
||||
}
|
||||
|
||||
VarietyLot _toLot(Lot l) {
|
||||
/// Records a germination test for a lot. Returns the new test id.
|
||||
Future<String> addGerminationTest({
|
||||
required String lotId,
|
||||
int? testedOn,
|
||||
int? sampleSize,
|
||||
int? germinatedCount,
|
||||
String? notes,
|
||||
}) async {
|
||||
final (created, updated) = _stamp();
|
||||
final id = idGen.newId();
|
||||
await _db
|
||||
.into(_db.germinationTests)
|
||||
.insert(
|
||||
GerminationTestsCompanion.insert(
|
||||
id: id,
|
||||
lotId: lotId,
|
||||
createdAt: created,
|
||||
updatedAt: updated,
|
||||
lastAuthor: nodeId,
|
||||
testedOn: Value(testedOn),
|
||||
sampleSize: Value(sampleSize),
|
||||
germinatedCount: Value(germinatedCount),
|
||||
notes: Value(notes),
|
||||
),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
VarietyLot _toLot(Lot l, List<GerminationEntry> germinationTests) {
|
||||
final hasQuantity =
|
||||
l.quantityKind != null ||
|
||||
l.quantityPrecise != null ||
|
||||
|
|
@ -346,6 +441,7 @@ class VarietyRepository {
|
|||
id: l.id,
|
||||
harvestYear: l.harvestYear,
|
||||
storageLocation: l.storageLocation,
|
||||
germinationTests: germinationTests,
|
||||
quantity: hasQuantity
|
||||
? Quantity(
|
||||
kind: _parseKind(l.quantityKind),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,14 @@
|
|||
"year": "Year {year}",
|
||||
"noYear": "Year unknown"
|
||||
},
|
||||
"germination": {
|
||||
"title": "Germination",
|
||||
"add": "Add test",
|
||||
"sampleSize": "Sample size",
|
||||
"germinated": "Germinated",
|
||||
"none": "No germination tests yet.",
|
||||
"result": "{percent}%"
|
||||
},
|
||||
"editVariety": {
|
||||
"title": "Edit seed",
|
||||
"name": "Name",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,14 @@
|
|||
"year": "Año {year}",
|
||||
"noYear": "Año desconocido"
|
||||
},
|
||||
"germination": {
|
||||
"title": "Germinación",
|
||||
"add": "Añadir prueba",
|
||||
"sampleSize": "Muestra",
|
||||
"germinated": "Germinadas",
|
||||
"none": "Aún no hay pruebas de germinación.",
|
||||
"result": "{percent}%"
|
||||
},
|
||||
"editVariety": {
|
||||
"title": "Editar semilla",
|
||||
"name": "Nombre",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 2
|
||||
/// Strings: 106 (53 per locale)
|
||||
/// Strings: 118 (59 per locale)
|
||||
///
|
||||
/// Built on 2026-07-07 at 19:21 UTC
|
||||
/// Built on 2026-07-07 at 19:58 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root);
|
||||
late final Translations$quickAdd$en quickAdd = Translations$quickAdd$en.internal(_root);
|
||||
late final Translations$detail$en detail = Translations$detail$en.internal(_root);
|
||||
late final Translations$germination$en germination = Translations$germination$en.internal(_root);
|
||||
late final Translations$editVariety$en editVariety = Translations$editVariety$en.internal(_root);
|
||||
late final Translations$addLot$en addLot = Translations$addLot$en.internal(_root);
|
||||
late final Translations$quantityKind$en quantityKind = Translations$quantityKind$en.internal(_root);
|
||||
|
|
@ -173,6 +174,33 @@ class Translations$detail$en {
|
|||
String get noYear => 'Year unknown';
|
||||
}
|
||||
|
||||
// Path: germination
|
||||
class Translations$germination$en {
|
||||
Translations$germination$en.internal(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Germination'
|
||||
String get title => 'Germination';
|
||||
|
||||
/// en: 'Add test'
|
||||
String get add => 'Add test';
|
||||
|
||||
/// en: 'Sample size'
|
||||
String get sampleSize => 'Sample size';
|
||||
|
||||
/// en: 'Germinated'
|
||||
String get germinated => 'Germinated';
|
||||
|
||||
/// en: 'No germination tests yet.'
|
||||
String get none => 'No germination tests yet.';
|
||||
|
||||
/// en: '{percent}%'
|
||||
String result({required Object percent}) => '${percent}%';
|
||||
}
|
||||
|
||||
// Path: editVariety
|
||||
class Translations$editVariety$en {
|
||||
Translations$editVariety$en.internal(this._root);
|
||||
|
|
@ -315,6 +343,12 @@ extension on Translations {
|
|||
'detail.deleteConfirm' => 'Delete this seed?',
|
||||
'detail.year' => ({required Object year}) => 'Year ${year}',
|
||||
'detail.noYear' => 'Year unknown',
|
||||
'germination.title' => 'Germination',
|
||||
'germination.add' => 'Add test',
|
||||
'germination.sampleSize' => 'Sample size',
|
||||
'germination.germinated' => 'Germinated',
|
||||
'germination.none' => 'No germination tests yet.',
|
||||
'germination.result' => ({required Object percent}) => '${percent}%',
|
||||
'editVariety.title' => 'Edit seed',
|
||||
'editVariety.name' => 'Name',
|
||||
'editVariety.category' => 'Category',
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
|
||||
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$es._(_root);
|
||||
@override late final _Translations$detail$es detail = _Translations$detail$es._(_root);
|
||||
@override late final _Translations$germination$es germination = _Translations$germination$es._(_root);
|
||||
@override late final _Translations$editVariety$es editVariety = _Translations$editVariety$es._(_root);
|
||||
@override late final _Translations$addLot$es addLot = _Translations$addLot$es._(_root);
|
||||
@override late final _Translations$quantityKind$es quantityKind = _Translations$quantityKind$es._(_root);
|
||||
|
|
@ -120,6 +121,21 @@ class _Translations$detail$es extends Translations$detail$en {
|
|||
@override String get noYear => 'Año desconocido';
|
||||
}
|
||||
|
||||
// Path: germination
|
||||
class _Translations$germination$es extends Translations$germination$en {
|
||||
_Translations$germination$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsEs _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Germinación';
|
||||
@override String get add => 'Añadir prueba';
|
||||
@override String get sampleSize => 'Muestra';
|
||||
@override String get germinated => 'Germinadas';
|
||||
@override String get none => 'Aún no hay pruebas de germinación.';
|
||||
@override String result({required Object percent}) => '${percent}%';
|
||||
}
|
||||
|
||||
// Path: editVariety
|
||||
class _Translations$editVariety$es extends Translations$editVariety$en {
|
||||
_Translations$editVariety$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
|
@ -208,6 +224,12 @@ extension on TranslationsEs {
|
|||
'detail.deleteConfirm' => '¿Eliminar esta semilla?',
|
||||
'detail.year' => ({required Object year}) => 'Año ${year}',
|
||||
'detail.noYear' => 'Año desconocido',
|
||||
'germination.title' => 'Germinación',
|
||||
'germination.add' => 'Añadir prueba',
|
||||
'germination.sampleSize' => 'Muestra',
|
||||
'germination.germinated' => 'Germinadas',
|
||||
'germination.none' => 'Aún no hay pruebas de germinación.',
|
||||
'germination.result' => ({required Object percent}) => '${percent}%',
|
||||
'editVariety.title' => 'Editar semilla',
|
||||
'editVariety.name' => 'Nombre',
|
||||
'editVariety.category' => 'Categoría',
|
||||
|
|
|
|||
|
|
@ -56,6 +56,18 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
Future<void> linkSpecies(String speciesId) =>
|
||||
_repo.linkSpecies(varietyId, speciesId);
|
||||
|
||||
Future<void> addGerminationTest({
|
||||
required String lotId,
|
||||
int? testedOn,
|
||||
int? sampleSize,
|
||||
int? germinatedCount,
|
||||
}) => _repo.addGerminationTest(
|
||||
lotId: lotId,
|
||||
testedOn: testedOn,
|
||||
sampleSize: sampleSize,
|
||||
germinatedCount: germinatedCount,
|
||||
);
|
||||
|
||||
Future<void> deleteVariety() async {
|
||||
await _repo.softDeleteVariety(varietyId);
|
||||
emit(
|
||||
|
|
|
|||
|
|
@ -139,6 +139,20 @@ class _DetailView extends StatelessWidget {
|
|||
subtitle: lot.storageLocation == null
|
||||
? null
|
||||
: Text(lot.storageLocation!),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (lot.latestGerminationRate != null)
|
||||
_GerminationBadge(rate: lot.latestGerminationRate!),
|
||||
IconButton(
|
||||
key: Key('lot.germination.${lot.id}'),
|
||||
icon: const Icon(Icons.grass_outlined),
|
||||
tooltip: t.germination.title,
|
||||
onPressed: () =>
|
||||
_showGerminationSheet(context, cubit, lot),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -166,6 +180,135 @@ String _quantityLabel(Translations t, Quantity q) {
|
|||
String _trimDouble(double v) =>
|
||||
v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toString();
|
||||
|
||||
String _germinationPercent(Translations t, double rate) =>
|
||||
t.germination.result(percent: (rate * 100).round());
|
||||
|
||||
/// A compact pill showing the latest germination rate on a lot tile.
|
||||
class _GerminationBadge extends StatelessWidget {
|
||||
const _GerminationBadge({required this.rate});
|
||||
|
||||
final double rate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_germinationPercent(context.t, rate),
|
||||
style: TextStyle(
|
||||
color: scheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showGerminationSheet(
|
||||
BuildContext context,
|
||||
VarietyDetailCubit cubit,
|
||||
VarietyLot lot,
|
||||
) {
|
||||
final t = context.t;
|
||||
final sample = TextEditingController();
|
||||
final germinated = TextEditingController();
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
t.germination.title,
|
||||
style: Theme.of(sheetContext).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (lot.germinationTests.isEmpty)
|
||||
Text(t.germination.none)
|
||||
else
|
||||
for (final test in lot.germinationTests)
|
||||
ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.grass_outlined),
|
||||
title: Text(
|
||||
test.rate == null ? '—' : _germinationPercent(t, test.rate!),
|
||||
),
|
||||
subtitle:
|
||||
(test.sampleSize != null && test.germinatedCount != null)
|
||||
? Text('${test.germinatedCount}/${test.sampleSize}')
|
||||
: null,
|
||||
),
|
||||
const Divider(),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const Key('germination.germinated'),
|
||||
controller: germinated,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.germination.germinated,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: const Key('germination.sampleSize'),
|
||||
controller: sample,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.germination.sampleSize,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
key: const Key('germination.save'),
|
||||
onPressed: () {
|
||||
cubit.addGerminationTest(
|
||||
lotId: lot.id,
|
||||
testedOn: DateTime.now().millisecondsSinceEpoch,
|
||||
sampleSize: int.tryParse(sample.text.trim()),
|
||||
germinatedCount: int.tryParse(germinated.text.trim()),
|
||||
);
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
child: Text(t.germination.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(
|
||||
BuildContext context,
|
||||
VarietyDetailCubit cubit,
|
||||
|
|
|
|||
|
|
@ -97,4 +97,42 @@ void main() {
|
|||
final detail = await repo.watchVariety(id).first;
|
||||
expect(detail!.category, 'My tomatoes');
|
||||
});
|
||||
|
||||
test(
|
||||
'addGerminationTest surfaces the latest derived rate on the lot',
|
||||
() async {
|
||||
final id = await repo.addQuickVariety(label: 'Maize');
|
||||
final lotId = await repo.addLot(varietyId: id, harvestYear: 2024);
|
||||
await repo.addGerminationTest(
|
||||
lotId: lotId,
|
||||
testedOn: 1000,
|
||||
sampleSize: 20,
|
||||
germinatedCount: 18,
|
||||
);
|
||||
|
||||
final lot = (await repo.watchVariety(id).first)!.lots.single;
|
||||
expect(lot.germinationTests, hasLength(1));
|
||||
expect(lot.latestGerminationRate, closeTo(0.9, 1e-9));
|
||||
},
|
||||
);
|
||||
|
||||
test('watchVariety reacts to a germination test being added', () async {
|
||||
final id = await repo.addQuickVariety(label: 'Maize');
|
||||
final lotId = await repo.addLot(varietyId: id);
|
||||
final queue = StreamQueue(repo.watchVariety(id));
|
||||
|
||||
final initial = await queue.next;
|
||||
expect(initial!.lots.single.germinationTests, isEmpty);
|
||||
|
||||
await repo.addGerminationTest(
|
||||
lotId: lotId,
|
||||
sampleSize: 10,
|
||||
germinatedCount: 9,
|
||||
);
|
||||
|
||||
final updated = await queue.next;
|
||||
expect(updated!.lots.single.latestGerminationRate, closeTo(0.9, 1e-9));
|
||||
|
||||
await queue.cancel();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,4 +157,42 @@ void main() {
|
|||
await disposeTree(tester);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('recording a germination test shows the rate badge on the lot', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = newTestRepository(db);
|
||||
final id = await repo.addQuickVariety(label: 'Maize');
|
||||
await repo.addLot(
|
||||
varietyId: id,
|
||||
harvestYear: 2024,
|
||||
quantity: const Quantity(kind: QuantityKind.cob),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrapDetail(
|
||||
repository: repo,
|
||||
varietyId: id,
|
||||
species: newTestSpeciesRepository(db),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.grass_outlined).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('germination.germinated')),
|
||||
'18',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('germination.sampleSize')),
|
||||
'20',
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('germination.save')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('90%'), findsOneWidget); // germination badge on the lot
|
||||
await disposeTree(tester);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue