tane/apps/app_seeds/lib/db/database.dart
vjrj e2665c27d4 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.
2026-07-11 02:33:42 +02:00

178 lines
5.8 KiB
Dart

import 'package:drift/drift.dart';
import 'enums.dart';
import 'tables.dart';
part 'database.g.dart';
/// The encrypted local inventory database (SQLCipher via an injected executor).
///
/// The executor is passed in so production wires SQLCipher while tests can use
/// an in-memory database — the schema and queries are identical either way.
@DriftDatabase(
tables: [
Varieties,
VarietyVernacularNames,
Species,
SpeciesCommonNames,
Lots,
GerminationTests,
ConditionChecks,
Movements,
Parties,
Attachments,
ExternalLinks,
Plantares,
Sales,
],
)
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
/// Current schema version; also stamped into interchange exports so an
/// importer knows which app generation wrote the file (data-model §7).
static const int currentSchemaVersion = 10;
@override
int get schemaVersion => currentSchemaVersion;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async => m.createAll(),
onUpgrade: (m, from, to) async {
// v2: lots can hold seeds or living plant material.
if (from < 2) {
await m.addColumn(lots, lots.type);
}
// v3: optional harvest month alongside the harvest year.
if (from < 3) {
await m.addColumn(lots, lots.harvestMonth);
}
// v4: optional packaging/supply form for living lots.
if (from < 4) {
await m.addColumn(lots, lots.presentation);
}
// v5: display order for attachments (cover photo = lowest sortOrder).
if (from < 5) {
await m.addColumn(attachments, attachments.sortOrder);
}
// v6: photo-first draft varieties awaiting a name ("to catalogue" tray).
if (from < 6) {
await m.addColumn(varieties, varieties.isDraft);
}
// v7: organic ("eco") self-declaration on varieties; per-species seed
// viability (years) reference data for expiry warnings. Guarded by a
// column-existence check so a dev database left half-migrated (column
// added but user_version not bumped) re-runs cleanly instead of failing
// on "duplicate column".
if (from < 7) {
if (!await _hasColumn('varieties', 'is_organic')) {
await m.addColumn(varieties, varieties.isOrganic);
}
if (!await _hasColumn('species', 'viability_years')) {
await m.addColumn(species, species.viabilityYears);
}
}
// v8: absorbs fields from a real seed-bank inventory. Varieties gain a
// "needs reproducing" intent and an advisory crop calendar; Lots gain
// provenance (origin name/place), a qualitative abundance level and a
// preservation format; a new ConditionChecks table logs container count +
// drying-agent state. Every step is guarded so a half-migrated dev
// database re-runs cleanly (see the v7 note above).
if (from < 8) {
Future<void> addIfMissing(
String table,
String column,
GeneratedColumn<Object> definition,
TableInfo<Table, dynamic> tableInfo,
) async {
if (!await _hasColumn(table, column)) {
await m.addColumn(tableInfo, definition);
}
}
await addIfMissing(
'varieties',
'needs_reproduction',
varieties.needsReproduction,
varieties,
);
await addIfMissing(
'varieties',
'sow_months',
varieties.sowMonths,
varieties,
);
await addIfMissing(
'varieties',
'transplant_months',
varieties.transplantMonths,
varieties,
);
await addIfMissing(
'varieties',
'flowering_months',
varieties.floweringMonths,
varieties,
);
await addIfMissing(
'varieties',
'fruiting_months',
varieties.fruitingMonths,
varieties,
);
await addIfMissing(
'varieties',
'seed_harvest_months',
varieties.seedHarvestMonths,
varieties,
);
await addIfMissing('lots', 'origin_name', lots.originName, lots);
await addIfMissing('lots', 'origin_place', lots.originPlace, lots);
await addIfMissing('lots', 'abundance', lots.abundance, lots);
await addIfMissing(
'lots',
'preservation_format',
lots.preservationFormat,
lots,
);
if (!await _hasTable('condition_checks')) {
await m.createTable(conditionChecks);
}
}
// v9: Plantares — reproduction commitments (data-model §2.7). Guarded so a
// half-migrated dev database re-runs cleanly (see the v7 note above).
if (from < 9) {
if (!await _hasTable('plantares')) {
await m.createTable(plantares);
}
}
// v10: Sales — recorded seed sales (separate from gift/Plantare). Guarded.
if (from < 10) {
if (!await _hasTable('sales')) {
await m.createTable(sales);
}
}
},
);
/// Whether a table named [table] already exists. Keeps the additive v8
/// table creation idempotent against partially-migrated databases.
Future<bool> _hasTable(String table) async {
final rows = await customSelect(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
variables: [Variable.withString(table)],
).get();
return rows.isNotEmpty;
}
/// Whether [table] already has a column named [column]. Used to keep additive
/// migrations idempotent against partially-migrated databases.
Future<bool> _hasColumn(String table, String column) async {
final rows = await customSelect(
'SELECT 1 FROM pragma_table_info(?) WHERE name = ?',
variables: [Variable.withString(table), Variable.withString(column)],
).get();
return rows.isNotEmpty;
}
}