feat(sales): local sales/purchase ledger (any currency)

A sale is a distinct model from a gift or a Plantare (reproduction
commitment): a recorded seed sale or purchase with an optional price in
ANY currency — €, Ğ1, time, or none yet. Mirrors the Plantare feature.

- schema v10: Sales table (SyncColumns), guarded createTable migration,
  schema dump + generated schema_v10 for the migration round-trip test
- enum SaleDirection { iSold, iBought }
- VarietyRepository: create/watch/watchForVariety/delete + backup
  export/exportForSync/import (LWW-by-HLC, tombstones)
- InventorySnapshot.sales + JSON codec encode/decode (round-trips amount,
  currency, counterparty)
- UI: SalesScreen (/sales), sale sheet, drawer entry, variety-detail
  action beside 'add Plantare'; money hides a trailing .0
- i18n sale block + menu.sales in en/es/pt/ast
- tests: sales repo test (5) incl. Ğ1/price-less/backup round-trip;
  Sales screen added to the small-screen overflow guard

Also harden the overflow guard: swallow the headless engine's image
resource service PNG-decode errors (unrelated to layout) while still
failing on real RenderFlex overflows, so home/about stop reporting
false failures.
This commit is contained in:
vjrj 2026-07-11 02:33:42 +02:00
parent de6938d5d7
commit 6de039d518
27 changed files with 6156 additions and 23 deletions

View file

@ -230,6 +230,26 @@ class InventoryJsonCodec {
'note': p.note,
},
],
'sales': [
for (final s in snapshot.sales)
{
..._syncMeta(
id: s.id,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
lastAuthor: s.lastAuthor,
schemaRowVersion: s.schemaRowVersion,
isDeleted: s.isDeleted,
),
'varietyId': s.varietyId,
'direction': s.direction.name,
'counterparty': s.counterparty,
'amount': s.amount,
'currency': s.currency,
'soldOn': s.soldOn,
'note': s.note,
},
],
});
}
@ -462,6 +482,24 @@ class InventoryJsonCodec {
note: m['note'] as String?,
);
}),
sales: _rows(root, 'sales', (m) {
return Sale(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
varietyId: m['varietyId'] as String?,
direction:
_enumOr(SaleDirection.values, m['direction'], SaleDirection.iSold),
counterparty: m['counterparty'] as String?,
amount: (m['amount'] as num?)?.toDouble(),
currency: m['currency'] as String?,
soldOn: _int(m, 'soldOn'),
note: m['note'] as String?,
);
}),
);
}

View file

@ -31,6 +31,7 @@ class InventorySnapshot {
this.parties = const [],
this.attachments = const [],
this.plantares = const [],
this.sales = const [],
this.speciesNamesById = const {},
});
@ -44,6 +45,7 @@ class InventorySnapshot {
final List<Party> parties;
final List<Attachment> attachments;
final List<Plantare> plantares;
final List<Sale> sales;
/// speciesId scientificName, for the species referenced by [varieties].
final Map<String, String> speciesNamesById;