'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.
346 lines
12 KiB
Dart
346 lines
12 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|