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:
vjrj 2026-07-07 21:59:34 +02:00
parent 4e8b8293e0
commit 37427fa738
10 changed files with 405 additions and 6 deletions

View file

@ -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,