diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 456c10d..d9598f0 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -619,6 +619,19 @@ "settledSection": "Fechos", "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": { "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.", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 2d57d70..6ac5db6 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -622,6 +622,19 @@ "settledSection": "Done", "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": { "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.", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 4da41b5..def88b4 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -621,6 +621,19 @@ "settledSection": "Hechos", "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": { "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.", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 43adb5b..b692cbe 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -618,6 +618,19 @@ "settledSection": "Feitos", "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": { "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.", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 8a2d815..2e9ba80 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1976 (494 per locale) +/// Strings: 2020 (505 per locale) /// /// Built on 2026-07-11 at 06:00 UTC diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 1da3ad9..e082411 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -80,6 +80,7 @@ class TranslationsAst extends Translations with BaseTranslations '¿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 class _Translations$sale$ast extends Translations$sale$en { _Translations$sale$ast._(TranslationsAst root) : this._root = root, super.internal(root); @@ -1740,6 +1761,17 @@ extension on TranslationsAst { 'plantare.openSection' => 'Pendientes', 'plantare.settledSection' => 'Fechos', '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.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', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 732d86b..08da39e 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -81,6 +81,7 @@ class Translations with BaseTranslations { 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$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); } @@ -1722,6 +1723,48 @@ class Translations$plantare$en { 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 class Translations$sale$en { Translations$sale$en.internal(this._root); @@ -2718,6 +2761,17 @@ extension on Translations { 'plantare.openSection' => 'Open', 'plantare.settledSection' => 'Done', '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.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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 25e7797..f339ab5 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -80,6 +80,7 @@ class TranslationsEs extends Translations with BaseTranslations '¿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 class _Translations$sale$es extends Translations$sale$en { _Translations$sale$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1744,6 +1765,17 @@ extension on TranslationsEs { 'plantare.openSection' => 'Pendientes', 'plantare.settledSection' => 'Hechos', '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.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', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 5800443..6201d1d 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -80,6 +80,7 @@ class TranslationsPt extends Translations with BaseTranslations '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 class _Translations$sale$pt extends Translations$sale$en { _Translations$sale$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1738,6 +1759,17 @@ extension on TranslationsPt { 'plantare.openSection' => 'Pendentes', 'plantare.settledSection' => 'Feitos', '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.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', diff --git a/apps/app_seeds/lib/ui/handover_sheet.dart b/apps/app_seeds/lib/ui/handover_sheet.dart new file mode 100644 index 0000000..36c9440 --- /dev/null +++ b/apps/app_seeds/lib/ui/handover_sheet.dart @@ -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 showHandoverSheet( + BuildContext context, { + required VarietyRepository repository, + required String varietyId, + List lots = const [], + VarietyLot? initialLot, +}) { + return showModalBottomSheet( + 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 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 = [ + 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 _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), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 8303b00..3478bf9 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -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(), 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(), - 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(), + 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 _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 _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), + ), + ], + ), + ], + ), ), ), ), diff --git a/apps/app_seeds/test/ui/handover_sheet_test.dart b/apps/app_seeds/test/ui/handover_sheet_test.dart new file mode 100644 index 0000000..d47f1cf --- /dev/null +++ b/apps/app_seeds/test/ui/handover_sheet_test.dart @@ -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( + 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); + }); +}