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];
|
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 {
|
class VarietyLot extends Equatable {
|
||||||
const VarietyLot({
|
const VarietyLot({
|
||||||
required this.id,
|
required this.id,
|
||||||
this.harvestYear,
|
this.harvestYear,
|
||||||
this.quantity,
|
this.quantity,
|
||||||
this.storageLocation,
|
this.storageLocation,
|
||||||
|
this.germinationTests = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final int? harvestYear;
|
final int? harvestYear;
|
||||||
final Quantity? quantity;
|
final Quantity? quantity;
|
||||||
final String? storageLocation;
|
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
|
@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).
|
/// The full detail of one variety (identity + its lots, names and first photo).
|
||||||
|
|
@ -197,6 +238,8 @@ class VarietyRepository {
|
||||||
(_db.select(
|
(_db.select(
|
||||||
_db.attachments,
|
_db.attachments,
|
||||||
)..where((a) => a.parentId.equals(id))).watch().map((_) {}),
|
)..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));
|
return triggers.asyncMap((_) => _loadVariety(id));
|
||||||
}
|
}
|
||||||
|
|
@ -221,6 +264,30 @@ class VarietyRepository {
|
||||||
..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false))
|
..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false))
|
||||||
..orderBy([(l) => OrderingTerm.desc(l.harvestYear)]))
|
..orderBy([(l) => OrderingTerm.desc(l.harvestYear)]))
|
||||||
.get();
|
.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(
|
final names = await (_db.select(
|
||||||
_db.varietyVernacularNames,
|
_db.varietyVernacularNames,
|
||||||
)..where((n) => n.varietyId.equals(id) & n.isDeleted.equals(false))).get();
|
)..where((n) => n.varietyId.equals(id) & n.isDeleted.equals(false))).get();
|
||||||
|
|
@ -243,7 +310,7 @@ class VarietyRepository {
|
||||||
notes: v.notes,
|
notes: v.notes,
|
||||||
speciesId: v.speciesId,
|
speciesId: v.speciesId,
|
||||||
scientificName: scientificName,
|
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(),
|
vernacularNames: names.map((n) => n.name).toList(),
|
||||||
photo: photos.isEmpty ? null : photos.first.bytes,
|
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 =
|
final hasQuantity =
|
||||||
l.quantityKind != null ||
|
l.quantityKind != null ||
|
||||||
l.quantityPrecise != null ||
|
l.quantityPrecise != null ||
|
||||||
|
|
@ -346,6 +441,7 @@ class VarietyRepository {
|
||||||
id: l.id,
|
id: l.id,
|
||||||
harvestYear: l.harvestYear,
|
harvestYear: l.harvestYear,
|
||||||
storageLocation: l.storageLocation,
|
storageLocation: l.storageLocation,
|
||||||
|
germinationTests: germinationTests,
|
||||||
quantity: hasQuantity
|
quantity: hasQuantity
|
||||||
? Quantity(
|
? Quantity(
|
||||||
kind: _parseKind(l.quantityKind),
|
kind: _parseKind(l.quantityKind),
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,14 @@
|
||||||
"year": "Year {year}",
|
"year": "Year {year}",
|
||||||
"noYear": "Year unknown"
|
"noYear": "Year unknown"
|
||||||
},
|
},
|
||||||
|
"germination": {
|
||||||
|
"title": "Germination",
|
||||||
|
"add": "Add test",
|
||||||
|
"sampleSize": "Sample size",
|
||||||
|
"germinated": "Germinated",
|
||||||
|
"none": "No germination tests yet.",
|
||||||
|
"result": "{percent}%"
|
||||||
|
},
|
||||||
"editVariety": {
|
"editVariety": {
|
||||||
"title": "Edit seed",
|
"title": "Edit seed",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,14 @@
|
||||||
"year": "Año {year}",
|
"year": "Año {year}",
|
||||||
"noYear": "Año desconocido"
|
"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": {
|
"editVariety": {
|
||||||
"title": "Editar semilla",
|
"title": "Editar semilla",
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 2
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// 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$inventory$en inventory = Translations$inventory$en.internal(_root);
|
||||||
late final Translations$quickAdd$en quickAdd = Translations$quickAdd$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$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$editVariety$en editVariety = Translations$editVariety$en.internal(_root);
|
||||||
late final Translations$addLot$en addLot = Translations$addLot$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);
|
late final Translations$quantityKind$en quantityKind = Translations$quantityKind$en.internal(_root);
|
||||||
|
|
@ -173,6 +174,33 @@ class Translations$detail$en {
|
||||||
String get noYear => 'Year unknown';
|
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
|
// Path: editVariety
|
||||||
class Translations$editVariety$en {
|
class Translations$editVariety$en {
|
||||||
Translations$editVariety$en.internal(this._root);
|
Translations$editVariety$en.internal(this._root);
|
||||||
|
|
@ -315,6 +343,12 @@ extension on Translations {
|
||||||
'detail.deleteConfirm' => 'Delete this seed?',
|
'detail.deleteConfirm' => 'Delete this seed?',
|
||||||
'detail.year' => ({required Object year}) => 'Year ${year}',
|
'detail.year' => ({required Object year}) => 'Year ${year}',
|
||||||
'detail.noYear' => 'Year unknown',
|
'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.title' => 'Edit seed',
|
||||||
'editVariety.name' => 'Name',
|
'editVariety.name' => 'Name',
|
||||||
'editVariety.category' => 'Category',
|
'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$inventory$es inventory = _Translations$inventory$es._(_root);
|
||||||
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$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$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$editVariety$es editVariety = _Translations$editVariety$es._(_root);
|
||||||
@override late final _Translations$addLot$es addLot = _Translations$addLot$es._(_root);
|
@override late final _Translations$addLot$es addLot = _Translations$addLot$es._(_root);
|
||||||
@override late final _Translations$quantityKind$es quantityKind = _Translations$quantityKind$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';
|
@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
|
// Path: editVariety
|
||||||
class _Translations$editVariety$es extends Translations$editVariety$en {
|
class _Translations$editVariety$es extends Translations$editVariety$en {
|
||||||
_Translations$editVariety$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_Translations$editVariety$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -208,6 +224,12 @@ extension on TranslationsEs {
|
||||||
'detail.deleteConfirm' => '¿Eliminar esta semilla?',
|
'detail.deleteConfirm' => '¿Eliminar esta semilla?',
|
||||||
'detail.year' => ({required Object year}) => 'Año ${year}',
|
'detail.year' => ({required Object year}) => 'Año ${year}',
|
||||||
'detail.noYear' => 'Año desconocido',
|
'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.title' => 'Editar semilla',
|
||||||
'editVariety.name' => 'Nombre',
|
'editVariety.name' => 'Nombre',
|
||||||
'editVariety.category' => 'Categoría',
|
'editVariety.category' => 'Categoría',
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,18 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
||||||
Future<void> linkSpecies(String speciesId) =>
|
Future<void> linkSpecies(String speciesId) =>
|
||||||
_repo.linkSpecies(varietyId, 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 {
|
Future<void> deleteVariety() async {
|
||||||
await _repo.softDeleteVariety(varietyId);
|
await _repo.softDeleteVariety(varietyId);
|
||||||
emit(
|
emit(
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,20 @@ class _DetailView extends StatelessWidget {
|
||||||
subtitle: lot.storageLocation == null
|
subtitle: lot.storageLocation == null
|
||||||
? null
|
? null
|
||||||
: Text(lot.storageLocation!),
|
: 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) =>
|
String _trimDouble(double v) =>
|
||||||
v == v.roundToDouble() ? v.toStringAsFixed(0) : v.toString();
|
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(
|
Future<void> _confirmDelete(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
VarietyDetailCubit cubit,
|
VarietyDetailCubit cubit,
|
||||||
|
|
|
||||||
|
|
@ -97,4 +97,42 @@ void main() {
|
||||||
final detail = await repo.watchVariety(id).first;
|
final detail = await repo.watchVariety(id).first;
|
||||||
expect(detail!.category, 'My tomatoes');
|
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);
|
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