feat(offers): asking price on lots, published with sale offers (schema v11)

The data model always meant sale offers to carry an informational price
(data-model §2.8, sharing-model §4.4); with offer state collapsed onto
the Lot, the price lives there. Null amount on a sell lot = 'to be
agreed' — the NIP-99 event simply omits the price tag.

- schema v11: Lots.price_amount/price_currency, guarded migration,
  schema dump + generated schema_v11 for the round-trip test
- ShareableLot/VarietyLot carry the price (sell-only in shareableLots);
  addLot/updateLot persist it and clear it when the lot leaves sale
- OffersCubit.publishLots forwards it (outbox flush picks it up free)
- lot sheet: price + currency inputs revealed only on 'For sale', with
  the €/Ğ1/hours quick-picks extracted to a shared CurrencyQuickPicks
  widget (reused by the sale sheet)
- backup JSON round-trips the new columns
- i18n share.price/priceHint in en/es/pt/ast
This commit is contained in:
vjrj 2026-07-11 07:24:23 +02:00
parent 89addb1ed7
commit cfa1053842
25 changed files with 4690 additions and 31 deletions

View file

@ -16,6 +16,7 @@ import '../domain/seed_viability.dart';
import '../domain/species_reference_links.dart';
import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart';
import 'currency_quick_picks.dart';
import 'harvest_date_picker.dart';
import 'photo_pick.dart';
import 'plantare_sheet.dart';
@ -1464,6 +1465,18 @@ Future<void> _showLotSheet(
existing?.originName != null || existing?.originPlace != null;
var showAbundance = existing?.abundance != null;
var showShare = selectedShare != OfferStatus.private;
final existingPrice = existing?.priceAmount;
final priceAmountCtrl = TextEditingController(
// Drop a trailing .0 so the field shows "5", not "5.0".
text: existingPrice == null
? ''
: (existingPrice == existingPrice.roundToDouble()
? existingPrice.toInt().toString()
: existingPrice.toString()),
);
final priceCurrencyCtrl = TextEditingController(
text: existing?.priceCurrency ?? '',
);
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
@ -1660,6 +1673,49 @@ Future<void> _showLotSheet(
style: Theme.of(sheetContext).textTheme.bodySmall,
),
),
// Price only when selling informational, agreed and
// paid off-app. Empty amount = "to be agreed".
if (selectedShare == OfferStatus.sell) ...[
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
key: const Key('lot.priceAmount'),
controller: priceAmountCtrl,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: t.share.price,
helperText: t.share.priceHint,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
key: const Key('lot.priceCurrency'),
controller: priceCurrencyCtrl,
decoration: InputDecoration(
labelText: t.sale.currency,
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
),
],
),
const SizedBox(height: 8),
CurrencyQuickPicks(
controller: priceCurrencyCtrl,
keyPrefix: 'lot',
onChanged: () => setState(() {}),
),
],
],
// Layer 2: advanced seed-bank details, collapsed by default.
// Only for seed lots, and (condition checks) an existing lot.
@ -1710,6 +1766,10 @@ Future<void> _showLotSheet(
onPressed: () {
final originName = _nullIfBlank(originNameCtrl.text);
final originPlace = _nullIfBlank(originPlaceCtrl.text);
final priceAmount = double.tryParse(
priceAmountCtrl.text.replaceAll(',', '.').trim(),
);
final priceCurrency = _nullIfBlank(priceCurrencyCtrl.text);
if (editing) {
cubit.updateLot(
lotId: existing.id,
@ -1723,6 +1783,8 @@ Future<void> _showLotSheet(
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
offerStatus: selectedShare,
priceAmount: priceAmount,
priceCurrency: priceCurrency,
);
} else {
cubit.addLot(
@ -1736,6 +1798,8 @@ Future<void> _showLotSheet(
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
offerStatus: selectedShare,
priceAmount: priceAmount,
priceCurrency: priceCurrency,
);
}
Navigator.of(sheetContext).pop();