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:
parent
de6938d5d7
commit
6de039d518
27 changed files with 6156 additions and 23 deletions
71
apps/app_seeds/test/data/sales_test.dart
Normal file
71
apps/app_seeds/test/data/sales_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
1997
apps/app_seeds/test/db/schema/schema_v10.dart
Normal file
1997
apps/app_seeds/test/db/schema/schema_v10.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue