feat(handover): one hand-over sheet replaces the separate sale/plantare doors

'Seeds changed hands' is now the single entry point on the variety
detail (and per lot row): direction (gave/received), one-tap 'all of it'
(default — the whole-plant/shrub case), optional partial quantity,
counterparty, and reveal-on-tap money and return-promise sections that
spawn the Sale/Plantare rows through recordHandover in one save.

Zero-lot varieties still work (ledger rows only); several lots get a
chip picker. The Sales/Plantares screens keep their own add buttons for
off-app records — the sheets stay, demoted to secondary.

i18n handover block in en/es/pt/ast; widget tests for the flow.
This commit is contained in:
vjrj 2026-07-11 07:59:23 +02:00
parent 665e3e9452
commit 269fb59070
12 changed files with 849 additions and 152 deletions

View file

@ -17,11 +17,10 @@ import '../domain/species_reference_links.dart';
import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart';
import 'currency_quick_picks.dart';
import 'handover_sheet.dart';
import 'harvest_date_picker.dart';
import 'photo_pick.dart';
import 'plantare_sheet.dart';
import 'quantity_kind_l10n.dart';
import 'sale_sheet.dart';
import 'quantity_picker.dart';
import 'seed_glyph.dart';
import 'theme.dart';
@ -118,25 +117,19 @@ class _DetailView extends StatelessWidget {
spacing: 8,
runSpacing: 8,
children: [
// ONE door for "seeds changed hands": gift, swap or sale, with
// or without money or a return promise. The sales/plantares
// screens keep their own add buttons for off-app records.
OutlinedButton.icon(
key: const Key('detail.addPlantare'),
icon: const Icon(Icons.volunteer_activism_outlined, size: 18),
onPressed: () => showPlantareSheet(
key: const Key('detail.handover'),
icon: const Icon(Icons.handshake_outlined, size: 18),
onPressed: () => showHandoverSheet(
context,
repository: context.read<VarietyRepository>(),
varietyId: detail.id,
lots: detail.lots,
),
label: Text(t.plantare.add),
),
OutlinedButton.icon(
key: const Key('detail.addSale'),
icon: const Icon(Icons.sell_outlined, size: 18),
onPressed: () => showSaleSheet(
context,
repository: context.read<VarietyRepository>(),
varietyId: detail.id,
),
label: Text(t.sale.add),
label: Text(t.handover.title),
),
],
),
@ -323,22 +316,32 @@ class _LotTile extends StatelessWidget {
?warning,
],
),
// Germination only applies to seed lots, not to plantel.
trailing: lot.type != LotType.seed
? null
: 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),
),
],
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lot.type == LotType.seed && lot.latestGerminationRate != null)
_GerminationBadge(rate: lot.latestGerminationRate!),
IconButton(
key: Key('lot.handover.${lot.id}'),
icon: const Icon(Icons.handshake_outlined),
tooltip: t.handover.title,
onPressed: () => showHandoverSheet(
context,
repository: context.read<VarietyRepository>(),
varietyId: cubit.varietyId,
lots: [lot],
),
),
// Germination only applies to seed lots, not to plantel.
if (lot.type == LotType.seed)
IconButton(
key: Key('lot.germination.${lot.id}'),
icon: const Icon(Icons.grass_outlined),
tooltip: t.germination.title,
onPressed: () => _showGerminationSheet(context, cubit, lot),
),
],
),
);
}
}
@ -497,80 +500,82 @@ Future<void> _showGerminationSheet(
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!),
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,
),
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(
borderRadius: BorderRadius.all(Radius.circular(8)),
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(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
),
),
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(
borderRadius: BorderRadius.all(Radius.circular(8)),
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(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
),
),
],
),
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),
),
],
),
],
),
],
),
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),
),
],
),
],
),
),
),
);
@ -1376,62 +1381,63 @@ Future<void> _showConditionSheet(
t.conditionCheck.title,
style: Theme.of(sheetContext).textTheme.titleLarge,
),
const SizedBox(height: 12),
TextField(
key: const Key('conditionCheck.containers'),
controller: containers,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.conditionCheck.containers,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
const SizedBox(height: 12),
TextField(
key: const Key('conditionCheck.containers'),
controller: containers,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.conditionCheck.containers,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
),
const SizedBox(height: 16),
Text(
t.conditionCheck.desiccant,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final d in DesiccantState.values)
ChoiceChip(
key: Key('desiccant.${d.name}'),
label: Text(desiccantLabel(t, d)),
selected: state == d,
onSelected: (sel) => setState(() => state = sel ? d : null),
const SizedBox(height: 16),
Text(
t.conditionCheck.desiccant,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final d in DesiccantState.values)
ChoiceChip(
key: Key('desiccant.${d.name}'),
label: Text(desiccantLabel(t, d)),
selected: state == d,
onSelected: (sel) =>
setState(() => state = sel ? d : null),
),
],
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(t.common.cancel),
),
],
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(t.common.cancel),
),
const Spacer(),
FilledButton(
key: const Key('conditionCheck.save'),
onPressed: () {
cubit.addConditionCheck(
lotId: lot.id,
checkedOn: DateTime.now().millisecondsSinceEpoch,
containerCount: int.tryParse(containers.text.trim()),
desiccantState: state,
);
Navigator.of(sheetContext).pop();
},
child: Text(t.conditionCheck.add),
),
],
),
],
),
const Spacer(),
FilledButton(
key: const Key('conditionCheck.save'),
onPressed: () {
cubit.addConditionCheck(
lotId: lot.id,
checkedOn: DateTime.now().millisecondsSinceEpoch,
containerCount: int.tryParse(containers.text.trim()),
desiccantState: state,
);
Navigator.of(sheetContext).pop();
},
child: Text(t.conditionCheck.add),
),
],
),
],
),
),
),
),