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

@ -79,6 +79,8 @@ class InventoryJsonCodec {
'originPlace': l.originPlace,
'abundance': l.abundance?.name,
'preservationFormat': l.preservationFormat?.name,
'priceAmount': l.priceAmount,
'priceCurrency': l.priceCurrency,
},
],
'vernacularNames': [
@ -338,6 +340,8 @@ class InventoryJsonCodec {
PreservationFormat.values,
m['preservationFormat'],
),
priceAmount: (m['priceAmount'] as num?)?.toDouble(),
priceCurrency: m['priceCurrency'] as String?,
);
}),
vernacularNames: _rows(root, 'vernacularNames', (m) {

View file

@ -226,6 +226,8 @@ class ShareableLot extends Equatable {
this.category,
this.harvestYear,
this.isOrganic = false,
this.priceAmount,
this.priceCurrency,
});
final String lotId;
@ -246,9 +248,23 @@ class ShareableLot extends Equatable {
/// filter the market for it.
final bool isOrganic;
/// Asking price, only populated for [OfferStatus.sell] lots. Null amount on
/// a sale means "price to be agreed" the offer publishes without a price.
final double? priceAmount;
final String? priceCurrency;
@override
List<Object?> get props =>
[lotId, varietyId, summary, offerStatus, category, harvestYear, isOrganic];
List<Object?> get props => [
lotId,
varietyId,
summary,
offerStatus,
category,
harvestYear,
isOrganic,
priceAmount,
priceCurrency,
];
}
/// One germination test on a lot; [rate] is derived (0..1), null when it can't
@ -322,6 +338,8 @@ class VarietyLot extends Equatable {
this.abundance,
this.preservationFormat,
this.offerStatus = OfferStatus.private,
this.priceAmount,
this.priceCurrency,
this.germinationTests = const [],
this.conditionChecks = const [],
});
@ -354,6 +372,11 @@ class VarietyLot extends Equatable {
/// the network is Block 2.
final OfferStatus offerStatus;
/// Asking price, meaningful when [offerStatus] is `sell`. Null amount means
/// "price to be agreed".
final double? priceAmount;
final String? priceCurrency;
final List<GerminationEntry> germinationTests;
/// Storage-condition checks, most-recent first (`conditionChecks.first` = last).
@ -377,6 +400,8 @@ class VarietyLot extends Equatable {
abundance,
preservationFormat,
offerStatus,
priceAmount,
priceCurrency,
germinationTests,
conditionChecks,
];
@ -1281,9 +1306,12 @@ class VarietyRepository {
Abundance? abundance,
PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private,
double? priceAmount,
String? priceCurrency,
}) async {
final (created, updated) = await _stamp();
final id = idGen.newId();
final forSale = offerStatus == OfferStatus.sell;
await _db
.into(_db.lots)
.insert(
@ -1306,6 +1334,8 @@ class VarietyRepository {
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
offerStatus: Value(offerStatus),
priceAmount: Value(forSale ? priceAmount : null),
priceCurrency: Value(forSale ? priceCurrency : null),
),
);
return id;
@ -1326,8 +1356,13 @@ class VarietyRepository {
Abundance? abundance,
PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private,
double? priceAmount,
String? priceCurrency,
}) async {
final (_, updated) = await _stamp();
// Price only makes sense on a lot offered for sale; clearing the sale
// status clears the price with it.
final forSale = offerStatus == OfferStatus.sell;
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
LotsCompanion(
type: Value(type),
@ -1343,6 +1378,8 @@ class VarietyRepository {
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
offerStatus: Value(offerStatus),
priceAmount: Value(forSale ? priceAmount : null),
priceCurrency: Value(forSale ? priceCurrency : null),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
@ -1580,6 +1617,12 @@ class VarietyRepository {
category: v.category,
harvestYear: l.harvestYear,
isOrganic: v.isOrganic,
priceAmount: l.offerStatus == OfferStatus.sell
? l.priceAmount
: null,
priceCurrency: l.offerStatus == OfferStatus.sell
? l.priceCurrency
: null,
),
];
}
@ -2363,6 +2406,8 @@ class VarietyRepository {
abundance: l.abundance,
preservationFormat: l.preservationFormat,
offerStatus: l.offerStatus,
priceAmount: l.priceAmount,
priceCurrency: l.priceCurrency,
germinationTests: germinationTests,
conditionChecks: conditionChecks,
quantity: hasQuantity