diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index a340cd4..94a4326 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -18,22 +18,63 @@ class VarietyListItem extends Equatable { List 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 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 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 get props => [id, harvestYear, quantity, storageLocation]; + List 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 = >{}; + 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 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 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), diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 4420ac4..393a00e 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 21617d2..8abedee 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 356c471..894126f 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: 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 diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 8ca6f36..888e780 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -45,6 +45,7 @@ class Translations with BaseTranslations { 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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index f98a224..2b63a61 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -44,6 +44,7 @@ class TranslationsEs extends Translations with BaseTranslations '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', diff --git a/apps/app_seeds/lib/state/variety_detail_cubit.dart b/apps/app_seeds/lib/state/variety_detail_cubit.dart index f8695ea..0780587 100644 --- a/apps/app_seeds/lib/state/variety_detail_cubit.dart +++ b/apps/app_seeds/lib/state/variety_detail_cubit.dart @@ -56,6 +56,18 @@ class VarietyDetailCubit extends Cubit { Future linkSpecies(String speciesId) => _repo.linkSpecies(varietyId, speciesId); + Future addGerminationTest({ + required String lotId, + int? testedOn, + int? sampleSize, + int? germinatedCount, + }) => _repo.addGerminationTest( + lotId: lotId, + testedOn: testedOn, + sampleSize: sampleSize, + germinatedCount: germinatedCount, + ); + Future deleteVariety() async { await _repo.softDeleteVariety(varietyId); emit( diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index dbf22d8..e0e2abc 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -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 _showGerminationSheet( + BuildContext context, + VarietyDetailCubit cubit, + VarietyLot lot, +) { + final t = context.t; + final sample = TextEditingController(); + final germinated = TextEditingController(); + return showModalBottomSheet( + 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 _confirmDelete( BuildContext context, VarietyDetailCubit cubit, diff --git a/apps/app_seeds/test/data/variety_detail_test.dart b/apps/app_seeds/test/data/variety_detail_test.dart index bb7dfc8..f3cea69 100644 --- a/apps/app_seeds/test/data/variety_detail_test.dart +++ b/apps/app_seeds/test/data/variety_detail_test.dart @@ -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(); + }); } diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart index ddf3d4e..6b49e2c 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -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); + }); }