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 5ecdd1222a
commit 1103b6ce15
25 changed files with 4690 additions and 31 deletions

View file

@ -40,4 +40,71 @@ void main() {
final lots = await repo.shareableLots();
expect(lots.where((l) => l.lotId == privateId), isEmpty);
});
test('sell lots expose their asking price; other statuses never do',
() async {
final repo = newTestRepository(db);
final varietyId = await repo.addQuickVariety(label: 'Calabaza');
final sellId = await repo.addLot(
varietyId: varietyId,
offerStatus: OfferStatus.sell,
priceAmount: 3.5,
priceCurrency: 'Ğ1',
);
// Price passed on a gift lot is dropped, not stored.
final giftId = await repo.addLot(
varietyId: varietyId,
offerStatus: OfferStatus.shared,
priceAmount: 99,
priceCurrency: '',
);
final lots = await repo.shareableLots();
final sell = lots.singleWhere((l) => l.lotId == sellId);
expect(sell.priceAmount, 3.5);
expect(sell.priceCurrency, 'Ğ1');
final gift = lots.singleWhere((l) => l.lotId == giftId);
expect(gift.priceAmount, isNull);
expect(gift.priceCurrency, isNull);
});
test('a sell lot without amount is "to be agreed" — shareable, no price',
() async {
final repo = newTestRepository(db);
final varietyId = await repo.addQuickVariety(label: 'Lechuga');
final sellId = await repo.addLot(
varietyId: varietyId,
offerStatus: OfferStatus.sell,
);
final lots = await repo.shareableLots();
final sell = lots.singleWhere((l) => l.lotId == sellId);
expect(sell.priceAmount, isNull);
});
test('moving a lot out of sale clears its stored price', () async {
final repo = newTestRepository(db);
final varietyId = await repo.addQuickVariety(label: 'Haba');
final lotId = await repo.addLot(
varietyId: varietyId,
offerStatus: OfferStatus.sell,
priceAmount: 2,
priceCurrency: '',
);
await repo.updateLot(
lotId: lotId,
type: LotType.seed,
offerStatus: OfferStatus.shared,
// Price args still sent by the form; must be ignored off-sale.
priceAmount: 2,
priceCurrency: '',
);
final detail = await repo.watchVariety(varietyId).first;
final lot = detail!.lots.singleWhere((l) => l.id == lotId);
expect(lot.priceAmount, isNull);
expect(lot.priceCurrency, isNull);
expect(lot.offerStatus, OfferStatus.shared);
});
}

View file

@ -11,19 +11,19 @@ void main() {
verifier = SchemaVerifier(GeneratedHelper());
});
test('freshly created database matches the exported schema v10', () async {
final schema = await verifier.schemaAt(10);
test('freshly created database matches the exported schema v11', () async {
final schema = await verifier.schemaAt(11);
final db = AppDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, 10);
await verifier.migrateAndValidate(db, 11);
await db.close();
});
// Every historical version upgrades cleanly to the current schema (v10).
for (var from = 1; from <= 9; from++) {
test('upgrades v$from → v10 and matches the fresh schema', () async {
// Every historical version upgrades cleanly to the current schema (v11).
for (var from = 1; from <= 10; from++) {
test('upgrades v$from → v11 and matches the fresh schema', () async {
final connection = await verifier.startAt(from);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 10);
await verifier.migrateAndValidate(db, 11);
await db.close();
});
}

View file

@ -14,6 +14,7 @@ import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -39,10 +40,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v9.DatabaseAtV9(db);
case 10:
return v10.DatabaseAtV10(db);
case 11:
return v11.DatabaseAtV11(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
}

File diff suppressed because it is too large Load diff

View file

@ -294,6 +294,43 @@ void main() {
await cubit.close();
});
test('publishLots carries the lot asking price on sale offers', () async {
final transport = FakeOfferTransport();
final cubit = OffersCubit(transport);
await cubit.publishLots(
const [
ShareableLot(
lotId: 'lot-sale',
varietyId: 'var-1',
summary: 'Tomate',
offerStatus: OfferStatus.sell,
priceAmount: 3.5,
priceCurrency: 'Ğ1',
),
// "To be agreed": sale without a figure publishes without a price.
ShareableLot(
lotId: 'lot-negotiable',
varietyId: 'var-2',
summary: 'Judía',
offerStatus: OfferStatus.sell,
),
],
authorPubkeyHex: 'ab' * 32,
areaGeohash: 'sp3e9',
);
await cubit.discover('sp3');
await pumpEventQueue();
final sale = cubit.state.offers.singleWhere((o) => o.id == 'lot-sale');
expect(sale.type, OfferType.sale);
expect(sale.priceAmount, 3.5);
expect(sale.priceCurrency, 'Ğ1');
final negotiable =
cubit.state.offers.singleWhere((o) => o.id == 'lot-negotiable');
expect(negotiable.priceAmount, isNull);
await cubit.close();
});
test('publishLots embeds an inline thumbnail data-URI on the offer',
() async {
final transport = FakeOfferTransport();