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

@ -619,6 +619,19 @@
"settledSection": "Fechos", "settledSection": "Fechos",
"removeConfirm": "¿Quitar esti compromisu?" "removeConfirm": "¿Quitar esti compromisu?"
}, },
"handover": {
"title": "Di o recibí semientes",
"help": "Un regalu, un truecu o una venta: apúntalo, con promesa de devolver semiente o ensin ella.",
"iGave": "Di semientes",
"iReceived": "Recibí semientes",
"whichLot": "¿De qué llote?",
"howMuch": "¿Cuánto?",
"allOfIt": "Too",
"partOfIt": "Una parte",
"paymentChip": "Hubo perres pel mediu",
"promiseGave": "Van devolveme semiente",
"promiseReceived": "Voi devolver semiente"
},
"sale": { "sale": {
"title": "Ventes", "title": "Ventes",
"help": "Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.", "help": "Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.",

View file

@ -622,6 +622,19 @@
"settledSection": "Done", "settledSection": "Done",
"removeConfirm": "Remove this commitment?" "removeConfirm": "Remove this commitment?"
}, },
"handover": {
"title": "Seeds changed hands",
"help": "A gift, a swap or a sale — note it down, with a return promise or without.",
"iGave": "I gave seeds",
"iReceived": "I received seeds",
"whichLot": "Which batch?",
"howMuch": "How much?",
"allOfIt": "All of it",
"partOfIt": "A part",
"paymentChip": "Money changed hands",
"promiseGave": "They'll return me seed",
"promiseReceived": "I'll return seed"
},
"sale": { "sale": {
"title": "Sales", "title": "Sales",
"help": "Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.", "help": "Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.",

View file

@ -621,6 +621,19 @@
"settledSection": "Hechos", "settledSection": "Hechos",
"removeConfirm": "¿Quitar este compromiso?" "removeConfirm": "¿Quitar este compromiso?"
}, },
"handover": {
"title": "Di o recibí semillas",
"help": "Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.",
"iGave": "Di semillas",
"iReceived": "Recibí semillas",
"whichLot": "¿De qué lote?",
"howMuch": "¿Cuánto?",
"allOfIt": "Todo",
"partOfIt": "Una parte",
"paymentChip": "Hubo dinero por medio",
"promiseGave": "Me devolverán semilla",
"promiseReceived": "Devolveré semilla"
},
"sale": { "sale": {
"title": "Ventas", "title": "Ventas",
"help": "Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.", "help": "Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.",

View file

@ -618,6 +618,19 @@
"settledSection": "Feitos", "settledSection": "Feitos",
"removeConfirm": "Remover este compromisso?" "removeConfirm": "Remover este compromisso?"
}, },
"handover": {
"title": "Dei ou recebi sementes",
"help": "Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.",
"iGave": "Dei sementes",
"iReceived": "Recebi sementes",
"whichLot": "De que lote?",
"howMuch": "Quanto?",
"allOfIt": "Tudo",
"partOfIt": "Uma parte",
"paymentChip": "Houve dinheiro pelo meio",
"promiseGave": "Vão devolver-me semente",
"promiseReceived": "Vou devolver semente"
},
"sale": { "sale": {
"title": "Vendas", "title": "Vendas",
"help": "Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.", "help": "Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.",

View file

@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 4 /// Locales: 4
/// Strings: 1976 (494 per locale) /// Strings: 2020 (505 per locale)
/// ///
/// Built on 2026-07-11 at 06:00 UTC /// Built on 2026-07-11 at 06:00 UTC

View file

@ -80,6 +80,7 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
@override late final _Translations$wot$ast wot = _Translations$wot$ast._(_root); @override late final _Translations$wot$ast wot = _Translations$wot$ast._(_root);
@override late final _Translations$notifications$ast notifications = _Translations$notifications$ast._(_root); @override late final _Translations$notifications$ast notifications = _Translations$notifications$ast._(_root);
@override late final _Translations$plantare$ast plantare = _Translations$plantare$ast._(_root); @override late final _Translations$plantare$ast plantare = _Translations$plantare$ast._(_root);
@override late final _Translations$handover$ast handover = _Translations$handover$ast._(_root);
@override late final _Translations$sale$ast sale = _Translations$sale$ast._(_root); @override late final _Translations$sale$ast sale = _Translations$sale$ast._(_root);
} }
@ -897,6 +898,26 @@ class _Translations$plantare$ast extends Translations$plantare$en {
@override String get removeConfirm => '¿Quitar esti compromisu?'; @override String get removeConfirm => '¿Quitar esti compromisu?';
} }
// Path: handover
class _Translations$handover$ast extends Translations$handover$en {
_Translations$handover$ast._(TranslationsAst root) : this._root = root, super.internal(root);
final TranslationsAst _root; // ignore: unused_field
// Translations
@override String get title => 'Di o recibí semientes';
@override String get help => 'Un regalu, un truecu o una venta: apúntalo, con promesa de devolver semiente o ensin ella.';
@override String get iGave => 'Di semientes';
@override String get iReceived => 'Recibí semientes';
@override String get whichLot => '¿De qué llote?';
@override String get howMuch => '¿Cuánto?';
@override String get allOfIt => 'Too';
@override String get partOfIt => 'Una parte';
@override String get paymentChip => 'Hubo perres pel mediu';
@override String get promiseGave => 'Van devolveme semiente';
@override String get promiseReceived => 'Voi devolver semiente';
}
// Path: sale // Path: sale
class _Translations$sale$ast extends Translations$sale$en { class _Translations$sale$ast extends Translations$sale$en {
_Translations$sale$ast._(TranslationsAst root) : this._root = root, super.internal(root); _Translations$sale$ast._(TranslationsAst root) : this._root = root, super.internal(root);
@ -1740,6 +1761,17 @@ extension on TranslationsAst {
'plantare.openSection' => 'Pendientes', 'plantare.openSection' => 'Pendientes',
'plantare.settledSection' => 'Fechos', 'plantare.settledSection' => 'Fechos',
'plantare.removeConfirm' => '¿Quitar esti compromisu?', 'plantare.removeConfirm' => '¿Quitar esti compromisu?',
'handover.title' => 'Di o recibí semientes',
'handover.help' => 'Un regalu, un truecu o una venta: apúntalo, con promesa de devolver semiente o ensin ella.',
'handover.iGave' => 'Di semientes',
'handover.iReceived' => 'Recibí semientes',
'handover.whichLot' => '¿De qué llote?',
'handover.howMuch' => '¿Cuánto?',
'handover.allOfIt' => 'Too',
'handover.partOfIt' => 'Una parte',
'handover.paymentChip' => 'Hubo perres pel mediu',
'handover.promiseGave' => 'Van devolveme semiente',
'handover.promiseReceived' => 'Voi devolver semiente',
'sale.title' => 'Ventes', 'sale.title' => 'Ventes',
'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.', 'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.',
'sale.add' => 'Rexistrar venta', 'sale.add' => 'Rexistrar venta',

View file

@ -81,6 +81,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$wot$en wot = Translations$wot$en.internal(_root); late final Translations$wot$en wot = Translations$wot$en.internal(_root);
late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root); late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root);
late final Translations$plantare$en plantare = Translations$plantare$en.internal(_root); late final Translations$plantare$en plantare = Translations$plantare$en.internal(_root);
late final Translations$handover$en handover = Translations$handover$en.internal(_root);
late final Translations$sale$en sale = Translations$sale$en.internal(_root); late final Translations$sale$en sale = Translations$sale$en.internal(_root);
} }
@ -1722,6 +1723,48 @@ class Translations$plantare$en {
String get removeConfirm => 'Remove this commitment?'; String get removeConfirm => 'Remove this commitment?';
} }
// Path: handover
class Translations$handover$en {
Translations$handover$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Seeds changed hands'
String get title => 'Seeds changed hands';
/// en: 'A gift, a swap or a sale — note it down, with a return promise or without.'
String get help => 'A gift, a swap or a sale — note it down, with a return promise or without.';
/// en: 'I gave seeds'
String get iGave => 'I gave seeds';
/// en: 'I received seeds'
String get iReceived => 'I received seeds';
/// en: 'Which batch?'
String get whichLot => 'Which batch?';
/// en: 'How much?'
String get howMuch => 'How much?';
/// en: 'All of it'
String get allOfIt => 'All of it';
/// en: 'A part'
String get partOfIt => 'A part';
/// en: 'Money changed hands'
String get paymentChip => 'Money changed hands';
/// en: 'They'll return me seed'
String get promiseGave => 'They\'ll return me seed';
/// en: 'I'll return seed'
String get promiseReceived => 'I\'ll return seed';
}
// Path: sale // Path: sale
class Translations$sale$en { class Translations$sale$en {
Translations$sale$en.internal(this._root); Translations$sale$en.internal(this._root);
@ -2718,6 +2761,17 @@ extension on Translations {
'plantare.openSection' => 'Open', 'plantare.openSection' => 'Open',
'plantare.settledSection' => 'Done', 'plantare.settledSection' => 'Done',
'plantare.removeConfirm' => 'Remove this commitment?', 'plantare.removeConfirm' => 'Remove this commitment?',
'handover.title' => 'Seeds changed hands',
'handover.help' => 'A gift, a swap or a sale — note it down, with a return promise or without.',
'handover.iGave' => 'I gave seeds',
'handover.iReceived' => 'I received seeds',
'handover.whichLot' => 'Which batch?',
'handover.howMuch' => 'How much?',
'handover.allOfIt' => 'All of it',
'handover.partOfIt' => 'A part',
'handover.paymentChip' => 'Money changed hands',
'handover.promiseGave' => 'They\'ll return me seed',
'handover.promiseReceived' => 'I\'ll return seed',
'sale.title' => 'Sales', 'sale.title' => 'Sales',
'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.', 'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.',
'sale.add' => 'Record a sale', 'sale.add' => 'Record a sale',

View file

@ -80,6 +80,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$wot$es wot = _Translations$wot$es._(_root); @override late final _Translations$wot$es wot = _Translations$wot$es._(_root);
@override late final _Translations$notifications$es notifications = _Translations$notifications$es._(_root); @override late final _Translations$notifications$es notifications = _Translations$notifications$es._(_root);
@override late final _Translations$plantare$es plantare = _Translations$plantare$es._(_root); @override late final _Translations$plantare$es plantare = _Translations$plantare$es._(_root);
@override late final _Translations$handover$es handover = _Translations$handover$es._(_root);
@override late final _Translations$sale$es sale = _Translations$sale$es._(_root); @override late final _Translations$sale$es sale = _Translations$sale$es._(_root);
} }
@ -899,6 +900,26 @@ class _Translations$plantare$es extends Translations$plantare$en {
@override String get removeConfirm => '¿Quitar este compromiso?'; @override String get removeConfirm => '¿Quitar este compromiso?';
} }
// Path: handover
class _Translations$handover$es extends Translations$handover$en {
_Translations$handover$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Di o recibí semillas';
@override String get help => 'Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.';
@override String get iGave => 'Di semillas';
@override String get iReceived => 'Recibí semillas';
@override String get whichLot => '¿De qué lote?';
@override String get howMuch => '¿Cuánto?';
@override String get allOfIt => 'Todo';
@override String get partOfIt => 'Una parte';
@override String get paymentChip => 'Hubo dinero por medio';
@override String get promiseGave => 'Me devolverán semilla';
@override String get promiseReceived => 'Devolveré semilla';
}
// Path: sale // Path: sale
class _Translations$sale$es extends Translations$sale$en { class _Translations$sale$es extends Translations$sale$en {
_Translations$sale$es._(TranslationsEs root) : this._root = root, super.internal(root); _Translations$sale$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -1744,6 +1765,17 @@ extension on TranslationsEs {
'plantare.openSection' => 'Pendientes', 'plantare.openSection' => 'Pendientes',
'plantare.settledSection' => 'Hechos', 'plantare.settledSection' => 'Hechos',
'plantare.removeConfirm' => '¿Quitar este compromiso?', 'plantare.removeConfirm' => '¿Quitar este compromiso?',
'handover.title' => 'Di o recibí semillas',
'handover.help' => 'Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.',
'handover.iGave' => 'Di semillas',
'handover.iReceived' => 'Recibí semillas',
'handover.whichLot' => '¿De qué lote?',
'handover.howMuch' => '¿Cuánto?',
'handover.allOfIt' => 'Todo',
'handover.partOfIt' => 'Una parte',
'handover.paymentChip' => 'Hubo dinero por medio',
'handover.promiseGave' => 'Me devolverán semilla',
'handover.promiseReceived' => 'Devolveré semilla',
'sale.title' => 'Ventas', 'sale.title' => 'Ventas',
'sale.help' => 'Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.', 'sale.help' => 'Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.',
'sale.add' => 'Registrar venta', 'sale.add' => 'Registrar venta',

View file

@ -80,6 +80,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$wot$pt wot = _Translations$wot$pt._(_root); @override late final _Translations$wot$pt wot = _Translations$wot$pt._(_root);
@override late final _Translations$notifications$pt notifications = _Translations$notifications$pt._(_root); @override late final _Translations$notifications$pt notifications = _Translations$notifications$pt._(_root);
@override late final _Translations$plantare$pt plantare = _Translations$plantare$pt._(_root); @override late final _Translations$plantare$pt plantare = _Translations$plantare$pt._(_root);
@override late final _Translations$handover$pt handover = _Translations$handover$pt._(_root);
@override late final _Translations$sale$pt sale = _Translations$sale$pt._(_root); @override late final _Translations$sale$pt sale = _Translations$sale$pt._(_root);
} }
@ -896,6 +897,26 @@ class _Translations$plantare$pt extends Translations$plantare$en {
@override String get removeConfirm => 'Remover este compromisso?'; @override String get removeConfirm => 'Remover este compromisso?';
} }
// Path: handover
class _Translations$handover$pt extends Translations$handover$en {
_Translations$handover$pt._(TranslationsPt root) : this._root = root, super.internal(root);
final TranslationsPt _root; // ignore: unused_field
// Translations
@override String get title => 'Dei ou recebi sementes';
@override String get help => 'Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.';
@override String get iGave => 'Dei sementes';
@override String get iReceived => 'Recebi sementes';
@override String get whichLot => 'De que lote?';
@override String get howMuch => 'Quanto?';
@override String get allOfIt => 'Tudo';
@override String get partOfIt => 'Uma parte';
@override String get paymentChip => 'Houve dinheiro pelo meio';
@override String get promiseGave => 'Vão devolver-me semente';
@override String get promiseReceived => 'Vou devolver semente';
}
// Path: sale // Path: sale
class _Translations$sale$pt extends Translations$sale$en { class _Translations$sale$pt extends Translations$sale$en {
_Translations$sale$pt._(TranslationsPt root) : this._root = root, super.internal(root); _Translations$sale$pt._(TranslationsPt root) : this._root = root, super.internal(root);
@ -1738,6 +1759,17 @@ extension on TranslationsPt {
'plantare.openSection' => 'Pendentes', 'plantare.openSection' => 'Pendentes',
'plantare.settledSection' => 'Feitos', 'plantare.settledSection' => 'Feitos',
'plantare.removeConfirm' => 'Remover este compromisso?', 'plantare.removeConfirm' => 'Remover este compromisso?',
'handover.title' => 'Dei ou recebi sementes',
'handover.help' => 'Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.',
'handover.iGave' => 'Dei sementes',
'handover.iReceived' => 'Recebi sementes',
'handover.whichLot' => 'De que lote?',
'handover.howMuch' => 'Quanto?',
'handover.allOfIt' => 'Tudo',
'handover.partOfIt' => 'Uma parte',
'handover.paymentChip' => 'Houve dinheiro pelo meio',
'handover.promiseGave' => 'Vão devolver-me semente',
'handover.promiseReceived' => 'Vou devolver semente',
'sale.title' => 'Vendas', 'sale.title' => 'Vendas',
'sale.help' => 'Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.', 'sale.help' => 'Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.',
'sale.add' => 'Registar venda', 'sale.add' => 'Registar venda',

View file

@ -0,0 +1,346 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../i18n/strings.g.dart';
import 'currency_quick_picks.dart';
import 'harvest_date_picker.dart';
import 'quantity_kind_l10n.dart';
import 'quantity_picker.dart';
import 'theme.dart';
/// Opens the hand-over sheet the ONE place to note that seeds changed hands
/// (a gift, a swap, a sale), optionally with money and/or a return promise.
/// It records the Movement and spawns the Sale/Plantare rows in one save
/// (data-model §2.8), so the person never juggles three separate forms.
///
/// [lots] are the variety's live batches: one → preselected; several → a
/// chip picker; none the quantity section hides and only the optional
/// records are saved. [initialLot] preselects a batch (entry from a lot row).
Future<void> showHandoverSheet(
BuildContext context, {
required VarietyRepository repository,
required String varietyId,
List<VarietyLot> lots = const [],
VarietyLot? initialLot,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _HandoverSheet(
repository: repository,
varietyId: varietyId,
lots: lots,
initialLot: initialLot ?? (lots.length == 1 ? lots.single : null),
),
);
}
class _HandoverSheet extends StatefulWidget {
const _HandoverSheet({
required this.repository,
required this.varietyId,
required this.lots,
this.initialLot,
});
final VarietyRepository repository;
final String varietyId;
final List<VarietyLot> lots;
final VarietyLot? initialLot;
@override
State<_HandoverSheet> createState() => _HandoverSheetState();
}
class _HandoverSheetState extends State<_HandoverSheet> {
HandoverDirection _direction = HandoverDirection.iGave;
VarietyLot? _lot;
bool _gaveAll = true;
Quantity? _partialQuantity;
final _counterparty = TextEditingController();
bool _withPayment = false;
final _amount = TextEditingController();
final _currency = TextEditingController();
bool _withPromise = false;
final _owed = TextEditingController();
bool _saving = false;
@override
void initState() {
super.initState();
_lot = widget.initialLot;
}
@override
void dispose() {
_counterparty.dispose();
_amount.dispose();
_currency.dispose();
_owed.dispose();
super.dispose();
}
String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim();
String _lotLabel(Translations t, VarietyLot lot) {
final parts = <String>[
if (lot.harvestYear != null)
harvestDateLabel(t, year: lot.harvestYear!, month: lot.harvestMonth)
else
t.detail.noYear,
if (lot.quantity != null) quantityDisplay(t, lot.quantity!),
];
return parts.join(' · ');
}
Future<void> _save() async {
setState(() => _saving = true);
final counterparty = _nullIfBlank(_counterparty.text);
final amount = double.tryParse(_amount.text.trim().replaceAll(',', '.'));
final currency = _nullIfBlank(_currency.text);
final owed = _nullIfBlank(_owed.text);
final lot = _lot;
if (lot != null) {
await widget.repository.recordHandover(
lotId: lot.id,
direction: _direction,
gaveAll: _gaveAll,
quantity: _gaveAll ? null : _partialQuantity,
counterparty: counterparty,
withPayment: _withPayment,
paymentAmount: amount,
paymentCurrency: currency,
withPromise: _withPromise,
promiseOwedDescription: owed,
);
} else {
// No batch to move record just the ledgers the person asked for.
if (_withPayment) {
await widget.repository.createSale(
direction: _direction == HandoverDirection.iGave
? SaleDirection.iSold
: SaleDirection.iBought,
varietyId: widget.varietyId,
counterparty: counterparty,
amount: amount,
currency: currency,
);
}
if (_withPromise) {
await widget.repository.createPlantare(
direction: _direction == HandoverDirection.iGave
? PlantareDirection.owedToMe
: PlantareDirection.iReturn,
varietyId: widget.varietyId,
counterparty: counterparty,
owedDescription: owed,
);
}
}
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final t = context.t;
final gave = _direction == HandoverDirection.iGave;
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.handover.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
t.handover.help,
style: const TextStyle(color: seedMuted, fontSize: 13),
),
const SizedBox(height: 12),
// Two big, human choices same idiom as the plantare sheet.
for (final d in HandoverDirection.values)
ListTile(
key: Key('handover.direction.${d.name}'),
contentPadding: EdgeInsets.zero,
leading: Icon(
_direction == d
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: _direction == d ? seedGreen : seedMuted,
),
title: Text(
d == HandoverDirection.iGave
? t.handover.iGave
: t.handover.iReceived,
),
onTap: () => setState(() => _direction = d),
),
if (widget.lots.length > 1) ...[
const SizedBox(height: 8),
Text(
t.handover.whichLot,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final lot in widget.lots)
ChoiceChip(
key: Key('handover.lot.${lot.id}'),
label: Text(_lotLabel(t, lot)),
selected: _lot?.id == lot.id,
onSelected: (sel) =>
setState(() => _lot = sel ? lot : null),
),
],
),
],
if (_lot != null && gave) ...[
const SizedBox(height: 8),
Text(
t.handover.howMuch,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ChoiceChip(
key: const Key('handover.allOfIt'),
label: Text(t.handover.allOfIt),
selected: _gaveAll,
onSelected: (sel) => setState(() => _gaveAll = true),
),
ChoiceChip(
key: const Key('handover.partOfIt'),
label: Text(t.handover.partOfIt),
selected: !_gaveAll,
onSelected: (sel) => setState(() => _gaveAll = false),
),
],
),
if (!_gaveAll) ...[
const SizedBox(height: 8),
QuantityPicker(
type: _lot!.type,
value: _partialQuantity,
onChanged: (q) => _partialQuantity = q,
),
],
],
const SizedBox(height: 12),
TextField(
key: const Key('handover.counterparty'),
controller: _counterparty,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
labelText: t.sale.counterparty,
helperText: t.sale.counterpartyHint,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
// Money and promise stay one tap away a plain gift needs neither.
Wrap(
spacing: 8,
children: [
if (!_withPayment)
ActionChip(
key: const Key('handover.addPayment'),
avatar: const Icon(Icons.sell_outlined, size: 18),
label: Text(t.handover.paymentChip),
onPressed: () => setState(() => _withPayment = true),
),
if (!_withPromise)
ActionChip(
key: const Key('handover.addPromise'),
avatar: const Icon(
Icons.volunteer_activism_outlined,
size: 18,
),
label: Text(
gave
? t.handover.promiseGave
: t.handover.promiseReceived,
),
onPressed: () => setState(() => _withPromise = true),
),
],
),
if (_withPayment) ...[
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
key: const Key('handover.amount'),
controller: _amount,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: t.sale.amount,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
key: const Key('handover.currency'),
controller: _currency,
decoration: InputDecoration(
labelText: t.sale.currency,
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
),
],
),
const SizedBox(height: 8),
CurrencyQuickPicks(
controller: _currency,
keyPrefix: 'handover',
onChanged: () => setState(() {}),
),
],
if (_withPromise) ...[
const SizedBox(height: 12),
TextField(
key: const Key('handover.owed'),
controller: _owed,
decoration: InputDecoration(
labelText: t.plantare.owed,
helperText: t.plantare.owedHint,
helperMaxLines: 2,
border: const OutlineInputBorder(),
),
),
],
const SizedBox(height: 20),
FilledButton(
key: const Key('handover.save'),
onPressed: _saving ? null : _save,
child: Text(t.common.save),
),
],
),
),
);
}
}

