From 1103b6ce15e9dd85b09b374ba2371bd65e4f3107 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 11 Jul 2026 07:24:23 +0200 Subject: [PATCH 1/3] feat(offers): asking price on lots, published with sale offers (schema v11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../drift_schemas/drift_schema_v11.json | 2171 +++++++++++++++++ .../export_import/inventory_json_codec.dart | 4 + .../lib/data/variety_repository.dart | 49 +- apps/app_seeds/lib/db/database.dart | 12 +- apps/app_seeds/lib/db/database.g.dart | 165 +- apps/app_seeds/lib/db/tables.dart | 11 + apps/app_seeds/lib/i18n/ast.i18n.json | 2 + apps/app_seeds/lib/i18n/en.i18n.json | 2 + apps/app_seeds/lib/i18n/es.i18n.json | 2 + apps/app_seeds/lib/i18n/pt.i18n.json | 2 + apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 4 + apps/app_seeds/lib/i18n/strings_en.g.dart | 8 + apps/app_seeds/lib/i18n/strings_es.g.dart | 4 + apps/app_seeds/lib/i18n/strings_pt.g.dart | 4 + apps/app_seeds/lib/state/offers_cubit.dart | 2 + .../lib/state/variety_detail_cubit.dart | 8 + .../lib/ui/currency_quick_picks.dart | 44 + apps/app_seeds/lib/ui/sale_sheet.dart | 21 +- .../lib/ui/variety_detail_screen.dart | 64 + .../test/data/shareable_lots_test.dart | 67 + apps/app_seeds/test/db/migration_test.dart | 14 +- apps/app_seeds/test/db/schema/schema.dart | 5 +- apps/app_seeds/test/db/schema/schema_v11.dart | 2015 +++++++++++++++ .../test/state/offers_cubit_test.dart | 37 + 25 files changed, 4690 insertions(+), 31 deletions(-) create mode 100644 apps/app_seeds/drift_schemas/drift_schema_v11.json create mode 100644 apps/app_seeds/lib/ui/currency_quick_picks.dart create mode 100644 apps/app_seeds/test/db/schema/schema_v11.dart diff --git a/apps/app_seeds/drift_schemas/drift_schema_v11.json b/apps/app_seeds/drift_schemas/drift_schema_v11.json new file mode 100644 index 0000000..82170fa --- /dev/null +++ b/apps/app_seeds/drift_schemas/drift_schema_v11.json @@ -0,0 +1,2171 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "varieties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "label", + "getter_name": "label", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "cultivar_name", + "getter_name": "cultivarName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "category", + "getter_name": "category", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_draft", + "getter_name": "isDraft", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_draft\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_draft\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_organic", + "getter_name": "isOrganic", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_organic\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_organic\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "needs_reproduction", + "getter_name": "needsReproduction", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"needs_reproduction\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"needs_reproduction\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sow_months", + "getter_name": "sowMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "transplant_months", + "getter_name": "transplantMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "flowering_months", + "getter_name": "floweringMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "fruiting_months", + "getter_name": "fruitingMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "seed_harvest_months", + "getter_name": "seedHarvestMonths", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "variety_vernacular_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "region", + "getter_name": "region", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [], + "type": "table", + "data": { + "name": "species", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "scientific_name", + "getter_name": "scientificName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "wikidata_qid", + "getter_name": "wikidataQid", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "gbif_key", + "getter_name": "gbifKey", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "family", + "getter_name": "family", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_bundled", + "getter_name": "isBundled", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_bundled\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_bundled\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "viability_years", + "getter_name": "viabilityYears", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 3, + "references": [], + "type": "table", + "data": { + "name": "species_common_names", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "species_id", + "getter_name": "speciesId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "name", + "getter_name": "name", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "language", + "getter_name": "language", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 4, + "references": [], + "type": "table", + "data": { + "name": "lots", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'seed\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(LotType.values)", + "dart_type_name": "LotType" + } + }, + { + "name": "harvest_year", + "getter_name": "harvestYear", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "harvest_month", + "getter_name": "harvestMonth", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "presentation", + "getter_name": "presentation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Presentation.values)", + "dart_type_name": "Presentation" + } + }, + { + "name": "storage_location", + "getter_name": "storageLocation", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "offer_status", + "getter_name": "offerStatus", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'private\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(OfferStatus.values)", + "dart_type_name": "OfferStatus" + } + }, + { + "name": "seedbank_id", + "getter_name": "seedbankId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_name", + "getter_name": "originName", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "origin_place", + "getter_name": "originPlace", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "abundance", + "getter_name": "abundance", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(Abundance.values)", + "dart_type_name": "Abundance" + } + }, + { + "name": "preservation_format", + "getter_name": "preservationFormat", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PreservationFormat.values)", + "dart_type_name": "PreservationFormat" + } + }, + { + "name": "price_amount", + "getter_name": "priceAmount", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "price_currency", + "getter_name": "priceCurrency", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 5, + "references": [], + "type": "table", + "data": { + "name": "germination_tests", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "tested_on", + "getter_name": "testedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sample_size", + "getter_name": "sampleSize", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "germinated_count", + "getter_name": "germinatedCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 6, + "references": [], + "type": "table", + "data": { + "name": "condition_checks", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "checked_on", + "getter_name": "checkedOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "container_count", + "getter_name": "containerCount", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "desiccant_state", + "getter_name": "desiccantState", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(DesiccantState.values)", + "dart_type_name": "DesiccantState" + } + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 7, + "references": [], + "type": "table", + "data": { + "name": "movements", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "lot_id", + "getter_name": "lotId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "type", + "getter_name": "type", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(MovementType.values)", + "dart_type_name": "MovementType" + } + }, + { + "name": "occurred_on", + "getter_name": "occurredOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "counterparty_id", + "getter_name": "counterpartyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_kind", + "getter_name": "quantityKind", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_precise", + "getter_name": "quantityPrecise", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "quantity_label", + "getter_name": "quantityLabel", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_movement_id", + "getter_name": "parentMovementId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "plantare_id", + "getter_name": "plantareId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "notes", + "getter_name": "notes", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 8, + "references": [], + "type": "table", + "data": { + "name": "parties", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "display_name", + "getter_name": "displayName", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "public_key", + "getter_name": "publicKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'person\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PartyKind.values)", + "dart_type_name": "PartyKind" + } + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 9, + "references": [], + "type": "table", + "data": { + "name": "attachments", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "kind", + "getter_name": "kind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(AttachmentKind.values)", + "dart_type_name": "AttachmentKind" + } + }, + { + "name": "uri", + "getter_name": "uri", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "bytes", + "getter_name": "bytes", + "moor_type": "blob", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "mime_type", + "getter_name": "mimeType", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sort_order", + "getter_name": "sortOrder", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 10, + "references": [], + "type": "table", + "data": { + "name": "external_links", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "parent_type", + "getter_name": "parentType", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(ParentType.values)", + "dart_type_name": "ParentType" + } + }, + { + "name": "parent_id", + "getter_name": "parentId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "url", + "getter_name": "url", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "title", + "getter_name": "title", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 11, + "references": [], + "type": "table", + "data": { + "name": "plantares", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareDirection.values)", + "dart_type_name": "PlantareDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "owed_description", + "getter_name": "owedDescription", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "made_on", + "getter_name": "madeOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "due_by", + "getter_name": "dueBy", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "status", + "getter_name": "status", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'open\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareStatus.values)", + "dart_type_name": "PlantareStatus" + } + }, + { + "name": "settled_on", + "getter_name": "settledOn", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 12, + "references": [], + "type": "table", + "data": { + "name": "sales", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "created_at", + "getter_name": "createdAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "updated_at", + "getter_name": "updatedAt", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "last_author", + "getter_name": "lastAuthor", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "is_deleted", + "getter_name": "isDeleted", + "moor_type": "bool", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "CHECK (\"is_deleted\" IN (0, 1))", + "dialectAwareDefaultConstraints": { + "sqlite": "CHECK (\"is_deleted\" IN (0, 1))" + }, + "default_dart": "const CustomExpression('0')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "schema_row_version", + "getter_name": "schemaRowVersion", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('1')", + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "variety_id", + "getter_name": "varietyId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "direction", + "getter_name": "direction", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(SaleDirection.values)", + "dart_type_name": "SaleDirection" + } + }, + { + "name": "counterparty", + "getter_name": "counterparty", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "amount", + "getter_name": "amount", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "currency", + "getter_name": "currency", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "sold_on", + "getter_name": "soldOn", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "note", + "getter_name": "note", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + } + ], + "fixed_sql": [ + { + "name": "varieties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"varieties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"label\" TEXT NOT NULL, \"species_id\" TEXT NULL, \"cultivar_name\" TEXT NULL, \"category\" TEXT NULL, \"notes\" TEXT NULL, \"is_draft\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_draft\" IN (0, 1)), \"is_organic\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_organic\" IN (0, 1)), \"needs_reproduction\" INTEGER NOT NULL DEFAULT 0 CHECK (\"needs_reproduction\" IN (0, 1)), \"sow_months\" INTEGER NULL, \"transplant_months\" INTEGER NULL, \"flowering_months\" INTEGER NULL, \"fruiting_months\" INTEGER NULL, \"seed_harvest_months\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "variety_vernacular_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"variety_vernacular_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, \"region\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"scientific_name\" TEXT NOT NULL, \"wikidata_qid\" TEXT NULL, \"gbif_key\" INTEGER NULL, \"family\" TEXT NULL, \"is_bundled\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_bundled\" IN (0, 1)), \"viability_years\" INTEGER NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "species_common_names", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"species_common_names\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"species_id\" TEXT NOT NULL, \"name\" TEXT NOT NULL, \"language\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "lots", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"lots\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL DEFAULT 'seed', \"harvest_year\" INTEGER NULL, \"harvest_month\" INTEGER NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"presentation\" TEXT NULL, \"storage_location\" TEXT NULL, \"offer_status\" TEXT NOT NULL DEFAULT 'private', \"seedbank_id\" TEXT NULL, \"origin_name\" TEXT NULL, \"origin_place\" TEXT NULL, \"abundance\" TEXT NULL, \"preservation_format\" TEXT NULL, \"price_amount\" REAL NULL, \"price_currency\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "germination_tests", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"germination_tests\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"tested_on\" INTEGER NULL, \"sample_size\" INTEGER NULL, \"germinated_count\" INTEGER NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "condition_checks", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"condition_checks\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"checked_on\" INTEGER NULL, \"container_count\" INTEGER NULL, \"desiccant_state\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "movements", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"movements\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"last_author\" TEXT NOT NULL, \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"lot_id\" TEXT NOT NULL, \"type\" TEXT NOT NULL, \"occurred_on\" INTEGER NULL, \"counterparty_id\" TEXT NULL, \"quantity_kind\" TEXT NULL, \"quantity_precise\" REAL NULL, \"quantity_label\" TEXT NULL, \"parent_movement_id\" TEXT NULL, \"plantare_id\" TEXT NULL, \"notes\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "parties", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"parties\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"display_name\" TEXT NOT NULL, \"public_key\" TEXT NULL, \"kind\" TEXT NOT NULL DEFAULT 'person', \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "attachments", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"attachments\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"kind\" TEXT NOT NULL, \"uri\" TEXT NULL, \"bytes\" BLOB NULL, \"mime_type\" TEXT NULL, \"sort_order\" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "external_links", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"external_links\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"parent_type\" TEXT NOT NULL, \"parent_id\" TEXT NOT NULL, \"url\" TEXT NOT NULL, \"title\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "plantares", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"plantares\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"owed_description\" TEXT NULL, \"made_on\" INTEGER NOT NULL, \"due_by\" INTEGER NULL, \"status\" TEXT NOT NULL DEFAULT 'open', \"settled_on\" INTEGER NULL, \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "sales", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"sales\" (\"id\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL, \"updated_at\" TEXT NOT NULL, \"last_author\" TEXT NOT NULL, \"is_deleted\" INTEGER NOT NULL DEFAULT 0 CHECK (\"is_deleted\" IN (0, 1)), \"schema_row_version\" INTEGER NOT NULL DEFAULT 1, \"variety_id\" TEXT NULL, \"direction\" TEXT NOT NULL, \"counterparty\" TEXT NULL, \"amount\" REAL NULL, \"currency\" TEXT NULL, \"sold_on\" INTEGER NOT NULL, \"note\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart b/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart index d10c845..5933538 100644 --- a/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart +++ b/apps/app_seeds/lib/data/export_import/inventory_json_codec.dart @@ -79,6 +79,8 @@ class InventoryJsonCodec { 'originPlace': l.originPlace, 'abundance': l.abundance?.name, 'preservationFormat': l.preservationFormat?.name, + 'priceAmount': l.priceAmount, + 'priceCurrency': l.priceCurrency, }, ], 'vernacularNames': [ @@ -338,6 +340,8 @@ class InventoryJsonCodec { PreservationFormat.values, m['preservationFormat'], ), + priceAmount: (m['priceAmount'] as num?)?.toDouble(), + priceCurrency: m['priceCurrency'] as String?, ); }), vernacularNames: _rows(root, 'vernacularNames', (m) { diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 102707d..bd5a05c 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -226,6 +226,8 @@ class ShareableLot extends Equatable { this.category, this.harvestYear, this.isOrganic = false, + this.priceAmount, + this.priceCurrency, }); final String lotId; @@ -246,9 +248,23 @@ class ShareableLot extends Equatable { /// filter the market for it. final bool isOrganic; + /// Asking price, only populated for [OfferStatus.sell] lots. Null amount on + /// a sale means "price to be agreed" — the offer publishes without a price. + final double? priceAmount; + final String? priceCurrency; + @override - List get props => - [lotId, varietyId, summary, offerStatus, category, harvestYear, isOrganic]; + List get props => [ + lotId, + varietyId, + summary, + offerStatus, + category, + harvestYear, + isOrganic, + priceAmount, + priceCurrency, + ]; } /// One germination test on a lot; [rate] is derived (0..1), null when it can't @@ -322,6 +338,8 @@ class VarietyLot extends Equatable { this.abundance, this.preservationFormat, this.offerStatus = OfferStatus.private, + this.priceAmount, + this.priceCurrency, this.germinationTests = const [], this.conditionChecks = const [], }); @@ -354,6 +372,11 @@ class VarietyLot extends Equatable { /// the network is Block 2. final OfferStatus offerStatus; + /// Asking price, meaningful when [offerStatus] is `sell`. Null amount means + /// "price to be agreed". + final double? priceAmount; + final String? priceCurrency; + final List germinationTests; /// Storage-condition checks, most-recent first (`conditionChecks.first` = last). @@ -377,6 +400,8 @@ class VarietyLot extends Equatable { abundance, preservationFormat, offerStatus, + priceAmount, + priceCurrency, germinationTests, conditionChecks, ]; @@ -1281,9 +1306,12 @@ class VarietyRepository { Abundance? abundance, PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, + double? priceAmount, + String? priceCurrency, }) async { final (created, updated) = await _stamp(); final id = idGen.newId(); + final forSale = offerStatus == OfferStatus.sell; await _db .into(_db.lots) .insert( @@ -1306,6 +1334,8 @@ class VarietyRepository { abundance: Value(abundance), preservationFormat: Value(preservationFormat), offerStatus: Value(offerStatus), + priceAmount: Value(forSale ? priceAmount : null), + priceCurrency: Value(forSale ? priceCurrency : null), ), ); return id; @@ -1326,8 +1356,13 @@ class VarietyRepository { Abundance? abundance, PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, + double? priceAmount, + String? priceCurrency, }) async { final (_, updated) = await _stamp(); + // Price only makes sense on a lot offered for sale; clearing the sale + // status clears the price with it. + final forSale = offerStatus == OfferStatus.sell; await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( LotsCompanion( type: Value(type), @@ -1343,6 +1378,8 @@ class VarietyRepository { abundance: Value(abundance), preservationFormat: Value(preservationFormat), offerStatus: Value(offerStatus), + priceAmount: Value(forSale ? priceAmount : null), + priceCurrency: Value(forSale ? priceCurrency : null), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), @@ -1580,6 +1617,12 @@ class VarietyRepository { category: v.category, harvestYear: l.harvestYear, isOrganic: v.isOrganic, + priceAmount: l.offerStatus == OfferStatus.sell + ? l.priceAmount + : null, + priceCurrency: l.offerStatus == OfferStatus.sell + ? l.priceCurrency + : null, ), ]; } @@ -2363,6 +2406,8 @@ class VarietyRepository { abundance: l.abundance, preservationFormat: l.preservationFormat, offerStatus: l.offerStatus, + priceAmount: l.priceAmount, + priceCurrency: l.priceCurrency, germinationTests: germinationTests, conditionChecks: conditionChecks, quantity: hasQuantity diff --git a/apps/app_seeds/lib/db/database.dart b/apps/app_seeds/lib/db/database.dart index 0dc022c..d7ede6d 100644 --- a/apps/app_seeds/lib/db/database.dart +++ b/apps/app_seeds/lib/db/database.dart @@ -31,7 +31,7 @@ class AppDatabase extends _$AppDatabase { /// 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; + static const int currentSchemaVersion = 11; @override int get schemaVersion => currentSchemaVersion; @@ -153,6 +153,16 @@ class AppDatabase extends _$AppDatabase { await m.createTable(sales); } } + // v11: asking price on Lots — published with "sell" market offers. + // Guarded (see the v7 note above). + if (from < 11) { + if (!await _hasColumn('lots', 'price_amount')) { + await m.addColumn(lots, lots.priceAmount); + } + if (!await _hasColumn('lots', 'price_currency')) { + await m.addColumn(lots, lots.priceCurrency); + } + } }, ); diff --git a/apps/app_seeds/lib/db/database.g.dart b/apps/app_seeds/lib/db/database.g.dart index c9a1d6c..81469a9 100644 --- a/apps/app_seeds/lib/db/database.g.dart +++ b/apps/app_seeds/lib/db/database.g.dart @@ -3266,6 +3266,28 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> { ).withConverter( $LotsTable.$converterpreservationFormatn, ); + static const VerificationMeta _priceAmountMeta = const VerificationMeta( + 'priceAmount', + ); + @override + late final GeneratedColumn priceAmount = GeneratedColumn( + 'price_amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); + static const VerificationMeta _priceCurrencyMeta = const VerificationMeta( + 'priceCurrency', + ); + @override + late final GeneratedColumn priceCurrency = GeneratedColumn( + 'price_currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -3289,6 +3311,8 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> { originPlace, abundance, preservationFormat, + priceAmount, + priceCurrency, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -3429,6 +3453,24 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> { ), ); } + if (data.containsKey('price_amount')) { + context.handle( + _priceAmountMeta, + priceAmount.isAcceptableOrUnknown( + data['price_amount']!, + _priceAmountMeta, + ), + ); + } + if (data.containsKey('price_currency')) { + context.handle( + _priceCurrencyMeta, + priceCurrency.isAcceptableOrUnknown( + data['price_currency']!, + _priceCurrencyMeta, + ), + ); + } return context; } @@ -3532,6 +3574,14 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> { data['${effectivePrefix}preservation_format'], ), ), + priceAmount: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}price_amount'], + ), + priceCurrency: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}price_currency'], + ), ); } @@ -3600,6 +3650,17 @@ class Lot extends DataClass implements Insertable { /// How the (seed) lot is physically conserved — see [PreservationFormat]. /// Distinct from [storageLocation]. Optional. final PreservationFormat? preservationFormat; + + /// Asking price when [offerStatus] is `sell` (data-model §2.8 puts price on + /// the Offer; with offer state collapsed onto the Lot, it lives here and is + /// published with the market offer). Informational only — money changes + /// hands off-platform, never in-app. Null amount on a sell lot means + /// "price to be agreed" (the offer publishes without a price). + final double? priceAmount; + + /// Free-text currency, like [Sales.currency]: "€", "Ğ1", a local/time + /// currency. Never assumed (sharing-model §6). + final String? priceCurrency; const Lot({ required this.id, required this.createdAt, @@ -3622,6 +3683,8 @@ class Lot extends DataClass implements Insertable { this.originPlace, this.abundance, this.preservationFormat, + this.priceAmount, + this.priceCurrency, }); @override Map toColumns(bool nullToAbsent) { @@ -3683,6 +3746,12 @@ class Lot extends DataClass implements Insertable { $LotsTable.$converterpreservationFormatn.toSql(preservationFormat), ); } + if (!nullToAbsent || priceAmount != null) { + map['price_amount'] = Variable(priceAmount); + } + if (!nullToAbsent || priceCurrency != null) { + map['price_currency'] = Variable(priceCurrency); + } return map; } @@ -3733,6 +3802,12 @@ class Lot extends DataClass implements Insertable { preservationFormat: preservationFormat == null && nullToAbsent ? const Value.absent() : Value(preservationFormat), + priceAmount: priceAmount == null && nullToAbsent + ? const Value.absent() + : Value(priceAmount), + priceCurrency: priceCurrency == null && nullToAbsent + ? const Value.absent() + : Value(priceCurrency), ); } @@ -3773,6 +3848,8 @@ class Lot extends DataClass implements Insertable { preservationFormat: $LotsTable.$converterpreservationFormatn.fromJson( serializer.fromJson(json['preservationFormat']), ), + priceAmount: serializer.fromJson(json['priceAmount']), + priceCurrency: serializer.fromJson(json['priceCurrency']), ); } @override @@ -3808,6 +3885,8 @@ class Lot extends DataClass implements Insertable { 'preservationFormat': serializer.toJson( $LotsTable.$converterpreservationFormatn.toJson(preservationFormat), ), + 'priceAmount': serializer.toJson(priceAmount), + 'priceCurrency': serializer.toJson(priceCurrency), }; } @@ -3833,6 +3912,8 @@ class Lot extends DataClass implements Insertable { Value originPlace = const Value.absent(), Value abundance = const Value.absent(), Value preservationFormat = const Value.absent(), + Value priceAmount = const Value.absent(), + Value priceCurrency = const Value.absent(), }) => Lot( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -3863,6 +3944,10 @@ class Lot extends DataClass implements Insertable { preservationFormat: preservationFormat.present ? preservationFormat.value : this.preservationFormat, + priceAmount: priceAmount.present ? priceAmount.value : this.priceAmount, + priceCurrency: priceCurrency.present + ? priceCurrency.value + : this.priceCurrency, ); Lot copyWithCompanion(LotsCompanion data) { return Lot( @@ -3915,6 +4000,12 @@ class Lot extends DataClass implements Insertable { preservationFormat: data.preservationFormat.present ? data.preservationFormat.value : this.preservationFormat, + priceAmount: data.priceAmount.present + ? data.priceAmount.value + : this.priceAmount, + priceCurrency: data.priceCurrency.present + ? data.priceCurrency.value + : this.priceCurrency, ); } @@ -3941,7 +4032,9 @@ class Lot extends DataClass implements Insertable { ..write('originName: $originName, ') ..write('originPlace: $originPlace, ') ..write('abundance: $abundance, ') - ..write('preservationFormat: $preservationFormat') + ..write('preservationFormat: $preservationFormat, ') + ..write('priceAmount: $priceAmount, ') + ..write('priceCurrency: $priceCurrency') ..write(')')) .toString(); } @@ -3969,6 +4062,8 @@ class Lot extends DataClass implements Insertable { originPlace, abundance, preservationFormat, + priceAmount, + priceCurrency, ]); @override bool operator ==(Object other) => @@ -3994,7 +4089,9 @@ class Lot extends DataClass implements Insertable { other.originName == this.originName && other.originPlace == this.originPlace && other.abundance == this.abundance && - other.preservationFormat == this.preservationFormat); + other.preservationFormat == this.preservationFormat && + other.priceAmount == this.priceAmount && + other.priceCurrency == this.priceCurrency); } class LotsCompanion extends UpdateCompanion { @@ -4019,6 +4116,8 @@ class LotsCompanion extends UpdateCompanion { final Value originPlace; final Value abundance; final Value preservationFormat; + final Value priceAmount; + final Value priceCurrency; final Value rowid; const LotsCompanion({ this.id = const Value.absent(), @@ -4042,6 +4141,8 @@ class LotsCompanion extends UpdateCompanion { this.originPlace = const Value.absent(), this.abundance = const Value.absent(), this.preservationFormat = const Value.absent(), + this.priceAmount = const Value.absent(), + this.priceCurrency = const Value.absent(), this.rowid = const Value.absent(), }); LotsCompanion.insert({ @@ -4066,6 +4167,8 @@ class LotsCompanion extends UpdateCompanion { this.originPlace = const Value.absent(), this.abundance = const Value.absent(), this.preservationFormat = const Value.absent(), + this.priceAmount = const Value.absent(), + this.priceCurrency = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -4094,6 +4197,8 @@ class LotsCompanion extends UpdateCompanion { Expression? originPlace, Expression? abundance, Expression? preservationFormat, + Expression? priceAmount, + Expression? priceCurrency, Expression? rowid, }) { return RawValuesInsertable({ @@ -4118,6 +4223,8 @@ class LotsCompanion extends UpdateCompanion { if (originPlace != null) 'origin_place': originPlace, if (abundance != null) 'abundance': abundance, if (preservationFormat != null) 'preservation_format': preservationFormat, + if (priceAmount != null) 'price_amount': priceAmount, + if (priceCurrency != null) 'price_currency': priceCurrency, if (rowid != null) 'rowid': rowid, }); } @@ -4144,6 +4251,8 @@ class LotsCompanion extends UpdateCompanion { Value? originPlace, Value? abundance, Value? preservationFormat, + Value? priceAmount, + Value? priceCurrency, Value? rowid, }) { return LotsCompanion( @@ -4168,6 +4277,8 @@ class LotsCompanion extends UpdateCompanion { originPlace: originPlace ?? this.originPlace, abundance: abundance ?? this.abundance, preservationFormat: preservationFormat ?? this.preservationFormat, + priceAmount: priceAmount ?? this.priceAmount, + priceCurrency: priceCurrency ?? this.priceCurrency, rowid: rowid ?? this.rowid, ); } @@ -4250,6 +4361,12 @@ class LotsCompanion extends UpdateCompanion { ), ); } + if (priceAmount.present) { + map['price_amount'] = Variable(priceAmount.value); + } + if (priceCurrency.present) { + map['price_currency'] = Variable(priceCurrency.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -4280,6 +4397,8 @@ class LotsCompanion extends UpdateCompanion { ..write('originPlace: $originPlace, ') ..write('abundance: $abundance, ') ..write('preservationFormat: $preservationFormat, ') + ..write('priceAmount: $priceAmount, ') + ..write('priceCurrency: $priceCurrency, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -11663,6 +11782,8 @@ typedef $$LotsTableCreateCompanionBuilder = Value originPlace, Value abundance, Value preservationFormat, + Value priceAmount, + Value priceCurrency, Value rowid, }); typedef $$LotsTableUpdateCompanionBuilder = @@ -11688,6 +11809,8 @@ typedef $$LotsTableUpdateCompanionBuilder = Value originPlace, Value abundance, Value preservationFormat, + Value priceAmount, + Value priceCurrency, Value rowid, }); @@ -11812,6 +11935,16 @@ class $$LotsTableFilterComposer extends Composer<_$AppDatabase, $LotsTable> { column: $table.preservationFormat, builder: (column) => ColumnWithTypeConverterFilters(column), ); + + ColumnFilters get priceAmount => $composableBuilder( + column: $table.priceAmount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get priceCurrency => $composableBuilder( + column: $table.priceCurrency, + builder: (column) => ColumnFilters(column), + ); } class $$LotsTableOrderingComposer extends Composer<_$AppDatabase, $LotsTable> { @@ -11926,6 +12059,16 @@ class $$LotsTableOrderingComposer extends Composer<_$AppDatabase, $LotsTable> { column: $table.preservationFormat, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get priceAmount => $composableBuilder( + column: $table.priceAmount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get priceCurrency => $composableBuilder( + column: $table.priceCurrency, + builder: (column) => ColumnOrderings(column), + ); } class $$LotsTableAnnotationComposer @@ -12030,6 +12173,16 @@ class $$LotsTableAnnotationComposer column: $table.preservationFormat, builder: (column) => column, ); + + GeneratedColumn get priceAmount => $composableBuilder( + column: $table.priceAmount, + builder: (column) => column, + ); + + GeneratedColumn get priceCurrency => $composableBuilder( + column: $table.priceCurrency, + builder: (column) => column, + ); } class $$LotsTableTableManager @@ -12082,6 +12235,8 @@ class $$LotsTableTableManager Value abundance = const Value.absent(), Value preservationFormat = const Value.absent(), + Value priceAmount = const Value.absent(), + Value priceCurrency = const Value.absent(), Value rowid = const Value.absent(), }) => LotsCompanion( id: id, @@ -12105,6 +12260,8 @@ class $$LotsTableTableManager originPlace: originPlace, abundance: abundance, preservationFormat: preservationFormat, + priceAmount: priceAmount, + priceCurrency: priceCurrency, rowid: rowid, ), createCompanionCallback: @@ -12131,6 +12288,8 @@ class $$LotsTableTableManager Value abundance = const Value.absent(), Value preservationFormat = const Value.absent(), + Value priceAmount = const Value.absent(), + Value priceCurrency = const Value.absent(), Value rowid = const Value.absent(), }) => LotsCompanion.insert( id: id, @@ -12154,6 +12313,8 @@ class $$LotsTableTableManager originPlace: originPlace, abundance: abundance, preservationFormat: preservationFormat, + priceAmount: priceAmount, + priceCurrency: priceCurrency, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/apps/app_seeds/lib/db/tables.dart b/apps/app_seeds/lib/db/tables.dart index 064ae42..a077d96 100644 --- a/apps/app_seeds/lib/db/tables.dart +++ b/apps/app_seeds/lib/db/tables.dart @@ -109,6 +109,17 @@ class Lots extends Table with SyncColumns { /// Distinct from [storageLocation]. Optional. TextColumn get preservationFormat => textEnum().nullable()(); + + /// Asking price when [offerStatus] is `sell` (data-model §2.8 puts price on + /// the Offer; with offer state collapsed onto the Lot, it lives here and is + /// published with the market offer). Informational only — money changes + /// hands off-platform, never in-app. Null amount on a sell lot means + /// "price to be agreed" (the offer publishes without a price). + RealColumn get priceAmount => real().nullable()(); + + /// Free-text currency, like [Sales.currency]: "€", "Ğ1", a local/time + /// currency. Never assumed (sharing-model §6). + TextColumn get priceCurrency => text().nullable()(); } /// Optional germination history for a Lot; percent is derived in code. diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 50702f7..456c10d 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -303,6 +303,8 @@ "add": "¿Compártesla?", "title": "¿Compártesla?", "nudge": "Tienes a esgaya: podríes compartir un poco.", + "price": "Preciu", + "priceHint": "Déxalu baleru p'alcordalu depués", "private": "Namás pa min", "gift": "Pa regalar", "exchange": "Pa trocar", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 6ae843d..2d57d70 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -304,6 +304,8 @@ "add": "Do you share it?", "title": "Do you share it?", "nudge": "You have plenty — you could share some.", + "price": "Price", + "priceHint": "Leave it empty to agree it later", "private": "Just for me", "gift": "To give away", "exchange": "To swap", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index f5baf51..4da41b5 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -303,6 +303,8 @@ "add": "¿La compartes?", "title": "¿La compartes?", "nudge": "Tienes de sobra: podrías compartir un poco.", + "price": "Precio", + "priceHint": "Déjalo vacío para acordarlo luego", "private": "Solo para mí", "gift": "Para regalar", "exchange": "Para intercambiar", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index ab2e91d..43adb5b 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -300,6 +300,8 @@ "add": "Partilhas esta?", "title": "Partilhas esta?", "nudge": "Tens de sobra: podias partilhar um pouco.", + "price": "Preço", + "priceHint": "Deixa vazio para combinar depois", "private": "Só para mim", "gift": "Para dar", "exchange": "Para trocar", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index d97213a..8a2d815 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1968 (492 per locale) +/// Strings: 1976 (494 per locale) /// -/// Built on 2026-07-11 at 05:50 UTC +/// Built on 2026-07-11 at 06:00 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 1ad3284..1da3ad9 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -547,6 +547,8 @@ class _Translations$share$ast extends Translations$share$en { @override String get add => '¿Compártesla?'; @override String get title => '¿Compártesla?'; @override String get nudge => 'Tienes a esgaya: podríes compartir un poco.'; + @override String get price => 'Preciu'; + @override String get priceHint => 'Déxalu baleru p\'alcordalu depués'; @override String get private => 'Namás pa min'; @override String get gift => 'Pa regalar'; @override String get exchange => 'Pa trocar'; @@ -1501,6 +1503,8 @@ extension on TranslationsAst { 'share.add' => '¿Compártesla?', 'share.title' => '¿Compártesla?', 'share.nudge' => 'Tienes a esgaya: podríes compartir un poco.', + 'share.price' => 'Preciu', + 'share.priceHint' => 'Déxalu baleru p\'alcordalu depués', 'share.private' => 'Namás pa min', 'share.gift' => 'Pa regalar', 'share.exchange' => 'Pa trocar', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 12d5c9b..732d86b 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -988,6 +988,12 @@ class Translations$share$en { /// en: 'You have plenty — you could share some.' String get nudge => 'You have plenty — you could share some.'; + /// en: 'Price' + String get price => 'Price'; + + /// en: 'Leave it empty to agree it later' + String get priceHint => 'Leave it empty to agree it later'; + /// en: 'Just for me' String get private => 'Just for me'; @@ -2473,6 +2479,8 @@ extension on Translations { 'share.add' => 'Do you share it?', 'share.title' => 'Do you share it?', 'share.nudge' => 'You have plenty — you could share some.', + 'share.price' => 'Price', + 'share.priceHint' => 'Leave it empty to agree it later', 'share.private' => 'Just for me', 'share.gift' => 'To give away', 'share.exchange' => 'To swap', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index a1d5934..25e7797 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -547,6 +547,8 @@ class _Translations$share$es extends Translations$share$en { @override String get add => '¿La compartes?'; @override String get title => '¿La compartes?'; @override String get nudge => 'Tienes de sobra: podrías compartir un poco.'; + @override String get price => 'Precio'; + @override String get priceHint => 'Déjalo vacío para acordarlo luego'; @override String get private => 'Solo para mí'; @override String get gift => 'Para regalar'; @override String get exchange => 'Para intercambiar'; @@ -1503,6 +1505,8 @@ extension on TranslationsEs { 'share.add' => '¿La compartes?', 'share.title' => '¿La compartes?', 'share.nudge' => 'Tienes de sobra: podrías compartir un poco.', + 'share.price' => 'Precio', + 'share.priceHint' => 'Déjalo vacío para acordarlo luego', 'share.private' => 'Solo para mí', 'share.gift' => 'Para regalar', 'share.exchange' => 'Para intercambiar', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 4335de7..5800443 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -544,6 +544,8 @@ class _Translations$share$pt extends Translations$share$en { @override String get add => 'Partilhas esta?'; @override String get title => 'Partilhas esta?'; @override String get nudge => 'Tens de sobra: podias partilhar um pouco.'; + @override String get price => 'Preço'; + @override String get priceHint => 'Deixa vazio para combinar depois'; @override String get private => 'Só para mim'; @override String get gift => 'Para dar'; @override String get exchange => 'Para trocar'; @@ -1497,6 +1499,8 @@ extension on TranslationsPt { 'share.add' => 'Partilhas esta?', 'share.title' => 'Partilhas esta?', 'share.nudge' => 'Tens de sobra: podias partilhar um pouco.', + 'share.price' => 'Preço', + 'share.priceHint' => 'Deixa vazio para combinar depois', 'share.private' => 'Só para mim', 'share.gift' => 'Para dar', 'share.exchange' => 'Para trocar', diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index ce6706a..d07c6b6 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -284,6 +284,8 @@ class OffersCubit extends Cubit { areaGeohash: areaGeohash, category: lot.category, isOrganic: lot.isOrganic, + priceAmount: lot.priceAmount, + priceCurrency: lot.priceCurrency, imageUrl: await _coverThumbnail(lot.varietyId), ); final result = await transport.publish(offer); diff --git a/apps/app_seeds/lib/state/variety_detail_cubit.dart b/apps/app_seeds/lib/state/variety_detail_cubit.dart index 292c07f..7767976 100644 --- a/apps/app_seeds/lib/state/variety_detail_cubit.dart +++ b/apps/app_seeds/lib/state/variety_detail_cubit.dart @@ -81,6 +81,8 @@ class VarietyDetailCubit extends Cubit { Abundance? abundance, PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, + double? priceAmount, + String? priceCurrency, }) => _repo.addLot( varietyId: varietyId, type: type, @@ -93,6 +95,8 @@ class VarietyDetailCubit extends Cubit { abundance: abundance, preservationFormat: preservationFormat, offerStatus: offerStatus, + priceAmount: priceAmount, + priceCurrency: priceCurrency, ); Future updateLot({ @@ -107,6 +111,8 @@ class VarietyDetailCubit extends Cubit { Abundance? abundance, PreservationFormat? preservationFormat, OfferStatus offerStatus = OfferStatus.private, + double? priceAmount, + String? priceCurrency, }) => _repo.updateLot( lotId: lotId, type: type, @@ -119,6 +125,8 @@ class VarietyDetailCubit extends Cubit { abundance: abundance, preservationFormat: preservationFormat, offerStatus: offerStatus, + priceAmount: priceAmount, + priceCurrency: priceCurrency, ); Future deleteLot(String lotId) => _repo.softDeleteLot(lotId); diff --git a/apps/app_seeds/lib/ui/currency_quick_picks.dart b/apps/app_seeds/lib/ui/currency_quick_picks.dart new file mode 100644 index 0000000..70ef5b1 --- /dev/null +++ b/apps/app_seeds/lib/ui/currency_quick_picks.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../i18n/strings.g.dart'; + +/// One-tap currency chips bound to a free-text [controller]. Ğ1 sits quietly +/// among the familiar ones — offered, never imposed — and free text still +/// takes anything else (sharing-model §6). Shared by the sale sheet, the lot +/// price field and the hand-over sheet so every money-ish input feels the same. +class CurrencyQuickPicks extends StatelessWidget { + const CurrencyQuickPicks({ + required this.controller, + required this.keyPrefix, + required this.onChanged, + super.key, + }); + + final TextEditingController controller; + + /// Namespaces the chip [Key]s (e.g. `sale` → `sale.currencyChip.€`). + final String keyPrefix; + + /// Fires after a chip toggles the controller text (for `setState`). + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Wrap( + spacing: 8, + children: [ + for (final c in ['€', 'Ğ1', t.sale.hours]) + ChoiceChip( + key: Key('$keyPrefix.currencyChip.$c'), + label: Text(c), + selected: controller.text.trim() == c, + onSelected: (sel) { + controller.text = sel ? c : ''; + onChanged(); + }, + ), + ], + ); + } +} diff --git a/apps/app_seeds/lib/ui/sale_sheet.dart b/apps/app_seeds/lib/ui/sale_sheet.dart index a197f1f..c4cd68d 100644 --- a/apps/app_seeds/lib/ui/sale_sheet.dart +++ b/apps/app_seeds/lib/ui/sale_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../data/variety_repository.dart'; import '../db/enums.dart'; import '../i18n/strings.g.dart'; +import 'currency_quick_picks.dart'; import 'theme.dart'; /// Opens the "record a sale" sheet. Optionally pre-attached to [varietyId] @@ -140,22 +141,10 @@ class _SaleSheetState extends State<_SaleSheet> { ], ), const SizedBox(height: 8), - // Quick picks so a currency is one tap. Ğ1 sits quietly among the - // familiar ones — offered, never imposed — and free text still takes - // anything else. - Wrap( - spacing: 8, - children: [ - for (final c in ['€', 'Ğ1', t.sale.hours]) - ChoiceChip( - key: Key('sale.currencyChip.$c'), - label: Text(c), - selected: _currency.text.trim() == c, - onSelected: (sel) => setState(() { - _currency.text = sel ? c : ''; - }), - ), - ], + CurrencyQuickPicks( + controller: _currency, + keyPrefix: 'sale', + onChanged: () => setState(() {}), ), const SizedBox(height: 12), TextField( diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 46ecc99..8303b00 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -16,6 +16,7 @@ import '../domain/seed_viability.dart'; import '../domain/species_reference_links.dart'; import '../i18n/strings.g.dart'; import '../state/variety_detail_cubit.dart'; +import 'currency_quick_picks.dart'; import 'harvest_date_picker.dart'; import 'photo_pick.dart'; import 'plantare_sheet.dart'; @@ -1464,6 +1465,18 @@ Future _showLotSheet( existing?.originName != null || existing?.originPlace != null; var showAbundance = existing?.abundance != null; var showShare = selectedShare != OfferStatus.private; + final existingPrice = existing?.priceAmount; + final priceAmountCtrl = TextEditingController( + // Drop a trailing .0 so the field shows "5", not "5.0". + text: existingPrice == null + ? '' + : (existingPrice == existingPrice.roundToDouble() + ? existingPrice.toInt().toString() + : existingPrice.toString()), + ); + final priceCurrencyCtrl = TextEditingController( + text: existing?.priceCurrency ?? '', + ); return showModalBottomSheet( context: context, isScrollControlled: true, @@ -1660,6 +1673,49 @@ Future _showLotSheet( style: Theme.of(sheetContext).textTheme.bodySmall, ), ), + // Price only when selling — informational, agreed and + // paid off-app. Empty amount = "to be agreed". + if (selectedShare == OfferStatus.sell) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + key: const Key('lot.priceAmount'), + controller: priceAmountCtrl, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: InputDecoration( + labelText: t.share.price, + helperText: t.share.priceHint, + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + key: const Key('lot.priceCurrency'), + controller: priceCurrencyCtrl, + decoration: InputDecoration( + labelText: t.sale.currency, + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + const SizedBox(height: 8), + CurrencyQuickPicks( + controller: priceCurrencyCtrl, + keyPrefix: 'lot', + onChanged: () => setState(() {}), + ), + ], ], // Layer 2: advanced seed-bank details, collapsed by default. // Only for seed lots, and (condition checks) an existing lot. @@ -1710,6 +1766,10 @@ Future _showLotSheet( onPressed: () { final originName = _nullIfBlank(originNameCtrl.text); final originPlace = _nullIfBlank(originPlaceCtrl.text); + final priceAmount = double.tryParse( + priceAmountCtrl.text.replaceAll(',', '.').trim(), + ); + final priceCurrency = _nullIfBlank(priceCurrencyCtrl.text); if (editing) { cubit.updateLot( lotId: existing.id, @@ -1723,6 +1783,8 @@ Future _showLotSheet( abundance: selectedAbundance, preservationFormat: selectedPreservation, offerStatus: selectedShare, + priceAmount: priceAmount, + priceCurrency: priceCurrency, ); } else { cubit.addLot( @@ -1736,6 +1798,8 @@ Future _showLotSheet( abundance: selectedAbundance, preservationFormat: selectedPreservation, offerStatus: selectedShare, + priceAmount: priceAmount, + priceCurrency: priceCurrency, ); } Navigator.of(sheetContext).pop(); diff --git a/apps/app_seeds/test/data/shareable_lots_test.dart b/apps/app_seeds/test/data/shareable_lots_test.dart index 69c6bdd..8a87e1c 100644 --- a/apps/app_seeds/test/data/shareable_lots_test.dart +++ b/apps/app_seeds/test/data/shareable_lots_test.dart @@ -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); + }); } diff --git a/apps/app_seeds/test/db/migration_test.dart b/apps/app_seeds/test/db/migration_test.dart index a5c6676..0a3415a 100644 --- a/apps/app_seeds/test/db/migration_test.dart +++ b/apps/app_seeds/test/db/migration_test.dart @@ -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(); }); } diff --git a/apps/app_seeds/test/db/schema/schema.dart b/apps/app_seeds/test/db/schema/schema.dart index e3e260c..60e4f10 100644 --- a/apps/app_seeds/test/db/schema/schema.dart +++ b/apps/app_seeds/test/db/schema/schema.dart @@ -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]; } diff --git a/apps/app_seeds/test/db/schema/schema_v11.dart b/apps/app_seeds/test/db/schema/schema_v11.dart new file mode 100644 index 0000000..17d4b21 --- /dev/null +++ b/apps/app_seeds/test/db/schema/schema_v11.dart @@ -0,0 +1,2015 @@ +// dart format width=80 +import 'dart:typed_data' as i2; +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class Varieties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Varieties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn cultivarName = GeneratedColumn( + 'cultivar_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn category = GeneratedColumn( + 'category', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isDraft = GeneratedColumn( + 'is_draft', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_draft IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn isOrganic = GeneratedColumn( + 'is_organic', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_organic IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn needsReproduction = GeneratedColumn( + 'needs_reproduction', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: + 'NOT NULL DEFAULT 0 CHECK (needs_reproduction IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn sowMonths = GeneratedColumn( + 'sow_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn transplantMonths = GeneratedColumn( + 'transplant_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn floweringMonths = GeneratedColumn( + 'flowering_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn fruitingMonths = GeneratedColumn( + 'fruiting_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn seedHarvestMonths = GeneratedColumn( + 'seed_harvest_months', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + label, + speciesId, + cultivarName, + category, + notes, + isDraft, + isOrganic, + needsReproduction, + sowMonths, + transplantMonths, + floweringMonths, + fruitingMonths, + seedHarvestMonths, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'varieties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Varieties createAlias(String alias) { + return Varieties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class VarietyVernacularNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + VarietyVernacularNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn region = GeneratedColumn( + 'region', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + name, + language, + region, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'variety_vernacular_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + VarietyVernacularNames createAlias(String alias) { + return VarietyVernacularNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Species extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Species(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn scientificName = GeneratedColumn( + 'scientific_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn wikidataQid = GeneratedColumn( + 'wikidata_qid', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn gbifKey = GeneratedColumn( + 'gbif_key', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn family = GeneratedColumn( + 'family', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn isBundled = GeneratedColumn( + 'is_bundled', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_bundled IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn viabilityYears = GeneratedColumn( + 'viability_years', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + scientificName, + wikidataQid, + gbifKey, + family, + isBundled, + viabilityYears, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Species createAlias(String alias) { + return Species(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class SpeciesCommonNames extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + SpeciesCommonNames(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn speciesId = GeneratedColumn( + 'species_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + speciesId, + name, + language, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'species_common_names'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + SpeciesCommonNames createAlias(String alias) { + return SpeciesCommonNames(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Lots extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Lots(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'seed\'', + defaultValue: const CustomExpression('\'seed\''), + ); + late final GeneratedColumn harvestYear = GeneratedColumn( + 'harvest_year', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn harvestMonth = GeneratedColumn( + 'harvest_month', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn presentation = GeneratedColumn( + 'presentation', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn storageLocation = GeneratedColumn( + 'storage_location', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn offerStatus = GeneratedColumn( + 'offer_status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'private\'', + defaultValue: const CustomExpression('\'private\''), + ); + late final GeneratedColumn seedbankId = GeneratedColumn( + 'seedbank_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originName = GeneratedColumn( + 'origin_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn originPlace = GeneratedColumn( + 'origin_place', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn abundance = GeneratedColumn( + 'abundance', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn preservationFormat = + GeneratedColumn( + 'preservation_format', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn priceAmount = GeneratedColumn( + 'price_amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn priceCurrency = GeneratedColumn( + 'price_currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + type, + harvestYear, + harvestMonth, + quantityKind, + quantityPrecise, + quantityLabel, + presentation, + storageLocation, + offerStatus, + seedbankId, + originName, + originPlace, + abundance, + preservationFormat, + priceAmount, + priceCurrency, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'lots'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Lots createAlias(String alias) { + return Lots(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class GerminationTests extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GerminationTests(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn testedOn = GeneratedColumn( + 'tested_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sampleSize = GeneratedColumn( + 'sample_size', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn germinatedCount = GeneratedColumn( + 'germinated_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + testedOn, + sampleSize, + germinatedCount, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'germination_tests'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GerminationTests createAlias(String alias) { + return GerminationTests(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ConditionChecks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ConditionChecks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn checkedOn = GeneratedColumn( + 'checked_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn containerCount = GeneratedColumn( + 'container_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn desiccantState = GeneratedColumn( + 'desiccant_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + lotId, + checkedOn, + containerCount, + desiccantState, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'condition_checks'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ConditionChecks createAlias(String alias) { + return ConditionChecks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Movements extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Movements(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn lotId = GeneratedColumn( + 'lot_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn occurredOn = GeneratedColumn( + 'occurred_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn counterpartyId = GeneratedColumn( + 'counterparty_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityKind = GeneratedColumn( + 'quantity_kind', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityPrecise = GeneratedColumn( + 'quantity_precise', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn quantityLabel = GeneratedColumn( + 'quantity_label', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn parentMovementId = GeneratedColumn( + 'parent_movement_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn plantareId = GeneratedColumn( + 'plantare_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn notes = GeneratedColumn( + 'notes', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + lastAuthor, + schemaRowVersion, + lotId, + type, + occurredOn, + counterpartyId, + quantityKind, + quantityPrecise, + quantityLabel, + parentMovementId, + plantareId, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'movements'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Movements createAlias(String alias) { + return Movements(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Parties extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Parties(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn publicKey = GeneratedColumn( + 'public_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'person\'', + defaultValue: const CustomExpression('\'person\''), + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + displayName, + publicKey, + kind, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'parties'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Parties createAlias(String alias) { + return Parties(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Attachments extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Attachments(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn uri = GeneratedColumn( + 'uri', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn bytes = + GeneratedColumn( + 'bytes', + aliasedName, + true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn mimeType = GeneratedColumn( + 'mime_type', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn sortOrder = GeneratedColumn( + 'sort_order', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0', + defaultValue: const CustomExpression('0'), + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + kind, + uri, + bytes, + mimeType, + sortOrder, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'attachments'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Attachments createAlias(String alias) { + return Attachments(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class ExternalLinks extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + ExternalLinks(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn parentType = GeneratedColumn( + 'parent_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn url = GeneratedColumn( + 'url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn title = GeneratedColumn( + 'title', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + parentType, + parentId, + url, + title, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'external_links'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + ExternalLinks createAlias(String alias) { + return ExternalLinks(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Plantares extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Plantares(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn owedDescription = GeneratedColumn( + 'owed_description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn madeOn = GeneratedColumn( + 'made_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn dueBy = GeneratedColumn( + 'due_by', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'open\'', + defaultValue: const CustomExpression('\'open\''), + ); + late final GeneratedColumn settledOn = GeneratedColumn( + 'settled_on', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + owedDescription, + madeOn, + dueBy, + status, + settledOn, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'plantares'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Plantares createAlias(String alias) { + return Plantares(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class Sales extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Sales(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn lastAuthor = GeneratedColumn( + 'last_author', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn isDeleted = GeneratedColumn( + 'is_deleted', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1))', + defaultValue: const CustomExpression('0'), + ); + late final GeneratedColumn schemaRowVersion = GeneratedColumn( + 'schema_row_version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT 1', + defaultValue: const CustomExpression('1'), + ); + late final GeneratedColumn varietyId = GeneratedColumn( + 'variety_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn direction = GeneratedColumn( + 'direction', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn counterparty = GeneratedColumn( + 'counterparty', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn amount = GeneratedColumn( + 'amount', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn currency = GeneratedColumn( + 'currency', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn soldOn = GeneratedColumn( + 'sold_on', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn note = GeneratedColumn( + 'note', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + amount, + currency, + soldOn, + note, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sales'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + Sales createAlias(String alias) { + return Sales(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class DatabaseAtV11 extends GeneratedDatabase { + DatabaseAtV11(QueryExecutor e) : super(e); + late final Varieties varieties = Varieties(this); + late final VarietyVernacularNames varietyVernacularNames = + VarietyVernacularNames(this); + late final Species species = Species(this); + late final SpeciesCommonNames speciesCommonNames = SpeciesCommonNames(this); + late final Lots lots = Lots(this); + late final GerminationTests germinationTests = GerminationTests(this); + late final ConditionChecks conditionChecks = ConditionChecks(this); + late final Movements movements = Movements(this); + late final Parties parties = Parties(this); + late final Attachments attachments = Attachments(this); + late final ExternalLinks externalLinks = ExternalLinks(this); + late final Plantares plantares = Plantares(this); + late final Sales sales = Sales(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + varieties, + varietyVernacularNames, + species, + speciesCommonNames, + lots, + germinationTests, + conditionChecks, + movements, + parties, + attachments, + externalLinks, + plantares, + sales, + ]; + @override + int get schemaVersion => 11; +} diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index ce8a3f1..8542008 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -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(); From e6e70e4b9719c0d4fe258b5069bcf63ea1416e46 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 11 Jul 2026 07:28:27 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(handover):=20recordHandover=20?= =?UTF-8?q?=E2=80=94=20one=20transaction=20for=20the=20moment=20seeds=20ch?= =?UTF-8?q?ange=20hands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns to the original model (data-model §2.8: a closed deal produces a Movement and, optionally, a Plantare): the given/received Movement plus, when they came with it, the Sale (informational payment note) and the Plantare (return promise), all sharing the counterparty. The Movement carries the promise via the plantare_id column the schema had reserved. 'Gave all' (one tap, the natural path for shrubs/whole plants) moves the lot's whole quantity, leaves an empty jar (0, same unit), clears abundance, and returns the lot to private — withdrawing its market offer on the next publish cycle, price cleared with it. New enum HandoverDirection; tests in test/data/handover_test.dart. --- .../lib/data/variety_repository.dart | 167 ++++++++++++++-- apps/app_seeds/lib/db/enums.dart | 7 + apps/app_seeds/test/data/handover_test.dart | 181 ++++++++++++++++++ 3 files changed, 336 insertions(+), 19 deletions(-) create mode 100644 apps/app_seeds/test/data/handover_test.dart diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index bd5a05c..69dfea0 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -1199,7 +1199,10 @@ class VarietyRepository { Future<({String id, String? family})?> _autoClassifyFromLabel( String label, ) async { - final speciesId = matchSpeciesInLabel(label, await _speciesNamesForMatching()); + final speciesId = matchSpeciesInLabel( + label, + await _speciesNamesForMatching(), + ); if (speciesId == null) return null; final species = await (_db.select( _db.species, @@ -1569,16 +1572,13 @@ class VarietyRepository { .get(); final bySpecies = >{}; for (final n in rows) { - (bySpecies[n.speciesId] ??= []).add(( - name: n.name, - language: n.language, - )); + (bySpecies[n.speciesId] ??= []).add((name: n.name, language: n.language)); } final result = {}; for (final entry in bySpecies.entries) { final inLocale = entry.value.where((n) => n.language == languageCode); - result[entry.key] = (inLocale.isEmpty ? entry.value.first : inLocale.first) - .name; + result[entry.key] = + (inLocale.isEmpty ? entry.value.first : inLocale.first).name; } return result; } @@ -1866,7 +1866,9 @@ class VarietyRepository { }) async { final (created, updated) = await _stamp(); final id = idGen.newId(); - await _db.into(_db.plantares).insert( + await _db + .into(_db.plantares) + .insert( PlantaresCompanion.insert( id: id, createdAt: created, @@ -1885,10 +1887,11 @@ class VarietyRepository { } /// All live commitments, newest first (for the Plantares screen). - Stream> watchPlantares() => (_db.select(_db.plantares) - ..where((p) => p.isDeleted.equals(false)) - ..orderBy([(p) => OrderingTerm.desc(p.madeOn)])) - .watch(); + Stream> watchPlantares() => + (_db.select(_db.plantares) + ..where((p) => p.isDeleted.equals(false)) + ..orderBy([(p) => OrderingTerm.desc(p.madeOn)])) + .watch(); /// The commitments recorded against one variety. Stream> watchPlantaresForVariety(String varietyId) => @@ -1906,8 +1909,7 @@ class VarietyRepository { await (_db.update(_db.plantares)..where((p) => p.id.equals(id))).write( PlantaresCompanion( status: Value(status), - settledOn: - Value(status == PlantareStatus.open ? null : now), + settledOn: Value(status == PlantareStatus.open ? null : now), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), @@ -1939,7 +1941,9 @@ class VarietyRepository { }) async { final (created, updated) = await _stamp(); final id = idGen.newId(); - await _db.into(_db.sales).insert( + await _db + .into(_db.sales) + .insert( SalesCompanion.insert( id: id, createdAt: created, @@ -1958,10 +1962,11 @@ class VarietyRepository { } /// All live sales, newest first (for the Sales screen). - Stream> watchSales() => (_db.select(_db.sales) - ..where((s) => s.isDeleted.equals(false)) - ..orderBy([(s) => OrderingTerm.desc(s.soldOn)])) - .watch(); + Stream> watchSales() => + (_db.select(_db.sales) + ..where((s) => s.isDeleted.equals(false)) + ..orderBy([(s) => OrderingTerm.desc(s.soldOn)])) + .watch(); /// The sales recorded against one variety. Stream> watchSalesForVariety(String varietyId) => @@ -1984,6 +1989,130 @@ class VarietyRepository { ); } + // --- Hand-over: the one moment seeds change hands -------------------------- + + /// Records a hand-over in a single transaction (data-model §2.8: a closed + /// deal produces a `Movement` and, optionally, a `Plantare`): the Movement + /// (given/received) plus — when they came with it — the Sale (money changed + /// hands, informational only, never processed in-app) and the Plantare (a + /// return promise), all sharing the same free-text [counterparty]. + /// + /// With nothing optional this is a plain gift: just the Movement. + /// + /// [gaveAll] is the one-tap "all of it" (the natural path for a whole plant + /// or shrub): the movement carries the lot's current quantity, the lot's + /// precise quantity drops to 0 and it returns to [OfferStatus.private] — + /// withdrawing it from the market on the next publish cycle. Otherwise + /// [quantity] says how much of the batch moved (optional). + Future<({String movementId, String? saleId, String? plantareId})> + recordHandover({ + required String lotId, + required HandoverDirection direction, + bool gaveAll = false, + Quantity? quantity, + String? counterparty, + bool withPayment = false, + double? paymentAmount, + String? paymentCurrency, + bool withPromise = false, + String? promiseOwedDescription, + int? promiseDueBy, + String? note, + }) async { + final gave = direction == HandoverDirection.iGave; + return _db.transaction(() async { + final lot = await (_db.select( + _db.lots, + )..where((l) => l.id.equals(lotId))).getSingle(); + + // The promise first, so the movement can carry its id — the link the + // model reserved for exactly this (data-model §2.4: "a `given` may + // carry a Plantare"). + String? plantareId; + if (withPromise) { + plantareId = await createPlantare( + direction: gave + ? PlantareDirection.owedToMe + : PlantareDirection.iReturn, + varietyId: lot.varietyId, + counterparty: counterparty, + owedDescription: promiseOwedDescription, + dueBy: promiseDueBy, + ); + } + + final movedAll = gaveAll && gave; + final lotHasQuantity = + lot.quantityKind != null || + lot.quantityPrecise != null || + lot.quantityLabel != null; + final movedQuantity = movedAll + ? (lotHasQuantity + ? Quantity( + kind: _parseKind(lot.quantityKind), + count: lot.quantityPrecise, + label: lot.quantityLabel, + ) + : null) + : quantity; + final noteParts = [ + if (counterparty != null && counterparty.trim().isNotEmpty) + counterparty.trim(), + if (note != null && note.trim().isNotEmpty) note.trim(), + ]; + final (created, _) = await _stamp(); + final movementId = idGen.newId(); + await _db + .into(_db.movements) + .insert( + MovementsCompanion.insert( + id: movementId, + createdAt: created, + lastAuthor: nodeId, + lotId: lotId, + type: gave ? MovementType.given : MovementType.received, + occurredOn: Value(created), + quantityKind: Value(movedQuantity?.kind.name), + quantityPrecise: Value(movedQuantity?.count?.toDouble()), + quantityLabel: Value(movedQuantity?.label), + plantareId: Value(plantareId), + notes: Value(noteParts.isEmpty ? null : noteParts.join(' — ')), + ), + ); + + if (movedAll) { + // The batch is gone: an empty jar (0, same unit) with nothing left to + // declare or offer. Price goes with the sale status. + final (_, updated) = await _stamp(); + await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( + LotsCompanion( + quantityPrecise: const Value(0), + abundance: const Value(null), + offerStatus: const Value(OfferStatus.private), + priceAmount: const Value(null), + priceCurrency: const Value(null), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + String? saleId; + if (withPayment) { + saleId = await createSale( + direction: gave ? SaleDirection.iSold : SaleDirection.iBought, + varietyId: lot.varietyId, + counterparty: counterparty, + amount: paymentAmount, + currency: paymentCurrency, + note: note, + ); + } + + return (movementId: movementId, saleId: saleId, plantareId: plantareId); + }); + } + /// Snapshots the live inventory (tombstones excluded) for the interchange /// export — data-model §7. Includes photo bytes; the JSON codec embeds them /// as base64 and the CSV codec ignores them. diff --git a/apps/app_seeds/lib/db/enums.dart b/apps/app_seeds/lib/db/enums.dart index 4617309..1e3db8a 100644 --- a/apps/app_seeds/lib/db/enums.dart +++ b/apps/app_seeds/lib/db/enums.dart @@ -106,3 +106,10 @@ enum PlantareStatus { open, returned, forgiven } /// SEPARATE model from a gift or a Plantare — seed for money (any currency: /// €, Ğ1, a local/time currency…). No commission is ever taken on seeds. enum SaleDirection { iSold, iBought } + +/// Direction of a hand-over — the one moment seeds actually change hands +/// (data-model §2.8: a closed deal produces a Movement and, optionally, a +/// Plantare and/or a Sale record). Not stored in a column of its own: it maps +/// to [MovementType.given]/[MovementType.received] plus the derived directions +/// of the optional Sale and Plantare rows. +enum HandoverDirection { iGave, iReceived } diff --git a/apps/app_seeds/test/data/handover_test.dart b/apps/app_seeds/test/data/handover_test.dart new file mode 100644 index 0000000..5bf6282 --- /dev/null +++ b/apps/app_seeds/test/data/handover_test.dart @@ -0,0 +1,181 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:drift/drift.dart' hide isNull; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/variety_repository.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + late VarietyRepository repo; + late String varietyId; + setUp(() async { + db = newTestDatabase(); + repo = newTestRepository(db); + varietyId = await repo.addQuickVariety(label: 'Tomate rosa'); + }); + tearDown(() => db.close()); + + Future movementById(String id) => + (db.select(db.movements)..where((m) => m.id.equals(id))).getSingle(); + + Future lotById(String id) => + (db.select(db.lots)..where((l) => l.id.equals(id))).getSingle(); + + test('a plain gift records just the given movement', () async { + final lotId = await repo.addLot(varietyId: varietyId); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + counterparty: 'María', + ); + + expect(result.saleId, isNull); + expect(result.plantareId, isNull); + final movement = await movementById(result.movementId); + expect(movement.type, MovementType.given); + expect(movement.lotId, lotId); + expect(movement.notes, 'María'); + expect(movement.plantareId, isNull); + expect(await repo.watchSales().first, isEmpty); + expect(await repo.watchPlantares().first, isEmpty); + }); + + test( + 'giving all depletes the lot, privatizes it and drops its price', + () async { + final lotId = await repo.addLot( + varietyId: varietyId, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + abundance: Abundance.plentyToShare, + offerStatus: OfferStatus.sell, + priceAmount: 3, + priceCurrency: '€', + ); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + gaveAll: true, + ); + + // The movement carries what actually moved: the lot's whole quantity. + final movement = await movementById(result.movementId); + expect(movement.quantityKind, QuantityKind.grams.name); + expect(movement.quantityPrecise, 40); + + // The lot stays as an empty jar, withdrawn from the market. + final lot = await lotById(lotId); + expect(lot.quantityPrecise, 0); + expect(lot.quantityKind, QuantityKind.grams.name); + expect(lot.abundance, isNull); + expect(lot.offerStatus, OfferStatus.private); + expect(lot.priceAmount, isNull); + expect(lot.priceCurrency, isNull); + expect( + (await repo.shareableLots()).where((l) => l.lotId == lotId), + isEmpty, + ); + }, + ); + + test('giving part keeps the lot open and offered', () async { + final lotId = await repo.addLot( + varietyId: varietyId, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + offerStatus: OfferStatus.shared, + ); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + quantity: const Quantity(kind: QuantityKind.grams, count: 10), + ); + + final movement = await movementById(result.movementId); + expect(movement.quantityPrecise, 10); + final lot = await lotById(lotId); + expect(lot.quantityPrecise, 40); // untouched: the log holds the history + expect(lot.offerStatus, OfferStatus.shared); + expect( + (await repo.shareableLots()).where((l) => l.lotId == lotId), + hasLength(1), + ); + }); + + test('a payment on a give creates the matching sale record', () async { + final lotId = await repo.addLot(varietyId: varietyId); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + counterparty: 'María', + withPayment: true, + paymentAmount: 2.5, + paymentCurrency: 'Ğ1', + ); + + final sales = await repo.watchSales().first; + expect(sales, hasLength(1)); + expect(sales.single.id, result.saleId); + expect(sales.single.direction, SaleDirection.iSold); + expect(sales.single.varietyId, varietyId); + expect(sales.single.counterparty, 'María'); + expect(sales.single.amount, 2.5); + expect(sales.single.currency, 'Ğ1'); + }); + + test('a promise on a give creates a plantare owed to me, linked from the ' + 'movement', () async { + final lotId = await repo.addLot(varietyId: varietyId); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iGave, + counterparty: 'María', + withPromise: true, + promiseOwedDescription: 'un puñado la próxima temporada', + ); + + final plantares = await repo.watchPlantares().first; + expect(plantares, hasLength(1)); + expect(plantares.single.id, result.plantareId); + expect(plantares.single.direction, PlantareDirection.owedToMe); + expect(plantares.single.varietyId, varietyId); + expect(plantares.single.counterparty, 'María'); + expect(plantares.single.owedDescription, 'un puñado la próxima temporada'); + // The link the model reserved: the movement carries the plantare id. + final movement = await movementById(result.movementId); + expect(movement.plantareId, result.plantareId); + }); + + test('receiving with payment and promise mirrors the directions', () async { + final lotId = await repo.addLot( + varietyId: varietyId, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + ); + + final result = await repo.recordHandover( + lotId: lotId, + direction: HandoverDirection.iReceived, + // Meaningless on a receive; must not deplete anything. + gaveAll: true, + withPayment: true, + paymentAmount: 5, + paymentCurrency: '€', + withPromise: true, + ); + + final movement = await movementById(result.movementId); + expect(movement.type, MovementType.received); + final lot = await lotById(lotId); + expect(lot.quantityPrecise, 40); + final sales = await repo.watchSales().first; + expect(sales.single.direction, SaleDirection.iBought); + final plantares = await repo.watchPlantares().first; + expect(plantares.single.direction, PlantareDirection.iReturn); + }); +} From 491e079b50df56cbca571aed2d562ab0d6386653 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 11 Jul 2026 07:59:23 +0200 Subject: [PATCH 3/3] feat(handover): one hand-over sheet replaces the separate sale/plantare doors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Seeds changed hands' is now the single entry point on the variety detail (and per lot row): direction (gave/received), one-tap 'all of it' (default — the whole-plant/shrub case), optional partial quantity, counterparty, and reveal-on-tap money and return-promise sections that spawn the Sale/Plantare rows through recordHandover in one save. Zero-lot varieties still work (ledger rows only); several lots get a chip picker. The Sales/Plantares screens keep their own add buttons for off-app records — the sheets stay, demoted to secondary. i18n handover block in en/es/pt/ast; widget tests for the flow. --- apps/app_seeds/lib/i18n/ast.i18n.json | 13 + apps/app_seeds/lib/i18n/en.i18n.json | 13 + apps/app_seeds/lib/i18n/es.i18n.json | 13 + apps/app_seeds/lib/i18n/pt.i18n.json | 13 + apps/app_seeds/lib/i18n/strings.g.dart | 2 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 32 ++ apps/app_seeds/lib/i18n/strings_en.g.dart | 54 +++ apps/app_seeds/lib/i18n/strings_es.g.dart | 32 ++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 32 ++ apps/app_seeds/lib/ui/handover_sheet.dart | 346 ++++++++++++++++++ .../lib/ui/variety_detail_screen.dart | 308 ++++++++-------- .../test/ui/handover_sheet_test.dart | 143 ++++++++ 12 files changed, 849 insertions(+), 152 deletions(-) create mode 100644 apps/app_seeds/lib/ui/handover_sheet.dart create mode 100644 apps/app_seeds/test/ui/handover_sheet_test.dart diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 456c10d..d9598f0 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -619,6 +619,19 @@ "settledSection": "Fechos", "removeConfirm": "¿Quitar esti compromisu?" }, + "handover": { + "title": "Di o recibí semientes", + "help": "Un regalu, un truecu o una venta: apúntalo, con promesa de devolver semiente o ensin ella.", + "iGave": "Di semientes", + "iReceived": "Recibí semientes", + "whichLot": "¿De qué llote?", + "howMuch": "¿Cuánto?", + "allOfIt": "Too", + "partOfIt": "Una parte", + "paymentChip": "Hubo perres pel mediu", + "promiseGave": "Van devolveme semiente", + "promiseReceived": "Voi devolver semiente" + }, "sale": { "title": "Ventes", "help": "Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 2d57d70..6ac5db6 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -622,6 +622,19 @@ "settledSection": "Done", "removeConfirm": "Remove this commitment?" }, + "handover": { + "title": "Seeds changed hands", + "help": "A gift, a swap or a sale — note it down, with a return promise or without.", + "iGave": "I gave seeds", + "iReceived": "I received seeds", + "whichLot": "Which batch?", + "howMuch": "How much?", + "allOfIt": "All of it", + "partOfIt": "A part", + "paymentChip": "Money changed hands", + "promiseGave": "They'll return me seed", + "promiseReceived": "I'll return seed" + }, "sale": { "title": "Sales", "help": "Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 4da41b5..def88b4 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -621,6 +621,19 @@ "settledSection": "Hechos", "removeConfirm": "¿Quitar este compromiso?" }, + "handover": { + "title": "Di o recibí semillas", + "help": "Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.", + "iGave": "Di semillas", + "iReceived": "Recibí semillas", + "whichLot": "¿De qué lote?", + "howMuch": "¿Cuánto?", + "allOfIt": "Todo", + "partOfIt": "Una parte", + "paymentChip": "Hubo dinero por medio", + "promiseGave": "Me devolverán semilla", + "promiseReceived": "Devolveré semilla" + }, "sale": { "title": "Ventas", "help": "Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 43adb5b..b692cbe 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -618,6 +618,19 @@ "settledSection": "Feitos", "removeConfirm": "Remover este compromisso?" }, + "handover": { + "title": "Dei ou recebi sementes", + "help": "Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.", + "iGave": "Dei sementes", + "iReceived": "Recebi sementes", + "whichLot": "De que lote?", + "howMuch": "Quanto?", + "allOfIt": "Tudo", + "partOfIt": "Uma parte", + "paymentChip": "Houve dinheiro pelo meio", + "promiseGave": "Vão devolver-me semente", + "promiseReceived": "Vou devolver semente" + }, "sale": { "title": "Vendas", "help": "Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 8a2d815..2e9ba80 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1976 (494 per locale) +/// Strings: 2020 (505 per locale) /// /// Built on 2026-07-11 at 06:00 UTC diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 1da3ad9..e082411 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -80,6 +80,7 @@ class TranslationsAst extends Translations with BaseTranslations '¿Quitar esti compromisu?'; } +// Path: handover +class _Translations$handover$ast extends Translations$handover$en { + _Translations$handover$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get title => 'Di o recibí semientes'; + @override String get help => 'Un regalu, un truecu o una venta: apúntalo, con promesa de devolver semiente o ensin ella.'; + @override String get iGave => 'Di semientes'; + @override String get iReceived => 'Recibí semientes'; + @override String get whichLot => '¿De qué llote?'; + @override String get howMuch => '¿Cuánto?'; + @override String get allOfIt => 'Too'; + @override String get partOfIt => 'Una parte'; + @override String get paymentChip => 'Hubo perres pel mediu'; + @override String get promiseGave => 'Van devolveme semiente'; + @override String get promiseReceived => 'Voi devolver semiente'; +} + // Path: sale class _Translations$sale$ast extends Translations$sale$en { _Translations$sale$ast._(TranslationsAst root) : this._root = root, super.internal(root); @@ -1740,6 +1761,17 @@ extension on TranslationsAst { 'plantare.openSection' => 'Pendientes', 'plantare.settledSection' => 'Fechos', 'plantare.removeConfirm' => '¿Quitar esti compromisu?', + 'handover.title' => 'Di o recibí semientes', + 'handover.help' => 'Un regalu, un truecu o una venta: apúntalo, con promesa de devolver semiente o ensin ella.', + 'handover.iGave' => 'Di semientes', + 'handover.iReceived' => 'Recibí semientes', + 'handover.whichLot' => '¿De qué llote?', + 'handover.howMuch' => '¿Cuánto?', + 'handover.allOfIt' => 'Too', + 'handover.partOfIt' => 'Una parte', + 'handover.paymentChip' => 'Hubo perres pel mediu', + 'handover.promiseGave' => 'Van devolveme semiente', + 'handover.promiseReceived' => 'Voi devolver semiente', 'sale.title' => 'Ventes', 'sale.help' => 'Rexistra semilla vendida o mercada — dineru, Ğ1 o cualquier moneda. Un modelu aparte del regalu y del Plantare. Enxamás se cobra comisión poles semilles.', 'sale.add' => 'Rexistrar venta', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 732d86b..08da39e 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -81,6 +81,7 @@ class Translations with BaseTranslations { late final Translations$wot$en wot = Translations$wot$en.internal(_root); late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root); late final Translations$plantare$en plantare = Translations$plantare$en.internal(_root); + late final Translations$handover$en handover = Translations$handover$en.internal(_root); late final Translations$sale$en sale = Translations$sale$en.internal(_root); } @@ -1722,6 +1723,48 @@ class Translations$plantare$en { String get removeConfirm => 'Remove this commitment?'; } +// Path: handover +class Translations$handover$en { + Translations$handover$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Seeds changed hands' + String get title => 'Seeds changed hands'; + + /// en: 'A gift, a swap or a sale — note it down, with a return promise or without.' + String get help => 'A gift, a swap or a sale — note it down, with a return promise or without.'; + + /// en: 'I gave seeds' + String get iGave => 'I gave seeds'; + + /// en: 'I received seeds' + String get iReceived => 'I received seeds'; + + /// en: 'Which batch?' + String get whichLot => 'Which batch?'; + + /// en: 'How much?' + String get howMuch => 'How much?'; + + /// en: 'All of it' + String get allOfIt => 'All of it'; + + /// en: 'A part' + String get partOfIt => 'A part'; + + /// en: 'Money changed hands' + String get paymentChip => 'Money changed hands'; + + /// en: 'They'll return me seed' + String get promiseGave => 'They\'ll return me seed'; + + /// en: 'I'll return seed' + String get promiseReceived => 'I\'ll return seed'; +} + // Path: sale class Translations$sale$en { Translations$sale$en.internal(this._root); @@ -2718,6 +2761,17 @@ extension on Translations { 'plantare.openSection' => 'Open', 'plantare.settledSection' => 'Done', 'plantare.removeConfirm' => 'Remove this commitment?', + 'handover.title' => 'Seeds changed hands', + 'handover.help' => 'A gift, a swap or a sale — note it down, with a return promise or without.', + 'handover.iGave' => 'I gave seeds', + 'handover.iReceived' => 'I received seeds', + 'handover.whichLot' => 'Which batch?', + 'handover.howMuch' => 'How much?', + 'handover.allOfIt' => 'All of it', + 'handover.partOfIt' => 'A part', + 'handover.paymentChip' => 'Money changed hands', + 'handover.promiseGave' => 'They\'ll return me seed', + 'handover.promiseReceived' => 'I\'ll return seed', 'sale.title' => 'Sales', 'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.', 'sale.add' => 'Record a sale', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 25e7797..f339ab5 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -80,6 +80,7 @@ class TranslationsEs extends Translations with BaseTranslations '¿Quitar este compromiso?'; } +// Path: handover +class _Translations$handover$es extends Translations$handover$en { + _Translations$handover$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Di o recibí semillas'; + @override String get help => 'Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.'; + @override String get iGave => 'Di semillas'; + @override String get iReceived => 'Recibí semillas'; + @override String get whichLot => '¿De qué lote?'; + @override String get howMuch => '¿Cuánto?'; + @override String get allOfIt => 'Todo'; + @override String get partOfIt => 'Una parte'; + @override String get paymentChip => 'Hubo dinero por medio'; + @override String get promiseGave => 'Me devolverán semilla'; + @override String get promiseReceived => 'Devolveré semilla'; +} + // Path: sale class _Translations$sale$es extends Translations$sale$en { _Translations$sale$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1744,6 +1765,17 @@ extension on TranslationsEs { 'plantare.openSection' => 'Pendientes', 'plantare.settledSection' => 'Hechos', 'plantare.removeConfirm' => '¿Quitar este compromiso?', + 'handover.title' => 'Di o recibí semillas', + 'handover.help' => 'Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.', + 'handover.iGave' => 'Di semillas', + 'handover.iReceived' => 'Recibí semillas', + 'handover.whichLot' => '¿De qué lote?', + 'handover.howMuch' => '¿Cuánto?', + 'handover.allOfIt' => 'Todo', + 'handover.partOfIt' => 'Una parte', + 'handover.paymentChip' => 'Hubo dinero por medio', + 'handover.promiseGave' => 'Me devolverán semilla', + 'handover.promiseReceived' => 'Devolveré semilla', 'sale.title' => 'Ventas', 'sale.help' => 'Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.', 'sale.add' => 'Registrar venta', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 5800443..6201d1d 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -80,6 +80,7 @@ class TranslationsPt extends Translations with BaseTranslations 'Remover este compromisso?'; } +// Path: handover +class _Translations$handover$pt extends Translations$handover$en { + _Translations$handover$pt._(TranslationsPt root) : this._root = root, super.internal(root); + + final TranslationsPt _root; // ignore: unused_field + + // Translations + @override String get title => 'Dei ou recebi sementes'; + @override String get help => 'Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.'; + @override String get iGave => 'Dei sementes'; + @override String get iReceived => 'Recebi sementes'; + @override String get whichLot => 'De que lote?'; + @override String get howMuch => 'Quanto?'; + @override String get allOfIt => 'Tudo'; + @override String get partOfIt => 'Uma parte'; + @override String get paymentChip => 'Houve dinheiro pelo meio'; + @override String get promiseGave => 'Vão devolver-me semente'; + @override String get promiseReceived => 'Vou devolver semente'; +} + // Path: sale class _Translations$sale$pt extends Translations$sale$en { _Translations$sale$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1738,6 +1759,17 @@ extension on TranslationsPt { 'plantare.openSection' => 'Pendentes', 'plantare.settledSection' => 'Feitos', 'plantare.removeConfirm' => 'Remover este compromisso?', + 'handover.title' => 'Dei ou recebi sementes', + 'handover.help' => 'Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.', + 'handover.iGave' => 'Dei sementes', + 'handover.iReceived' => 'Recebi sementes', + 'handover.whichLot' => 'De que lote?', + 'handover.howMuch' => 'Quanto?', + 'handover.allOfIt' => 'Tudo', + 'handover.partOfIt' => 'Uma parte', + 'handover.paymentChip' => 'Houve dinheiro pelo meio', + 'handover.promiseGave' => 'Vão devolver-me semente', + 'handover.promiseReceived' => 'Vou devolver semente', 'sale.title' => 'Vendas', 'sale.help' => 'Regista semente vendida ou comprada — dinheiro, Ğ1 ou qualquer moeda. Um modelo separado do presente e do Plantare. Nunca se cobra comissão pelas sementes.', 'sale.add' => 'Registar venda', diff --git a/apps/app_seeds/lib/ui/handover_sheet.dart b/apps/app_seeds/lib/ui/handover_sheet.dart new file mode 100644 index 0000000..36c9440 --- /dev/null +++ b/apps/app_seeds/lib/ui/handover_sheet.dart @@ -0,0 +1,346 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; + +import '../data/variety_repository.dart'; +import '../db/enums.dart'; +import '../i18n/strings.g.dart'; +import 'currency_quick_picks.dart'; +import 'harvest_date_picker.dart'; +import 'quantity_kind_l10n.dart'; +import 'quantity_picker.dart'; +import 'theme.dart'; + +/// Opens the hand-over sheet — the ONE place to note that seeds changed hands +/// (a gift, a swap, a sale…), optionally with money and/or a return promise. +/// It records the Movement and spawns the Sale/Plantare rows in one save +/// (data-model §2.8), so the person never juggles three separate forms. +/// +/// [lots] are the variety's live batches: one → preselected; several → a +/// chip picker; none → the quantity section hides and only the optional +/// records are saved. [initialLot] preselects a batch (entry from a lot row). +Future showHandoverSheet( + BuildContext context, { + required VarietyRepository repository, + required String varietyId, + List lots = const [], + VarietyLot? initialLot, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _HandoverSheet( + repository: repository, + varietyId: varietyId, + lots: lots, + initialLot: initialLot ?? (lots.length == 1 ? lots.single : null), + ), + ); +} + +class _HandoverSheet extends StatefulWidget { + const _HandoverSheet({ + required this.repository, + required this.varietyId, + required this.lots, + this.initialLot, + }); + + final VarietyRepository repository; + final String varietyId; + final List lots; + final VarietyLot? initialLot; + + @override + State<_HandoverSheet> createState() => _HandoverSheetState(); +} + +class _HandoverSheetState extends State<_HandoverSheet> { + HandoverDirection _direction = HandoverDirection.iGave; + VarietyLot? _lot; + bool _gaveAll = true; + Quantity? _partialQuantity; + final _counterparty = TextEditingController(); + bool _withPayment = false; + final _amount = TextEditingController(); + final _currency = TextEditingController(); + bool _withPromise = false; + final _owed = TextEditingController(); + bool _saving = false; + + @override + void initState() { + super.initState(); + _lot = widget.initialLot; + } + + @override + void dispose() { + _counterparty.dispose(); + _amount.dispose(); + _currency.dispose(); + _owed.dispose(); + super.dispose(); + } + + String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim(); + + String _lotLabel(Translations t, VarietyLot lot) { + final parts = [ + if (lot.harvestYear != null) + harvestDateLabel(t, year: lot.harvestYear!, month: lot.harvestMonth) + else + t.detail.noYear, + if (lot.quantity != null) quantityDisplay(t, lot.quantity!), + ]; + return parts.join(' · '); + } + + Future _save() async { + setState(() => _saving = true); + final counterparty = _nullIfBlank(_counterparty.text); + final amount = double.tryParse(_amount.text.trim().replaceAll(',', '.')); + final currency = _nullIfBlank(_currency.text); + final owed = _nullIfBlank(_owed.text); + final lot = _lot; + if (lot != null) { + await widget.repository.recordHandover( + lotId: lot.id, + direction: _direction, + gaveAll: _gaveAll, + quantity: _gaveAll ? null : _partialQuantity, + counterparty: counterparty, + withPayment: _withPayment, + paymentAmount: amount, + paymentCurrency: currency, + withPromise: _withPromise, + promiseOwedDescription: owed, + ); + } else { + // No batch to move — record just the ledgers the person asked for. + if (_withPayment) { + await widget.repository.createSale( + direction: _direction == HandoverDirection.iGave + ? SaleDirection.iSold + : SaleDirection.iBought, + varietyId: widget.varietyId, + counterparty: counterparty, + amount: amount, + currency: currency, + ); + } + if (_withPromise) { + await widget.repository.createPlantare( + direction: _direction == HandoverDirection.iGave + ? PlantareDirection.owedToMe + : PlantareDirection.iReturn, + varietyId: widget.varietyId, + counterparty: counterparty, + owedDescription: owed, + ); + } + } + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + final gave = _direction == HandoverDirection.iGave; + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + t.handover.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + t.handover.help, + style: const TextStyle(color: seedMuted, fontSize: 13), + ), + const SizedBox(height: 12), + // Two big, human choices — same idiom as the plantare sheet. + for (final d in HandoverDirection.values) + ListTile( + key: Key('handover.direction.${d.name}'), + contentPadding: EdgeInsets.zero, + leading: Icon( + _direction == d + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: _direction == d ? seedGreen : seedMuted, + ), + title: Text( + d == HandoverDirection.iGave + ? t.handover.iGave + : t.handover.iReceived, + ), + onTap: () => setState(() => _direction = d), + ), + if (widget.lots.length > 1) ...[ + const SizedBox(height: 8), + Text( + t.handover.whichLot, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final lot in widget.lots) + ChoiceChip( + key: Key('handover.lot.${lot.id}'), + label: Text(_lotLabel(t, lot)), + selected: _lot?.id == lot.id, + onSelected: (sel) => + setState(() => _lot = sel ? lot : null), + ), + ], + ), + ], + if (_lot != null && gave) ...[ + const SizedBox(height: 8), + Text( + t.handover.howMuch, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + ChoiceChip( + key: const Key('handover.allOfIt'), + label: Text(t.handover.allOfIt), + selected: _gaveAll, + onSelected: (sel) => setState(() => _gaveAll = true), + ), + ChoiceChip( + key: const Key('handover.partOfIt'), + label: Text(t.handover.partOfIt), + selected: !_gaveAll, + onSelected: (sel) => setState(() => _gaveAll = false), + ), + ], + ), + if (!_gaveAll) ...[ + const SizedBox(height: 8), + QuantityPicker( + type: _lot!.type, + value: _partialQuantity, + onChanged: (q) => _partialQuantity = q, + ), + ], + ], + const SizedBox(height: 12), + TextField( + key: const Key('handover.counterparty'), + controller: _counterparty, + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + labelText: t.sale.counterparty, + helperText: t.sale.counterpartyHint, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + // Money and promise stay one tap away — a plain gift needs neither. + Wrap( + spacing: 8, + children: [ + if (!_withPayment) + ActionChip( + key: const Key('handover.addPayment'), + avatar: const Icon(Icons.sell_outlined, size: 18), + label: Text(t.handover.paymentChip), + onPressed: () => setState(() => _withPayment = true), + ), + if (!_withPromise) + ActionChip( + key: const Key('handover.addPromise'), + avatar: const Icon( + Icons.volunteer_activism_outlined, + size: 18, + ), + label: Text( + gave + ? t.handover.promiseGave + : t.handover.promiseReceived, + ), + onPressed: () => setState(() => _withPromise = true), + ), + ], + ), + if (_withPayment) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + key: const Key('handover.amount'), + controller: _amount, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: InputDecoration( + labelText: t.sale.amount, + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + key: const Key('handover.currency'), + controller: _currency, + decoration: InputDecoration( + labelText: t.sale.currency, + border: const OutlineInputBorder(), + ), + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + const SizedBox(height: 8), + CurrencyQuickPicks( + controller: _currency, + keyPrefix: 'handover', + onChanged: () => setState(() {}), + ), + ], + if (_withPromise) ...[ + const SizedBox(height: 12), + TextField( + key: const Key('handover.owed'), + controller: _owed, + decoration: InputDecoration( + labelText: t.plantare.owed, + helperText: t.plantare.owedHint, + helperMaxLines: 2, + border: const OutlineInputBorder(), + ), + ), + ], + const SizedBox(height: 20), + FilledButton( + key: const Key('handover.save'), + onPressed: _saving ? null : _save, + child: Text(t.common.save), + ), + ], + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 8303b00..3478bf9 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -17,11 +17,10 @@ import '../domain/species_reference_links.dart'; import '../i18n/strings.g.dart'; import '../state/variety_detail_cubit.dart'; import 'currency_quick_picks.dart'; +import 'handover_sheet.dart'; import 'harvest_date_picker.dart'; import 'photo_pick.dart'; -import 'plantare_sheet.dart'; import 'quantity_kind_l10n.dart'; -import 'sale_sheet.dart'; import 'quantity_picker.dart'; import 'seed_glyph.dart'; import 'theme.dart'; @@ -118,25 +117,19 @@ class _DetailView extends StatelessWidget { spacing: 8, runSpacing: 8, children: [ + // ONE door for "seeds changed hands": gift, swap or sale, with + // or without money or a return promise. The sales/plantares + // screens keep their own add buttons for off-app records. OutlinedButton.icon( - key: const Key('detail.addPlantare'), - icon: const Icon(Icons.volunteer_activism_outlined, size: 18), - onPressed: () => showPlantareSheet( + key: const Key('detail.handover'), + icon: const Icon(Icons.handshake_outlined, size: 18), + onPressed: () => showHandoverSheet( context, repository: context.read(), varietyId: detail.id, + lots: detail.lots, ), - label: Text(t.plantare.add), - ), - OutlinedButton.icon( - key: const Key('detail.addSale'), - icon: const Icon(Icons.sell_outlined, size: 18), - onPressed: () => showSaleSheet( - context, - repository: context.read(), - varietyId: detail.id, - ), - label: Text(t.sale.add), + label: Text(t.handover.title), ), ], ), @@ -323,22 +316,32 @@ class _LotTile extends StatelessWidget { ?warning, ], ), - // Germination only applies to seed lots, not to plantel. - trailing: lot.type != LotType.seed - ? null - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (lot.latestGerminationRate != null) - _GerminationBadge(rate: lot.latestGerminationRate!), - IconButton( - key: Key('lot.germination.${lot.id}'), - icon: const Icon(Icons.grass_outlined), - tooltip: t.germination.title, - onPressed: () => _showGerminationSheet(context, cubit, lot), - ), - ], + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (lot.type == LotType.seed && lot.latestGerminationRate != null) + _GerminationBadge(rate: lot.latestGerminationRate!), + IconButton( + key: Key('lot.handover.${lot.id}'), + icon: const Icon(Icons.handshake_outlined), + tooltip: t.handover.title, + onPressed: () => showHandoverSheet( + context, + repository: context.read(), + varietyId: cubit.varietyId, + lots: [lot], ), + ), + // Germination only applies to seed lots, not to plantel. + if (lot.type == LotType.seed) + IconButton( + key: Key('lot.germination.${lot.id}'), + icon: const Icon(Icons.grass_outlined), + tooltip: t.germination.title, + onPressed: () => _showGerminationSheet(context, cubit, lot), + ), + ], + ), ); } } @@ -497,80 +500,82 @@ Future _showGerminationSheet( t.germination.title, style: Theme.of(sheetContext).textTheme.titleLarge, ), - const SizedBox(height: 8), - if (lot.germinationTests.isEmpty) - Text(t.germination.none) - else - for (final test in lot.germinationTests) - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.grass_outlined), - title: Text( - test.rate == null ? '—' : _germinationPercent(t, test.rate!), + const SizedBox(height: 8), + if (lot.germinationTests.isEmpty) + Text(t.germination.none) + else + for (final test in lot.germinationTests) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.grass_outlined), + title: Text( + test.rate == null + ? '—' + : _germinationPercent(t, test.rate!), + ), + subtitle: + (test.sampleSize != null && test.germinatedCount != null) + ? Text('${test.germinatedCount}/${test.sampleSize}') + : null, ), - subtitle: - (test.sampleSize != null && test.germinatedCount != null) - ? Text('${test.germinatedCount}/${test.sampleSize}') - : null, - ), - const Divider(), - Row( - children: [ - Expanded( - child: TextField( - key: const Key('germination.germinated'), - controller: germinated, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: t.germination.germinated, - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), + const Divider(), + Row( + children: [ + Expanded( + child: TextField( + key: const Key('germination.germinated'), + controller: germinated, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: t.germination.germinated, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), ), - ), - const SizedBox(width: 12), - Expanded( - child: TextField( - key: const Key('germination.sampleSize'), - controller: sample, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: t.germination.sampleSize, - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), + const SizedBox(width: 12), + Expanded( + child: TextField( + key: const Key('germination.sampleSize'), + controller: sample, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: t.germination.sampleSize, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - TextButton( - onPressed: () => Navigator.of(sheetContext).pop(), - child: Text(t.common.cancel), - ), - const Spacer(), - FilledButton( - key: const Key('germination.save'), - onPressed: () { - cubit.addGerminationTest( - lotId: lot.id, - testedOn: DateTime.now().millisecondsSinceEpoch, - sampleSize: int.tryParse(sample.text.trim()), - germinatedCount: int.tryParse(germinated.text.trim()), - ); - Navigator.of(sheetContext).pop(); - }, - child: Text(t.germination.add), - ), - ], - ), - ], - ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(), + child: Text(t.common.cancel), + ), + const Spacer(), + FilledButton( + key: const Key('germination.save'), + onPressed: () { + cubit.addGerminationTest( + lotId: lot.id, + testedOn: DateTime.now().millisecondsSinceEpoch, + sampleSize: int.tryParse(sample.text.trim()), + germinatedCount: int.tryParse(germinated.text.trim()), + ); + Navigator.of(sheetContext).pop(); + }, + child: Text(t.germination.add), + ), + ], + ), + ], + ), ), ), ); @@ -1376,62 +1381,63 @@ Future _showConditionSheet( t.conditionCheck.title, style: Theme.of(sheetContext).textTheme.titleLarge, ), - const SizedBox(height: 12), - TextField( - key: const Key('conditionCheck.containers'), - controller: containers, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: t.conditionCheck.containers, - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), + const SizedBox(height: 12), + TextField( + key: const Key('conditionCheck.containers'), + controller: containers, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: t.conditionCheck.containers, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), - ), - const SizedBox(height: 16), - Text( - t.conditionCheck.desiccant, - style: Theme.of(sheetContext).textTheme.labelLarge, - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 4, - children: [ - for (final d in DesiccantState.values) - ChoiceChip( - key: Key('desiccant.${d.name}'), - label: Text(desiccantLabel(t, d)), - selected: state == d, - onSelected: (sel) => setState(() => state = sel ? d : null), + const SizedBox(height: 16), + Text( + t.conditionCheck.desiccant, + style: Theme.of(sheetContext).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final d in DesiccantState.values) + ChoiceChip( + key: Key('desiccant.${d.name}'), + label: Text(desiccantLabel(t, d)), + selected: state == d, + onSelected: (sel) => + setState(() => state = sel ? d : null), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(), + child: Text(t.common.cancel), ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - TextButton( - onPressed: () => Navigator.of(sheetContext).pop(), - child: Text(t.common.cancel), - ), - const Spacer(), - FilledButton( - key: const Key('conditionCheck.save'), - onPressed: () { - cubit.addConditionCheck( - lotId: lot.id, - checkedOn: DateTime.now().millisecondsSinceEpoch, - containerCount: int.tryParse(containers.text.trim()), - desiccantState: state, - ); - Navigator.of(sheetContext).pop(); - }, - child: Text(t.conditionCheck.add), - ), - ], - ), - ], - ), + const Spacer(), + FilledButton( + key: const Key('conditionCheck.save'), + onPressed: () { + cubit.addConditionCheck( + lotId: lot.id, + checkedOn: DateTime.now().millisecondsSinceEpoch, + containerCount: int.tryParse(containers.text.trim()), + desiccantState: state, + ); + Navigator.of(sheetContext).pop(); + }, + child: Text(t.conditionCheck.add), + ), + ], + ), + ], + ), ), ), ), diff --git a/apps/app_seeds/test/ui/handover_sheet_test.dart b/apps/app_seeds/test/ui/handover_sheet_test.dart new file mode 100644 index 0000000..d47f1cf --- /dev/null +++ b/apps/app_seeds/test/ui/handover_sheet_test.dart @@ -0,0 +1,143 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + testWidgets('the detail screen offers ONE hand-over door, no separate ' + 'sale/plantare buttons', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('detail.handover')), findsOneWidget); + expect(find.byKey(const Key('detail.addSale')), findsNothing); + expect(find.byKey(const Key('detail.addPlantare')), findsNothing); + await disposeTree(tester); + }); + + testWidgets('giving all with payment and promise records the three pieces ' + 'in one save', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + final lotId = await repo.addLot( + varietyId: id, + quantity: const Quantity(kind: QuantityKind.grams, count: 40), + offerStatus: OfferStatus.sell, + priceAmount: 3, + priceCurrency: '€', + ); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail.handover'))); + await tester.pumpAndSettle(); + + // "All of it" is the default — one tap covers the whole-plant case. + final allChip = tester.widget( + find.byKey(const Key('handover.allOfIt')), + ); + expect(allChip.selected, isTrue); + + await tester.enterText( + find.byKey(const Key('handover.counterparty')), + 'María', + ); + + await tester.tap(find.byKey(const Key('handover.addPayment'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('handover.amount')), '2,5'); + await tester.tap(find.byKey(const Key('handover.currencyChip.Ğ1'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('handover.addPromise'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('handover.owed')), + 'un puñado', + ); + + await tester.ensureVisible(find.byKey(const Key('handover.save'))); + await tester.tap(find.byKey(const Key('handover.save'))); + await tester.pumpAndSettle(); + + final sales = await repo.watchSales().first; + expect(sales.single.direction, SaleDirection.iSold); + expect(sales.single.amount, 2.5); + expect(sales.single.currency, 'Ğ1'); + expect(sales.single.counterparty, 'María'); + + final plantares = await repo.watchPlantares().first; + expect(plantares.single.direction, PlantareDirection.owedToMe); + expect(plantares.single.owedDescription, 'un puñado'); + + final lot = await (db.select( + db.lots, + )..where((l) => l.id.equals(lotId))).getSingle(); + expect(lot.quantityPrecise, 0); + expect(lot.offerStatus, OfferStatus.private); + expect(lot.priceAmount, isNull); + + final movements = await db.select(db.movements).get(); + expect(movements.single.type, MovementType.given); + expect(movements.single.plantareId, plantares.single.id); + await disposeTree(tester); + }); + + testWidgets('receiving hides the how-much choice and mirrors directions', ( + tester, + ) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + await repo.addLot(varietyId: id); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail.handover'))); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const Key('handover.direction.iReceived')), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('handover.allOfIt')), findsNothing); + + await tester.ensureVisible(find.byKey(const Key('handover.save'))); + await tester.tap(find.byKey(const Key('handover.save'))); + await tester.pumpAndSettle(); + + final movements = await db.select(db.movements).get(); + expect(movements.single.type, MovementType.received); + await disposeTree(tester); + }); +}