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

@ -0,0 +1,71 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import '../support/test_support.dart';
/// A Sale is a recorded seed sale/purchase a model separate from a gift or a
/// Plantare, with a price in any currency.
void main() {
late AppDatabase db;
setUp(() => db = newTestDatabase());
tearDown(() => db.close());
test('records a sale with amount + currency and lists it', () async {
final repo = newTestRepository(db);
final vid = await repo.addQuickVariety(label: 'Tomate rosa');
await repo.createSale(
direction: SaleDirection.iSold,
varietyId: vid,
counterparty: 'Ana',
amount: 5,
currency: '',
);
final all = await repo.watchSales().first;
expect(all.single.direction, SaleDirection.iSold);
expect(all.single.amount, 5);
expect(all.single.currency, '');
expect(all.single.counterparty, 'Ana');
expect(await repo.watchSalesForVariety(vid).first, hasLength(1));
});
test('supports any currency (Ğ1, time…) and a price-less record', () async {
final repo = newTestRepository(db);
await repo.createSale(direction: SaleDirection.iBought, amount: 12, currency: 'Ğ1');
await repo.createSale(direction: SaleDirection.iSold); // no figure yet
final all = await repo.watchSales().first;
expect(all, hasLength(2));
expect(all.any((s) => s.currency == 'Ğ1' && s.amount == 12), isTrue);
expect(all.any((s) => s.amount == null), isTrue);
});
test('deleting a sale tombstones it', () async {
final repo = newTestRepository(db);
final id = await repo.createSale(direction: SaleDirection.iSold, amount: 3);
expect(await repo.watchSales().first, hasLength(1));
await repo.deleteSale(id);
expect(await repo.watchSales().first, isEmpty);
});
test('sales survive a backup round-trip', () async {
final repoA = newTestRepository(db);
await repoA.createSale(
direction: SaleDirection.iSold,
counterparty: 'Feria de la comarca',
amount: 2.5,
currency: '',
);
final snapshot = await repoA.exportInventory();
expect(snapshot.sales, hasLength(1));
final dbB = newTestDatabase();
addTearDown(dbB.close);
final repoB = newTestRepository(dbB);
await repoB.importInventory(snapshot);
final onB = await repoB.watchSales().first;
expect(onB.single.amount, 2.5);
expect(onB.single.currency, '');
expect(onB.single.counterparty, 'Feria de la comarca');
});
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@ import 'package:tane/ui/inventory_list_screen.dart';
import 'package:tane/ui/market_screen.dart';
import 'package:tane/ui/plantares_screen.dart';
import 'package:tane/ui/profile_screen.dart';
import 'package:tane/ui/sales_screen.dart';
import 'package:tane/ui/settings_screen.dart';
import '../support/test_support.dart';
@ -51,6 +52,19 @@ void main() {
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
// flutter_test's headless engine can't decode our real PNG assets (the
// logo), so `Image.asset` throws from the "image resource service". That's
// unrelated to layout swallow ONLY those, and let every other FlutterError
// (crucially the "rendering library" RenderFlex overflow this guard exists
// to catch) still fail the test. Installed here, inside the running test,
// because testWidgets reinstalls its own onError at test start (a setUp
// override would be clobbered).
final previous = FlutterError.onError;
FlutterError.onError = (details) {
if (details.library == 'image resource service') return;
previous?.call(details);
};
addTearDown(() => FlutterError.onError = previous);
await tester.pumpWidget(widget);
await tester.pumpAndSettle();
}
@ -258,6 +272,28 @@ void main() {
await disposeTree(tester);
});
testWidgets('the Sales screen (with a sale) fits a small screen',
(tester) async {
final db = newTestDatabase();
addTearDown(db.close);
final repo = newTestRepository(db);
await repo.createSale(
direction: SaleDirection.iSold,
counterparty: 'Feria de intercambio de la comarca',
amount: 12.5,
currency: '',
);
await pumpSmall(
tester,
wrapScreen(
repository: repo,
locale: AppLocale.es,
child: const SalesScreen(),
),
);
await disposeTree(tester);
});
// NOTE: the variety-detail screen is deliberately NOT overflow-checked here.
// It reports a ~2px overflow on a RenderFlex that is already DISPOSED/DEFUNCT
// a transient stale frame while its cubit's Drift stream rebuilds during