View file

@ -17,11 +17,10 @@ import '../domain/species_reference_links.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart'; import '../state/variety_detail_cubit.dart';
import 'currency_quick_picks.dart'; import 'currency_quick_picks.dart';
import 'handover_sheet.dart';
import 'harvest_date_picker.dart'; import 'harvest_date_picker.dart';
import 'photo_pick.dart'; import 'photo_pick.dart';
import 'plantare_sheet.dart';
import 'quantity_kind_l10n.dart'; import 'quantity_kind_l10n.dart';
import 'sale_sheet.dart';
import 'quantity_picker.dart'; import 'quantity_picker.dart';
import 'seed_glyph.dart'; import 'seed_glyph.dart';
import 'theme.dart'; import 'theme.dart';
@ -118,25 +117,19 @@ class _DetailView extends StatelessWidget {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ 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( OutlinedButton.icon(
key: const Key('detail.addPlantare'), key: const Key('detail.handover'),
icon: const Icon(Icons.volunteer_activism_outlined, size: 18), icon: const Icon(Icons.handshake_outlined, size: 18),
onPressed: () => showPlantareSheet( onPressed: () => showHandoverSheet(
context, context,
repository: context.read<VarietyRepository>(), repository: context.read<VarietyRepository>(),
varietyId: detail.id, varietyId: detail.id,
lots: detail.lots,
), ),
label: Text(t.plantare.add), label: Text(t.handover.title),
),
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),
), ),
], ],
), ),
@ -323,22 +316,32 @@ class _LotTile extends StatelessWidget {
?warning, ?warning,
], ],
), ),
// Germination only applies to seed lots, not to plantel. trailing: Row(
trailing: lot.type != LotType.seed mainAxisSize: MainAxisSize.min,
? null children: [
: Row( if (lot.type == LotType.seed && lot.latestGerminationRate != null)
mainAxisSize: MainAxisSize.min, _GerminationBadge(rate: lot.latestGerminationRate!),
children: [ IconButton(
if (lot.latestGerminationRate != null) key: Key('lot.handover.${lot.id}'),
_GerminationBadge(rate: lot.latestGerminationRate!), icon: const Icon(Icons.handshake_outlined),
IconButton( tooltip: t.handover.title,
key: Key('lot.germination.${lot.id}'), onPressed: () => showHandoverSheet(
icon: const Icon(Icons.grass_outlined), context,
tooltip: t.germination.title, repository: context.read<VarietyRepository>(),
onPressed: () => _showGerminationSheet(context, cubit, lot), 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, t.germination.title,
style: Theme.of(sheetContext).textTheme.titleLarge, style: Theme.of(sheetContext).textTheme.titleLarge,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (lot.germinationTests.isEmpty) if (lot.germinationTests.isEmpty)
Text(t.germination.none) Text(t.germination.none)
else else
for (final test in lot.germinationTests) for (final test in lot.germinationTests)
ListTile( ListTile(
dense: true, dense: true,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.grass_outlined), leading: const Icon(Icons.grass_outlined),
title: Text( title: Text(
test.rate == null ? '' : _germinationPercent(t, test.rate!), test.rate == null
? ''
: _germinationPercent(t, test.rate!),
),
subtitle:
(test.sampleSize != null && test.germinatedCount != null)
? Text('${test.germinatedCount}/${test.sampleSize}')
: null,
), ),
subtitle: const Divider(),
(test.sampleSize != null && test.germinatedCount != null) Row(
? Text('${test.germinatedCount}/${test.sampleSize}') children: [
: null, Expanded(
), child: TextField(
const Divider(), key: const Key('germination.germinated'),
Row( controller: germinated,
children: [ keyboardType: TextInputType.number,
Expanded( decoration: InputDecoration(
child: TextField( labelText: t.germination.germinated,
key: const Key('germination.germinated'), border: const OutlineInputBorder(
controller: germinated, borderRadius: BorderRadius.all(Radius.circular(8)),
keyboardType: TextInputType.number, ),
decoration: InputDecoration(
labelText: t.germination.germinated,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
), ),
), ),
), const SizedBox(width: 12),
const SizedBox(width: 12), Expanded(
Expanded( child: TextField(
child: TextField( key: const Key('germination.sampleSize'),
key: const Key('germination.sampleSize'), controller: sample,
controller: sample, keyboardType: TextInputType.number,
keyboardType: TextInputType.number, decoration: InputDecoration(
decoration: InputDecoration( labelText: t.germination.sampleSize,
labelText: t.germination.sampleSize, border: const OutlineInputBorder(
border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)),
borderRadius: BorderRadius.all(Radius.circular(8)), ),
), ),
), ),
), ),
), ],
], ),
), const SizedBox(height: 16),
const SizedBox(height: 16), Row(
Row( children: [
children: [ TextButton(
TextButton( onPressed: () => Navigator.of(sheetContext).pop(),
onPressed: () => Navigator.of(sheetContext).pop(), child: Text(t.common.cancel),
child: Text(t.common.cancel), ),
), const Spacer(),
const Spacer(), FilledButton(
FilledButton( key: const Key('germination.save'),
key: const Key('germination.save'), onPressed: () {
onPressed: () { cubit.addGerminationTest(
cubit.addGerminationTest( lotId: lot.id,
lotId: lot.id, testedOn: DateTime.now().millisecondsSinceEpoch,
testedOn: DateTime.now().millisecondsSinceEpoch, sampleSize: int.tryParse(sample.text.trim()),
sampleSize: int.tryParse(sample.text.trim()), germinatedCount: int.tryParse(germinated.text.trim()),
germinatedCount: int.tryParse(germinated.text.trim()), );
); Navigator.of(sheetContext).pop();
Navigator.of(sheetContext).pop(); },
}, child: Text(t.germination.add),
child: Text(t.germination.add), ),
), ],
], ),
), ],
], ),
),
), ),
), ),
); );
@ -1376,62 +1381,63 @@ Future<void> _showConditionSheet(
t.conditionCheck.title, t.conditionCheck.title,
style: Theme.of(sheetContext).textTheme.titleLarge, style: Theme.of(sheetContext).textTheme.titleLarge,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
key: const Key('conditionCheck.containers'), key: const Key('conditionCheck.containers'),
controller: containers, controller: containers,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
decoration: InputDecoration( decoration: InputDecoration(
labelText: t.conditionCheck.containers, labelText: t.conditionCheck.containers,
border: const OutlineInputBorder( border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
),
), ),
), ),
), const SizedBox(height: 16),
const SizedBox(height: 16), Text(
Text( t.conditionCheck.desiccant,
t.conditionCheck.desiccant, style: Theme.of(sheetContext).textTheme.labelLarge,
style: Theme.of(sheetContext).textTheme.labelLarge, ),
), const SizedBox(height: 8),
const SizedBox(height: 8), Wrap(
Wrap( spacing: 8,
spacing: 8, runSpacing: 4,
runSpacing: 4, children: [
children: [ for (final d in DesiccantState.values)
for (final d in DesiccantState.values) ChoiceChip(
ChoiceChip( key: Key('desiccant.${d.name}'),
key: Key('desiccant.${d.name}'), label: Text(desiccantLabel(t, d)),
label: Text(desiccantLabel(t, d)), selected: state == d,
selected: state == d, onSelected: (sel) =>
onSelected: (sel) => setState(() => state = sel ? d : null), setState(() => state = sel ? d : null),
),
],
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(t.common.cancel),
), ),
], const Spacer(),
), FilledButton(
const SizedBox(height: 16), key: const Key('conditionCheck.save'),
Row( onPressed: () {
children: [ cubit.addConditionCheck(
TextButton( lotId: lot.id,
onPressed: () => Navigator.of(sheetContext).pop(), checkedOn: DateTime.now().millisecondsSinceEpoch,
child: Text(t.common.cancel), containerCount: int.tryParse(containers.text.trim()),
), desiccantState: state,
const Spacer(), );
FilledButton( Navigator.of(sheetContext).pop();
key: const Key('conditionCheck.save'), },
onPressed: () { child: Text(t.conditionCheck.add),
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),
),
],
),
],
),
), ),
), ),
), ),

View file

@ -0,0 +1,143 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import '../support/test_support.dart';
void main() {
late AppDatabase db;
setUp(() => db = newTestDatabase());
tearDown(() => db.close());
testWidgets('the detail screen offers ONE hand-over door, no separate '
'sale/plantare buttons', (tester) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail.handover')), findsOneWidget);
expect(find.byKey(const Key('detail.addSale')), findsNothing);
expect(find.byKey(const Key('detail.addPlantare')), findsNothing);
await disposeTree(tester);
});
testWidgets('giving all with payment and promise records the three pieces '
'in one save', (tester) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
final lotId = await repo.addLot(
varietyId: id,
quantity: const Quantity(kind: QuantityKind.grams, count: 40),
offerStatus: OfferStatus.sell,
priceAmount: 3,
priceCurrency: '',
);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('detail.handover')));
await tester.pumpAndSettle();
// "All of it" is the default one tap covers the whole-plant case.
final allChip = tester.widget<ChoiceChip>(
find.byKey(const Key('handover.allOfIt')),
);
expect(allChip.selected, isTrue);
await tester.enterText(
find.byKey(const Key('handover.counterparty')),
'María',
);
await tester.tap(find.byKey(const Key('handover.addPayment')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('handover.amount')), '2,5');
await tester.tap(find.byKey(const Key('handover.currencyChip.Ğ1')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('handover.addPromise')));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('handover.owed')),
'un puñado',
);
await tester.ensureVisible(find.byKey(const Key('handover.save')));
await tester.tap(find.byKey(const Key('handover.save')));
await tester.pumpAndSettle();
final sales = await repo.watchSales().first;
expect(sales.single.direction, SaleDirection.iSold);
expect(sales.single.amount, 2.5);
expect(sales.single.currency, 'Ğ1');
expect(sales.single.counterparty, 'María');
final plantares = await repo.watchPlantares().first;
expect(plantares.single.direction, PlantareDirection.owedToMe);
expect(plantares.single.owedDescription, 'un puñado');
final lot = await (db.select(
db.lots,
)..where((l) => l.id.equals(lotId))).getSingle();
expect(lot.quantityPrecise, 0);
expect(lot.offerStatus, OfferStatus.private);
expect(lot.priceAmount, isNull);
final movements = await db.select(db.movements).get();
expect(movements.single.type, MovementType.given);
expect(movements.single.plantareId, plantares.single.id);
await disposeTree(tester);
});
testWidgets('receiving hides the how-much choice and mirrors directions', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
await repo.addLot(varietyId: id);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('detail.handover')));
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('handover.direction.iReceived')),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('handover.allOfIt')), findsNothing);
await tester.ensureVisible(find.byKey(const Key('handover.save')));
await tester.tap(find.byKey(const Key('handover.save')));
await tester.pumpAndSettle();
final movements = await db.select(db.movements).get();
expect(movements.single.type, MovementType.received);
await disposeTree(tester);
});
}