From 3de01bd94827bc054e3eba9f324399d8ea6e85f9 Mon Sep 17 00:00:00 2001 From: vjrj Date: Mon, 20 Jul 2026 17:48:37 +0200 Subject: [PATCH 1/3] perf(inventory): lazy list, photo thumbnails, indexes, debounced reload Scale the local inventory to 10k+ varieties: - Render the list with ListView.builder over a flattened header/item model instead of building every tile upfront. - Store a small regenerable JPEG thumbnail per photo (schema v14) and use it for the 48px list avatar; full bytes stay for offer image hosting. Existing photos are backfilled lazily at startup. Thumbnail is local-only (excluded from CRDT sync and backups by the JSON codec). - Add indexes on varieties(is_deleted,is_draft), attachments(parent_type, parent_id,kind), lots(variety_id) via @TableIndex. - Debounce watchInventoryView (~250ms) so a burst of table writes triggers one reload, not seven. - cacheWidth/cacheHeight on the list avatar decode. - Scale test raised 3k -> 10k; migration test v13 -> v14. --- .../drift_schemas/drift_schema_v14.json | 2509 +++++++++++++++++ .../lib/data/variety_repository.dart | 137 +- apps/app_seeds/lib/db/database.dart | 33 +- apps/app_seeds/lib/db/database.g.dart | 89 + apps/app_seeds/lib/db/tables.dart | 22 + apps/app_seeds/lib/di/injector.dart | 5 + .../lib/services/offer_thumbnail.dart | 20 + .../lib/ui/inventory_list_screen.dart | 55 +- .../test/data/inventory_scale_test.dart | 11 +- 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_v14.dart | 2248 +++++++++++++++ 12 files changed, 5114 insertions(+), 34 deletions(-) create mode 100644 apps/app_seeds/drift_schemas/drift_schema_v14.json create mode 100644 apps/app_seeds/test/db/schema/schema_v14.dart diff --git a/apps/app_seeds/drift_schemas/drift_schema_v14.json b/apps/app_seeds/drift_schemas/drift_schema_v14.json new file mode 100644 index 0000000..623b534 --- /dev/null +++ b/apps/app_seeds/drift_schemas/drift_schema_v14.json @@ -0,0 +1,2509 @@ +{ + "_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": "garden_outcomes", + "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": "year", + "getter_name": "year", + "moor_type": "int", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "rating", + "getter_name": "rating", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(GardenOutcomeRating.values)", + "dart_type_name": "GardenOutcomeRating" + } + }, + { + "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": "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": 9, + "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": 10, + "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": "thumbnail", + "getter_name": "thumbnail", + "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": 11, + "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": 12, + "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": [] + }, + { + "name": "pledge_id", + "getter_name": "pledgeId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "debtor_key", + "getter_name": "debtorKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "creditor_key", + "getter_name": "creditorKey", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "debtor_signature", + "getter_name": "debtorSignature", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "creditor_signature", + "getter_name": "creditorSignature", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "movement_id", + "getter_name": "movementId", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "remote_state", + "getter_name": "remoteState", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareRemoteState.values)", + "dart_type_name": "PlantareRemoteState" + } + }, + { + "name": "return_kind", + "getter_name": "returnKind", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": "const CustomExpression('\\'similar\\'')", + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EnumNameConverter(PlantareReturnKind.values)", + "dart_type_name": "PlantareReturnKind" + } + }, + { + "name": "work_hours", + "getter_name": "workHours", + "moor_type": "double", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 13, + "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" + ] + } + }, + { + "id": 14, + "references": [ + 0 + ], + "type": "index", + "data": { + "on": 0, + "name": "idx_varieties_deleted_draft", + "sql": null, + "unique": false, + "columns": [ + { + "column": "is_deleted", + "order_by": null + }, + { + "column": "is_draft", + "order_by": null + } + ] + } + }, + { + "id": 15, + "references": [ + 4 + ], + "type": "index", + "data": { + "on": 4, + "name": "idx_lots_variety", + "sql": null, + "unique": false, + "columns": [ + { + "column": "variety_id", + "order_by": null + } + ] + } + }, + { + "id": 16, + "references": [ + 10 + ], + "type": "index", + "data": { + "on": 10, + "name": "idx_attachments_parent", + "sql": null, + "unique": false, + "columns": [ + { + "column": "parent_type", + "order_by": null + }, + { + "column": "parent_id", + "order_by": null + }, + { + "column": "kind", + "order_by": null + } + ] + } + } + ], + "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": "garden_outcomes", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"garden_outcomes\" (\"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, \"year\" INTEGER NULL, \"rating\" 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, \"thumbnail\" 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, \"pledge_id\" TEXT NULL, \"debtor_key\" TEXT NULL, \"creditor_key\" TEXT NULL, \"debtor_signature\" TEXT NULL, \"creditor_signature\" TEXT NULL, \"movement_id\" TEXT NULL, \"remote_state\" TEXT NULL, \"return_kind\" TEXT NOT NULL DEFAULT 'similar', \"work_hours\" REAL 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\"));" + } + ] + }, + { + "name": "idx_varieties_deleted_draft", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_varieties_deleted_draft ON varieties (is_deleted, is_draft)" + } + ] + }, + { + "name": "idx_lots_variety", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_lots_variety ON lots (variety_id)" + } + ] + }, + { + "name": "idx_attachments_parent", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX idx_attachments_parent ON attachments (parent_type, parent_id, kind)" + } + ] + } + ] +} \ No newline at end of file diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index dd5e877..da4b511 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:async/async.dart'; import 'package:commons_core/commons_core.dart'; import 'package:drift/drift.dart'; @@ -652,13 +654,27 @@ class VarietyRepository { required this.idGen, required this.nodeId, int Function()? nowMillis, + Uint8List? Function(Uint8List)? thumbnailBuilder, }) : _now = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch), + _thumbnailBuilder = thumbnailBuilder, _clock = Hlc.zero(nodeId); final AppDatabase _db; final IdGen idGen; final String nodeId; final int Function() _now; + + /// Builds the small list-avatar thumbnail from a full photo. Injected (from + /// `services/offer_thumbnail.dart` in production) so the data layer stays free + /// of the image codec and unit tests run without decoding. Null → no + /// thumbnail is stored and the list falls back to the full photo. + final Uint8List? Function(Uint8List)? _thumbnailBuilder; + + /// The thumbnail for [photoBytes], or null when no builder is wired or the + /// bytes aren't decodable. Wrapped in a Value for direct use in a companion. + Value _thumbValue(Uint8List photoBytes) => + Value(_thumbnailBuilder?.call(photoBytes)); + Hlc _clock; /// Emits the non-deleted inventory, ordered by category then label, each with @@ -696,7 +712,11 @@ class VarietyRepository { _db.select(_db.species).watch().map((_) {}), _db.select(_db.lots).watch().map((_) {}), ]); - return triggers.asyncMap( + // Coalesce bursts: a single quick-add or handover touches several of these + // tables at once, and each would otherwise re-run the full (7-query) load. + // Debouncing collapses the burst into one reload — decisive with a large + // inventory. + return _debounce(triggers, const Duration(milliseconds: 250)).asyncMap( (_) async => (items: await _loadInventory(), drafts: await _loadDrafts()), ); } @@ -929,7 +949,10 @@ class VarietyRepository { return rows.map((l) => l.varietyId).toSet(); } - /// Loads the first photo BLOB for each of [varietyIds] (one query). + /// Loads the first photo's small [thumbnail] for each of [varietyIds] (one + /// query) — for the inventory-list avatar. Falls back to the full-resolution + /// [bytes] only when a thumbnail hasn't been generated yet (older rows, + /// pending lazy backfill), so the list stays correct meanwhile. Future> _firstPhotosFor( List varietyIds, ) async { @@ -950,17 +973,69 @@ class VarietyRepository { .get(); final byVariety = {}; for (final row in rows) { - final bytes = row.bytes; - if (bytes != null) byVariety.putIfAbsent(row.parentId, () => bytes); + final image = row.thumbnail ?? row.bytes; + if (image != null) byVariety.putIfAbsent(row.parentId, () => image); } return byVariety; } + /// Generates the missing list thumbnails for photos that predate the + /// thumbnail column (or arrived via sync/backup restore, which never carry + /// one). Processes in small batches so decoding doesn't block the UI; safe to + /// call at startup as fire-and-forget. No-op when no thumbnail builder is + /// wired. Returns how many thumbnails were written. + Future backfillThumbnails({int batchSize = 20}) async { + final build = _thumbnailBuilder; + if (build == null) return 0; + var written = 0; + while (true) { + final batch = + await (_db.select(_db.attachments) + ..where( + (a) => + a.kind.equalsValue(AttachmentKind.photo) & + a.isDeleted.equals(false) & + a.thumbnail.isNull() & + a.bytes.isNotNull(), + ) + ..limit(batchSize)) + .get(); + if (batch.isEmpty) break; + for (final row in batch) { + final thumb = build(row.bytes!); + // No decodable image → store the full bytes as the "thumbnail" so this + // row isn't re-scanned forever. It's rare (corrupt photo) and still + // bounded by the avatar's cacheWidth at render time. + await (_db.update(_db.attachments)..where((a) => a.id.equals(row.id))) + .write(AttachmentsCompanion(thumbnail: Value(thumb ?? row.bytes))); + written++; + } + if (batch.length < batchSize) break; + } + return written; + } + /// The cover photo (lowest `sortOrder`) for a single variety, or null when it - /// has none. Used by the publish step to host an offer's image; reuses the - /// same "first photo" rule as the inventory avatar. - Future coverPhotoFor(String varietyId) async => - (await _firstPhotosFor([varietyId]))[varietyId]; + /// has none. Used by the publish step to host an offer's image, so it returns + /// the FULL-resolution [bytes] (not the list thumbnail). + Future coverPhotoFor(String varietyId) async { + final rows = + await (_db.select(_db.attachments) + ..where( + (a) => + a.parentId.equals(varietyId) & + a.parentType.equalsValue(ParentType.variety) & + a.kind.equalsValue(AttachmentKind.photo) & + a.isDeleted.equals(false), + ) + ..orderBy([ + (a) => OrderingTerm(expression: a.sortOrder), + (a) => OrderingTerm(expression: a.createdAt), + ]) + ..limit(1)) + .get(); + return rows.isEmpty ? null : rows.first.bytes; + } /// Maps each of [speciesIds] to its scientific name (one query). Future> _scientificNamesFor( @@ -1040,6 +1115,7 @@ class VarietyRepository { parentId: varietyId, kind: AttachmentKind.photo, bytes: Value(photoBytes), + thumbnail: _thumbValue(photoBytes), mimeType: const Value('image/jpeg'), ), ); @@ -1080,6 +1156,7 @@ class VarietyRepository { parentId: varietyId, kind: AttachmentKind.photo, bytes: Value(photoBytes), + thumbnail: _thumbValue(photoBytes), mimeType: const Value('image/jpeg'), ), ); @@ -1795,6 +1872,7 @@ class VarietyRepository { parentId: varietyId, kind: AttachmentKind.photo, bytes: Value(bytes), + thumbnail: _thumbValue(bytes), mimeType: const Value('image/jpeg'), ), ); @@ -3006,3 +3084,46 @@ class VarietyRepository { return maxPacked == null ? null : Hlc.parse(maxPacked); } } + +/// Emits the latest event from [source] only once [duration] has elapsed with +/// no newer event — collapsing a burst of rapid change-triggers into a single +/// downstream reload. Trailing-edge; single-subscription (matches the merged +/// Drift trigger stream it wraps). +Stream _debounce(Stream source, Duration duration) { + late StreamController controller; + StreamSubscription? sub; + Timer? timer; + T? pending; + var hasPending = false; + + void flush() { + if (hasPending) { + hasPending = false; + controller.add(pending as T); + } + } + + controller = StreamController( + onListen: () { + sub = source.listen( + (value) { + pending = value; + hasPending = true; + timer?.cancel(); + timer = Timer(duration, flush); + }, + onError: controller.addError, + onDone: () { + timer?.cancel(); + flush(); + controller.close(); + }, + ); + }, + onCancel: () async { + timer?.cancel(); + await sub?.cancel(); + }, + ); + return controller.stream; +} diff --git a/apps/app_seeds/lib/db/database.dart b/apps/app_seeds/lib/db/database.dart index 01c0d85..53c061f 100644 --- a/apps/app_seeds/lib/db/database.dart +++ b/apps/app_seeds/lib/db/database.dart @@ -32,7 +32,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 = 13; + static const int currentSchemaVersion = 14; @override int get schemaVersion => currentSchemaVersion; @@ -196,9 +196,40 @@ class AppDatabase extends _$AppDatabase { await m.createTable(gardenOutcomes); } } + // v14: scalability for large inventories. A local, regenerable [thumbnail] + // BLOB on attachments (so the list never decodes full-resolution photos) + // plus indexes on the columns the inventory list filters/joins on. All + // additive and guarded, so a half-migrated dev database re-runs cleanly + // (see the v7 note above). Existing thumbnails are backfilled lazily in + // the background, not here (image decoding in a migration is slow/fragile). + if (from < 14) { + if (!await _hasColumn('attachments', 'thumbnail')) { + await m.addColumn(attachments, attachments.thumbnail); + } + // Declared as `@TableIndex` on the tables, so a fresh install gets them + // via createAll(); existing databases get them here. Guarded against a + // half-migrated dev database (see the v7 note above). + for (final index in [ + idxVarietiesDeletedDraft, + idxAttachmentsParent, + idxLotsVariety, + ]) { + if (!await _hasIndex(index.entityName)) await m.create(index); + } + } }, ); + /// Whether an index named [name] already exists — keeps the additive v14 + /// index creation idempotent against partially-migrated databases. + Future _hasIndex(String name) async { + final rows = await customSelect( + "SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?", + variables: [Variable.withString(name)], + ).get(); + return rows.isNotEmpty; + } + /// Whether a table named [table] already exists. Keeps the additive v8 /// table creation idempotent against partially-migrated databases. Future _hasTable(String table) async { diff --git a/apps/app_seeds/lib/db/database.g.dart b/apps/app_seeds/lib/db/database.g.dart index da165ef..17b35c8 100644 --- a/apps/app_seeds/lib/db/database.g.dart +++ b/apps/app_seeds/lib/db/database.g.dart @@ -7975,6 +7975,17 @@ class $AttachmentsTable extends Attachments type: DriftSqlType.blob, requiredDuringInsert: false, ); + static const VerificationMeta _thumbnailMeta = const VerificationMeta( + 'thumbnail', + ); + @override + late final GeneratedColumn thumbnail = GeneratedColumn( + 'thumbnail', + aliasedName, + true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + ); static const VerificationMeta _mimeTypeMeta = const VerificationMeta( 'mimeType', ); @@ -8011,6 +8022,7 @@ class $AttachmentsTable extends Attachments kind, uri, bytes, + thumbnail, mimeType, sortOrder, ]; @@ -8090,6 +8102,12 @@ class $AttachmentsTable extends Attachments bytes.isAcceptableOrUnknown(data['bytes']!, _bytesMeta), ); } + if (data.containsKey('thumbnail')) { + context.handle( + _thumbnailMeta, + thumbnail.isAcceptableOrUnknown(data['thumbnail']!, _thumbnailMeta), + ); + } if (data.containsKey('mime_type')) { context.handle( _mimeTypeMeta, @@ -8159,6 +8177,10 @@ class $AttachmentsTable extends Attachments DriftSqlType.blob, data['${effectivePrefix}bytes'], ), + thumbnail: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}thumbnail'], + ), mimeType: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}mime_type'], @@ -8193,6 +8215,13 @@ class Attachment extends DataClass implements Insertable { final AttachmentKind kind; final String? uri; final Uint8List? bytes; + + /// A small JPEG thumbnail of [bytes] (photos only), decoded once on save so + /// the inventory list never has to decode the full-resolution photo for a + /// 48px avatar. Purely derived, local and regenerable — it is EXCLUDED from + /// CRDT sync payloads and backups (see [SyncColumns] usage); a peer or a + /// restored backup regenerates it lazily via `backfillThumbnails`. + final Uint8List? thumbnail; final String? mimeType; /// Display order among sibling attachments (lower first). The lowest-ordered @@ -8212,6 +8241,7 @@ class Attachment extends DataClass implements Insertable { required this.kind, this.uri, this.bytes, + this.thumbnail, this.mimeType, required this.sortOrder, }); @@ -8241,6 +8271,9 @@ class Attachment extends DataClass implements Insertable { if (!nullToAbsent || bytes != null) { map['bytes'] = Variable(bytes); } + if (!nullToAbsent || thumbnail != null) { + map['thumbnail'] = Variable(thumbnail); + } if (!nullToAbsent || mimeType != null) { map['mime_type'] = Variable(mimeType); } @@ -8263,6 +8296,9 @@ class Attachment extends DataClass implements Insertable { bytes: bytes == null && nullToAbsent ? const Value.absent() : Value(bytes), + thumbnail: thumbnail == null && nullToAbsent + ? const Value.absent() + : Value(thumbnail), mimeType: mimeType == null && nullToAbsent ? const Value.absent() : Value(mimeType), @@ -8291,6 +8327,7 @@ class Attachment extends DataClass implements Insertable { ), uri: serializer.fromJson(json['uri']), bytes: serializer.fromJson(json['bytes']), + thumbnail: serializer.fromJson(json['thumbnail']), mimeType: serializer.fromJson(json['mimeType']), sortOrder: serializer.fromJson(json['sortOrder']), ); @@ -8314,6 +8351,7 @@ class Attachment extends DataClass implements Insertable { ), 'uri': serializer.toJson(uri), 'bytes': serializer.toJson(bytes), + 'thumbnail': serializer.toJson(thumbnail), 'mimeType': serializer.toJson(mimeType), 'sortOrder': serializer.toJson(sortOrder), }; @@ -8331,6 +8369,7 @@ class Attachment extends DataClass implements Insertable { AttachmentKind? kind, Value uri = const Value.absent(), Value bytes = const Value.absent(), + Value thumbnail = const Value.absent(), Value mimeType = const Value.absent(), int? sortOrder, }) => Attachment( @@ -8345,6 +8384,7 @@ class Attachment extends DataClass implements Insertable { kind: kind ?? this.kind, uri: uri.present ? uri.value : this.uri, bytes: bytes.present ? bytes.value : this.bytes, + thumbnail: thumbnail.present ? thumbnail.value : this.thumbnail, mimeType: mimeType.present ? mimeType.value : this.mimeType, sortOrder: sortOrder ?? this.sortOrder, ); @@ -8367,6 +8407,7 @@ class Attachment extends DataClass implements Insertable { kind: data.kind.present ? data.kind.value : this.kind, uri: data.uri.present ? data.uri.value : this.uri, bytes: data.bytes.present ? data.bytes.value : this.bytes, + thumbnail: data.thumbnail.present ? data.thumbnail.value : this.thumbnail, mimeType: data.mimeType.present ? data.mimeType.value : this.mimeType, sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder, ); @@ -8386,6 +8427,7 @@ class Attachment extends DataClass implements Insertable { ..write('kind: $kind, ') ..write('uri: $uri, ') ..write('bytes: $bytes, ') + ..write('thumbnail: $thumbnail, ') ..write('mimeType: $mimeType, ') ..write('sortOrder: $sortOrder') ..write(')')) @@ -8405,6 +8447,7 @@ class Attachment extends DataClass implements Insertable { kind, uri, $driftBlobEquality.hash(bytes), + $driftBlobEquality.hash(thumbnail), mimeType, sortOrder, ); @@ -8423,6 +8466,7 @@ class Attachment extends DataClass implements Insertable { other.kind == this.kind && other.uri == this.uri && $driftBlobEquality.equals(other.bytes, this.bytes) && + $driftBlobEquality.equals(other.thumbnail, this.thumbnail) && other.mimeType == this.mimeType && other.sortOrder == this.sortOrder); } @@ -8439,6 +8483,7 @@ class AttachmentsCompanion extends UpdateCompanion { final Value kind; final Value uri; final Value bytes; + final Value thumbnail; final Value mimeType; final Value sortOrder; final Value rowid; @@ -8454,6 +8499,7 @@ class AttachmentsCompanion extends UpdateCompanion { this.kind = const Value.absent(), this.uri = const Value.absent(), this.bytes = const Value.absent(), + this.thumbnail = const Value.absent(), this.mimeType = const Value.absent(), this.sortOrder = const Value.absent(), this.rowid = const Value.absent(), @@ -8470,6 +8516,7 @@ class AttachmentsCompanion extends UpdateCompanion { required AttachmentKind kind, this.uri = const Value.absent(), this.bytes = const Value.absent(), + this.thumbnail = const Value.absent(), this.mimeType = const Value.absent(), this.sortOrder = const Value.absent(), this.rowid = const Value.absent(), @@ -8492,6 +8539,7 @@ class AttachmentsCompanion extends UpdateCompanion { Expression? kind, Expression? uri, Expression? bytes, + Expression? thumbnail, Expression? mimeType, Expression? sortOrder, Expression? rowid, @@ -8508,6 +8556,7 @@ class AttachmentsCompanion extends UpdateCompanion { if (kind != null) 'kind': kind, if (uri != null) 'uri': uri, if (bytes != null) 'bytes': bytes, + if (thumbnail != null) 'thumbnail': thumbnail, if (mimeType != null) 'mime_type': mimeType, if (sortOrder != null) 'sort_order': sortOrder, if (rowid != null) 'rowid': rowid, @@ -8526,6 +8575,7 @@ class AttachmentsCompanion extends UpdateCompanion { Value? kind, Value? uri, Value? bytes, + Value? thumbnail, Value? mimeType, Value? sortOrder, Value? rowid, @@ -8542,6 +8592,7 @@ class AttachmentsCompanion extends UpdateCompanion { kind: kind ?? this.kind, uri: uri ?? this.uri, bytes: bytes ?? this.bytes, + thumbnail: thumbnail ?? this.thumbnail, mimeType: mimeType ?? this.mimeType, sortOrder: sortOrder ?? this.sortOrder, rowid: rowid ?? this.rowid, @@ -8588,6 +8639,9 @@ class AttachmentsCompanion extends UpdateCompanion { if (bytes.present) { map['bytes'] = Variable(bytes.value); } + if (thumbnail.present) { + map['thumbnail'] = Variable(thumbnail.value); + } if (mimeType.present) { map['mime_type'] = Variable(mimeType.value); } @@ -8614,6 +8668,7 @@ class AttachmentsCompanion extends UpdateCompanion { ..write('kind: $kind, ') ..write('uri: $uri, ') ..write('bytes: $bytes, ') + ..write('thumbnail: $thumbnail, ') ..write('mimeType: $mimeType, ') ..write('sortOrder: $sortOrder, ') ..write('rowid: $rowid') @@ -11435,6 +11490,18 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ExternalLinksTable externalLinks = $ExternalLinksTable(this); late final $PlantaresTable plantares = $PlantaresTable(this); late final $SalesTable sales = $SalesTable(this); + late final Index idxVarietiesDeletedDraft = Index( + 'idx_varieties_deleted_draft', + 'CREATE INDEX idx_varieties_deleted_draft ON varieties (is_deleted, is_draft)', + ); + late final Index idxLotsVariety = Index( + 'idx_lots_variety', + 'CREATE INDEX idx_lots_variety ON lots (variety_id)', + ); + late final Index idxAttachmentsParent = Index( + 'idx_attachments_parent', + 'CREATE INDEX idx_attachments_parent ON attachments (parent_type, parent_id, kind)', + ); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -11454,6 +11521,9 @@ abstract class _$AppDatabase extends GeneratedDatabase { externalLinks, plantares, sales, + idxVarietiesDeletedDraft, + idxLotsVariety, + idxAttachmentsParent, ]; } @@ -15118,6 +15188,7 @@ typedef $$AttachmentsTableCreateCompanionBuilder = required AttachmentKind kind, Value uri, Value bytes, + Value thumbnail, Value mimeType, Value sortOrder, Value rowid, @@ -15135,6 +15206,7 @@ typedef $$AttachmentsTableUpdateCompanionBuilder = Value kind, Value uri, Value bytes, + Value thumbnail, Value mimeType, Value sortOrder, Value rowid, @@ -15206,6 +15278,11 @@ class $$AttachmentsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get thumbnail => $composableBuilder( + column: $table.thumbnail, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get mimeType => $composableBuilder( column: $table.mimeType, builder: (column) => ColumnFilters(column), @@ -15281,6 +15358,11 @@ class $$AttachmentsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get thumbnail => $composableBuilder( + column: $table.thumbnail, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get mimeType => $composableBuilder( column: $table.mimeType, builder: (column) => ColumnOrderings(column), @@ -15341,6 +15423,9 @@ class $$AttachmentsTableAnnotationComposer GeneratedColumn get bytes => $composableBuilder(column: $table.bytes, builder: (column) => column); + GeneratedColumn get thumbnail => + $composableBuilder(column: $table.thumbnail, builder: (column) => column); + GeneratedColumn get mimeType => $composableBuilder(column: $table.mimeType, builder: (column) => column); @@ -15390,6 +15475,7 @@ class $$AttachmentsTableTableManager Value kind = const Value.absent(), Value uri = const Value.absent(), Value bytes = const Value.absent(), + Value thumbnail = const Value.absent(), Value mimeType = const Value.absent(), Value sortOrder = const Value.absent(), Value rowid = const Value.absent(), @@ -15405,6 +15491,7 @@ class $$AttachmentsTableTableManager kind: kind, uri: uri, bytes: bytes, + thumbnail: thumbnail, mimeType: mimeType, sortOrder: sortOrder, rowid: rowid, @@ -15422,6 +15509,7 @@ class $$AttachmentsTableTableManager required AttachmentKind kind, Value uri = const Value.absent(), Value bytes = const Value.absent(), + Value thumbnail = const Value.absent(), Value mimeType = const Value.absent(), Value sortOrder = const Value.absent(), Value rowid = const Value.absent(), @@ -15437,6 +15525,7 @@ class $$AttachmentsTableTableManager kind: kind, uri: uri, bytes: bytes, + thumbnail: thumbnail, mimeType: mimeType, sortOrder: sortOrder, rowid: rowid, diff --git a/apps/app_seeds/lib/db/tables.dart b/apps/app_seeds/lib/db/tables.dart index 4d2c49f..125f418 100644 --- a/apps/app_seeds/lib/db/tables.dart +++ b/apps/app_seeds/lib/db/tables.dart @@ -5,6 +5,10 @@ import 'sync_columns.dart'; /// The identity/accession — one row per distinct thing in the inventory. /// Only [label] is mandatory (progressive disclosure). +/// +/// The index covers the inventory list's hot filter — non-deleted, non-draft +/// rows — so it stays fast with a large catalogue. +@TableIndex(name: 'idx_varieties_deleted_draft', columns: {#isDeleted, #isDraft}) class Varieties extends Table with SyncColumns { TextColumn get label => text()(); TextColumn get speciesId => text().nullable()(); // → Species @@ -77,6 +81,10 @@ class SpeciesCommonNames extends Table with SyncColumns { /// A homogeneous batch held for a Variety — its own year and its own unit. /// Quantity (commons_core value type) is flattened into columns here. +/// +/// Indexed by [varietyId] — the inventory list joins lots per variety (types, +/// shared status, viability), so this avoids a full scan per reload. +@TableIndex(name: 'idx_lots_variety', columns: {#varietyId}) class Lots extends Table with SyncColumns { TextColumn get varietyId => text()(); TextColumn get type => @@ -181,12 +189,26 @@ class Parties extends Table with SyncColumns { /// rest by SQLCipher) via [bytes]; external files use [uri]. Storing bytes here /// keeps the "no plaintext at rest" rule for photos in Block 1; an external /// encrypted file store is a later optimization. +/// +/// Indexed by (parentType, parentId, kind) — the list's "first photo per +/// variety" lookup filters on exactly these. +@TableIndex( + name: 'idx_attachments_parent', + columns: {#parentType, #parentId, #kind}, +) class Attachments extends Table with SyncColumns { TextColumn get parentType => textEnum()(); TextColumn get parentId => text()(); TextColumn get kind => textEnum()(); TextColumn get uri => text().nullable()(); BlobColumn get bytes => blob().nullable()(); + + /// A small JPEG thumbnail of [bytes] (photos only), decoded once on save so + /// the inventory list never has to decode the full-resolution photo for a + /// 48px avatar. Purely derived, local and regenerable — it is EXCLUDED from + /// CRDT sync payloads and backups (see [SyncColumns] usage); a peer or a + /// restored backup regenerates it lazily via `backfillThumbnails`. + BlobColumn get thumbnail => blob().nullable()(); TextColumn get mimeType => text().nullable()(); /// Display order among sibling attachments (lower first). The lowest-ordered diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 528baf5..844a4fb 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -28,6 +28,7 @@ import '../services/file_service.dart'; import '../services/inbox_service.dart'; import '../services/locale_store.dart'; import '../services/notification_service.dart'; +import '../services/offer_thumbnail.dart'; import '../services/ocr/label_text_extractor.dart'; import '../services/ocr/ocr_language.dart'; import '../services/ocr/tesseract_label_extractor.dart'; @@ -152,7 +153,11 @@ Future configureDependencies() async { database, idGen: IdGen(), nodeId: nodeId, + thumbnailBuilder: inventoryThumbnailBytes, ); + // Backfill list thumbnails for photos that predate the thumbnail column (or + // arrived via a restored backup). Fire-and-forget so startup isn't blocked. + unawaited(varietyRepository.backfillThumbnails()); const fileService = FilePickerFileService(); diff --git a/apps/app_seeds/lib/services/offer_thumbnail.dart b/apps/app_seeds/lib/services/offer_thumbnail.dart index 81e756a..27a6b04 100644 --- a/apps/app_seeds/lib/services/offer_thumbnail.dart +++ b/apps/app_seeds/lib/services/offer_thumbnail.dart @@ -34,6 +34,26 @@ String? offerThumbnailDataUri( } } +/// Builds a small JPEG thumbnail (longest edge [edge]px) for the inventory-list +/// avatar, so the list never has to decode the full-resolution photo. Returns +/// raw JPEG bytes, or null on undecodable input (the caller falls back to the +/// full photo). Kept as local bytes — regenerable, so it is excluded from sync +/// and backups. +Uint8List? inventoryThumbnailBytes( + Uint8List bytes, { + int edge = 96, + int quality = 72, +}) { + try { + final decoded = img.decodeImage(bytes); + if (decoded == null) return null; + final resized = _fitWithin(decoded, edge); + return Uint8List.fromList(img.encodeJpg(resized, quality: quality)); + } catch (_) { + return null; + } +} + /// Extracts the raw bytes from a `data:...;base64,…` URI, or null when [uri] is /// not a base64 data URI. Used by the UI to render an inline thumbnail with /// `Image.memory` instead of a network fetch. diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index bcda588..2ec0925 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -582,27 +582,47 @@ class _InventoryBody extends StatelessWidget { } // Items arrive ordered by category then label; insert a header whenever the - // category changes. - final rows = []; + // category changes. Precompute a flat row model (header or item) so the + // ListView can build tiles lazily — with a large inventory, building every + // tile upfront (the old `ListView(children:)`) stalled the first paint. + final rows = <_InventoryRow>[]; String? currentCategory; for (final item in items) { final category = item.category ?? t.inventory.uncategorized; if (category != currentCategory) { currentCategory = category; - rows.add(_CategoryHeader(title: category)); + rows.add(_InventoryRow.header(category)); } - rows.add( - _VarietyTile( - item: item, - selectionMode: selectionMode, - selected: selectedIds.contains(item.id), - ), - ); + rows.add(_InventoryRow.variety(item)); } - return ListView(children: rows); + return ListView.builder( + itemCount: rows.length, + itemBuilder: (context, i) { + final row = rows[i]; + final header = row.header; + if (header != null) { + return _CategoryHeader(title: header); + } + return _VarietyTile( + item: row.item!, + selectionMode: selectionMode, + selected: selectedIds.contains(row.item!.id), + ); + }, + ); } } +/// A single row in the flattened inventory list: either a category [header] or +/// a variety [item] (exactly one is non-null). +class _InventoryRow { + const _InventoryRow.header(this.header) : item = null; + const _InventoryRow.variety(this.item) : header = null; + + final String? header; + final VarietyListItem? item; +} + class _CategoryHeader extends StatelessWidget { const _CategoryHeader({required this.title}); @@ -767,10 +787,21 @@ class _Avatar extends StatelessWidget { // Decorative: the tile title already announces the variety name, so keep // the thumbnail / initial out of the semantics tree. if (photo != null) { + // Decode straight to the 48px avatar size (× device pixel ratio) instead + // of holding the full-resolution photo in the image cache — decisive for + // memory with a large inventory of photographed varieties. + final cachePx = (48 * MediaQuery.devicePixelRatioOf(context)).round(); return ExcludeSemantics( child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover), + child: Image.memory( + photo, + width: 48, + height: 48, + cacheWidth: cachePx, + cacheHeight: cachePx, + fit: BoxFit.cover, + ), ), ); } diff --git a/apps/app_seeds/test/data/inventory_scale_test.dart b/apps/app_seeds/test/data/inventory_scale_test.dart index d6928df..576e9b7 100644 --- a/apps/app_seeds/test/data/inventory_scale_test.dart +++ b/apps/app_seeds/test/data/inventory_scale_test.dart @@ -19,8 +19,8 @@ void main() { }); tearDown(() => db.close()); - test('the inventory view loads 3000 varieties quickly', () async { - const count = 3000; + test('the inventory view loads 10000 varieties quickly', () async { + const count = 10000; for (var i = 0; i < count; i++) { final id = await repo.addQuickVariety( label: 'Variety $i', @@ -43,11 +43,12 @@ void main() { sw.stop(); expect(view.items, hasLength(count)); - // Generous ceiling for CI; typical local runs are well under 500ms. The - // point is to catch an accidental N+1 regression, not to micro-benchmark. + // Generous ceiling for CI (the point is to catch an N+1 regression, not to + // micro-benchmark). Includes the ~250ms debounce on the first emit; typical + // local runs are well under 2s for 10k rows with the v14 indexes. expect( sw.elapsedMilliseconds, - lessThan(3000), + lessThan(6000), reason: 'inventory view took ${sw.elapsedMilliseconds}ms for $count rows', ); }); diff --git a/apps/app_seeds/test/db/migration_test.dart b/apps/app_seeds/test/db/migration_test.dart index 7b7f0cd..4653582 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 v13', () async { - final schema = await verifier.schemaAt(13); + test('freshly created database matches the exported schema v14', () async { + final schema = await verifier.schemaAt(14); final db = AppDatabase(schema.newConnection()); - await verifier.migrateAndValidate(db, 13); + await verifier.migrateAndValidate(db, 14); await db.close(); }); - // Every historical version upgrades cleanly to the current schema (v13). - for (var from = 1; from <= 12; from++) { - test('upgrades v$from → v13 and matches the fresh schema', () async { + // Every historical version upgrades cleanly to the current schema (v14). + for (var from = 1; from <= 13; from++) { + test('upgrades v$from → v14 and matches the fresh schema', () async { final connection = await verifier.startAt(from); final db = AppDatabase(connection); - await verifier.migrateAndValidate(db, 13); + await verifier.migrateAndValidate(db, 14); await db.close(); }); } diff --git a/apps/app_seeds/test/db/schema/schema.dart b/apps/app_seeds/test/db/schema/schema.dart index ba56c19..938ab12 100644 --- a/apps/app_seeds/test/db/schema/schema.dart +++ b/apps/app_seeds/test/db/schema/schema.dart @@ -17,6 +17,7 @@ import 'schema_v10.dart' as v10; import 'schema_v11.dart' as v11; import 'schema_v12.dart' as v12; import 'schema_v13.dart' as v13; +import 'schema_v14.dart' as v14; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -48,10 +49,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v12.DatabaseAtV12(db); case 13: return v13.DatabaseAtV13(db); + case 14: + return v14.DatabaseAtV14(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]; } diff --git a/apps/app_seeds/test/db/schema/schema_v14.dart b/apps/app_seeds/test/db/schema/schema_v14.dart new file mode 100644 index 0000000..ad1dee1 --- /dev/null +++ b/apps/app_seeds/test/db/schema/schema_v14.dart @@ -0,0 +1,2248 @@ +// 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 GardenOutcomes extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GardenOutcomes(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 year = GeneratedColumn( + 'year', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn rating = GeneratedColumn( + 'rating', + 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, + year, + rating, + notes, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'garden_outcomes'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + GardenOutcomes createAlias(String alias) { + return GardenOutcomes(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 thumbnail = + GeneratedColumn( + 'thumbnail', + 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, + thumbnail, + 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', + ); + late final GeneratedColumn pledgeId = GeneratedColumn( + 'pledge_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn debtorKey = GeneratedColumn( + 'debtor_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn creditorKey = GeneratedColumn( + 'creditor_key', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn debtorSignature = GeneratedColumn( + 'debtor_signature', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn creditorSignature = + GeneratedColumn( + 'creditor_signature', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn movementId = GeneratedColumn( + 'movement_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn remoteState = GeneratedColumn( + 'remote_state', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn returnKind = GeneratedColumn( + 'return_kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL DEFAULT \'similar\'', + defaultValue: const CustomExpression('\'similar\''), + ); + late final GeneratedColumn workHours = GeneratedColumn( + 'work_hours', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + lastAuthor, + isDeleted, + schemaRowVersion, + varietyId, + direction, + counterparty, + owedDescription, + madeOn, + dueBy, + status, + settledOn, + note, + pledgeId, + debtorKey, + creditorKey, + debtorSignature, + creditorSignature, + movementId, + remoteState, + returnKind, + workHours, + ]; + @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 DatabaseAtV14 extends GeneratedDatabase { + DatabaseAtV14(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 GardenOutcomes gardenOutcomes = GardenOutcomes(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); + late final Index idxVarietiesDeletedDraft = Index( + 'idx_varieties_deleted_draft', + 'CREATE INDEX idx_varieties_deleted_draft ON varieties (is_deleted, is_draft)', + ); + late final Index idxLotsVariety = Index( + 'idx_lots_variety', + 'CREATE INDEX idx_lots_variety ON lots (variety_id)', + ); + late final Index idxAttachmentsParent = Index( + 'idx_attachments_parent', + 'CREATE INDEX idx_attachments_parent ON attachments (parent_type, parent_id, kind)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + varieties, + varietyVernacularNames, + species, + speciesCommonNames, + lots, + germinationTests, + conditionChecks, + gardenOutcomes, + movements, + parties, + attachments, + externalLinks, + plantares, + sales, + idxVarietiesDeletedDraft, + idxLotsVariety, + idxAttachmentsParent, + ]; + @override + int get schemaVersion => 14; +} From f17ae7751cb1462bec0220c594ba6aae6a3e44d3 Mon Sep 17 00:00:00 2001 From: vjrj Date: Mon, 20 Jul 2026 22:47:12 +0200 Subject: [PATCH 2/3] perf(market): paginate Nostr offer discovery, infinite scroll, memory cap Bound market memory and let large areas page instead of loading everything: - DiscoveryQuery gains an until cursor; OfferTransport gains discoverPage() (one-shot, EOSE-bounded, newest-first) alongside the existing live discover() stream, which now accepts since so it only carries NEW offers going forward. - NostrOfferTransport.discoverPage sorts by created_at desc and derives the next cursor from the oldest event seen (not the oldest type-matched one, so filtering never skips a page). - OffersCubit: discover() fetches the first page + opens a since=now live sub; loadNextPage() pages further back; the in-memory list is capped at 400 offers (each can carry a ~40KB inline photo thumbnail, so unbounded growth in a busy area was a real OOM risk). - market_screen: infinite scroll via a scroll-position trigger + footer spinner, instead of a single unbounded ListView. - Added discoverPage unit tests (commons_core) and cubit pagination tests (first page/cursor, accumulation, cap, cross-page dedup). --- apps/app_seeds/lib/state/offers_cubit.dart | 144 +++++++++++++++--- apps/app_seeds/lib/ui/market_screen.dart | 53 +++++-- .../test/screenshots/screenshots_test.dart | 6 +- .../test/state/offers_cubit_test.dart | 138 ++++++++++++++++- .../test/ui/inventory_list_lazy_test.dart | 45 ++++++ .../social/nostr/nostr_offer_transport.dart | 31 +++- .../commons_core/lib/src/social/offer.dart | 18 +++ .../lib/src/social/offer_transport.dart | 12 +- .../nostr_offer_transport_page_test.dart | 109 +++++++++++++ 9 files changed, 513 insertions(+), 43 deletions(-) create mode 100644 apps/app_seeds/test/ui/inventory_list_lazy_test.dart create mode 100644 packages/commons_core/test/social/nostr_offer_transport_page_test.dart diff --git a/apps/app_seeds/lib/state/offers_cubit.dart b/apps/app_seeds/lib/state/offers_cubit.dart index d5fecc2..d6c462b 100644 --- a/apps/app_seeds/lib/state/offers_cubit.dart +++ b/apps/app_seeds/lib/state/offers_cubit.dart @@ -24,12 +24,16 @@ class OffersState extends Equatable { this.blockedAuthors = const {}, this.hiddenOfferKeys = const {}, this.searching = false, + this.loadingMore = false, + this.nextPageCursor, this.publishing = false, this.hasSearched = false, this.error, }); - /// Offers discovered so far for [areaGeohash], newest appended as they arrive. + /// Offers discovered so far for [areaGeohash], newest first. Bounded in size + /// (see [OffersCubit.maxOffersKept]) so a busy area can't grow the list — and + /// the inline photo thumbnails it carries — without limit. final List offers; /// The coarse area currently being browsed. @@ -57,8 +61,21 @@ class OffersState extends Equatable { final Set hiddenOfferKeys; final bool searching; + + /// True while a "load more" page is in flight, so the UI shows a footer + /// spinner and doesn't fire overlapping page requests. + final bool loadingMore; + + /// Cursor (Unix seconds) for the next older page, or null when the newest + /// page was short (nothing older) or the in-memory cap was reached — in both + /// cases there is no more to load. + final int? nextPageCursor; + final bool publishing; + /// Whether more offers can be paged in (drives the infinite-scroll trigger). + bool get canLoadMore => nextPageCursor != null; + /// True once a discovery has been started, so the UI can tell "no search yet" /// from "searched, found nothing". final bool hasSearched; @@ -115,6 +132,8 @@ class OffersState extends Equatable { Set? blockedAuthors, Set? hiddenOfferKeys, bool? searching, + bool? loadingMore, + int? Function()? nextPageCursor, bool? publishing, bool? hasSearched, String? Function()? error, @@ -129,6 +148,9 @@ class OffersState extends Equatable { blockedAuthors: blockedAuthors ?? this.blockedAuthors, hiddenOfferKeys: hiddenOfferKeys ?? this.hiddenOfferKeys, searching: searching ?? this.searching, + loadingMore: loadingMore ?? this.loadingMore, + nextPageCursor: + nextPageCursor != null ? nextPageCursor() : this.nextPageCursor, publishing: publishing ?? this.publishing, hasSearched: hasSearched ?? this.hasSearched, error: error != null ? error() : this.error, @@ -146,6 +168,8 @@ class OffersState extends Equatable { blockedAuthors, hiddenOfferKeys, searching, + loadingMore, + nextPageCursor, publishing, hasSearched, error, @@ -183,10 +207,23 @@ class OffersCubit extends Cubit { StreamSubscription? _sub; Timer? _searchTimeout; + /// Upper bound on offers held in memory. Each offer can carry an inline + /// (~40 KB) photo thumbnail, so an unbounded list in a busy area is a real + /// out-of-memory risk on mobile. Paging stops once the list reaches this, and + /// live offers beyond it evict the oldest — the newest stay visible. + static const int maxOffersKept = 400; + /// Whether a live transport is available (relay configured and reachable). bool get isOnline => _transport != null; - /// Starts (or restarts) discovery for [geohashPrefix]. Results stream in. + /// Current time in Unix seconds — the granularity Nostr filters use for + /// `since`/`until`. + int _now() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + + /// Starts (or restarts) discovery for [geohashPrefix]. Fetches the newest page + /// up front (bounded), then keeps a live subscription for offers published + /// from now on — older results arrive via [loadNextPage], not by draining the + /// whole area into memory. Future discover(String geohashPrefix) async { final transport = _transport; if (transport == null) { @@ -207,15 +244,38 @@ class OffersCubit extends Cubit { searching: true, hasSearched: true, )); - _sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen( - (offer) => - emit(state.copyWith(offers: _merge(state.offers, offer), searching: false)), - onError: (Object e) => - emit(state.copyWith(searching: false, error: () => '$e')), + + final query = DiscoveryQuery( + geohashPrefix: geohashPrefix, + types: state.typeFilter, ); - // The discover stream stays open for live offers and never signals "done", - // so stop the spinner after a beat: no results → show the empty state, not - // an endless "searching". + // Live subscription first, bounded to offers published from now on, so a new + // listing shows up immediately without re-dumping the whole area. + final since = _now(); + _sub = transport.discover(query, since: since).listen( + (offer) => emit(state.copyWith( + offers: _capped(_prepend(state.offers, offer)), + searching: false, + )), + onError: (Object e) => + emit(state.copyWith(searching: false, error: () => '$e')), + ); + + try { + final page = await transport.discoverPage(query); + if (!isClosed) { + emit(state.copyWith( + offers: _capped(_mergePage(state.offers, page.offers)), + nextPageCursor: () => page.nextCursor, + searching: false, + )); + } + } catch (e) { + if (!isClosed) emit(state.copyWith(searching: false, error: () => '$e')); + } + + // Safety net: if the first page hangs and no live offer arrives, still stop + // the spinner so the screen shows the empty state, not an endless search. _searchTimeout = Timer(const Duration(seconds: 6), () { if (!isClosed && state.searching) { emit(state.copyWith(searching: false)); @@ -223,18 +283,64 @@ class OffersCubit extends Cubit { }); } - /// Appends [incoming] to [current], replacing any existing offer with the same - /// author + id. Relays legitimately resend addressable events (a stored copy - /// plus a live echo after publishing), so a plain append would double the - /// listing; keeping one entry per (author, id) is the NIP-99 semantics. - static List _merge(List current, Offer incoming) => [ - for (final o in current) - if (!(o.id == incoming.id && - o.authorPubkeyHex == incoming.authorPubkeyHex)) - o, + /// Loads the next (older) page of offers for the current area. Called by the + /// list as it nears the end. No-op when offline, already loading, or there is + /// nothing older to fetch. + Future loadNextPage() async { + final transport = _transport; + final cursor = state.nextPageCursor; + if (transport == null || cursor == null || state.loadingMore) return; + emit(state.copyWith(loadingMore: true)); + try { + final page = await transport.discoverPage(DiscoveryQuery( + geohashPrefix: state.areaGeohash, + types: state.typeFilter, + until: cursor, + )); + final merged = _mergePage(state.offers, page.offers); + // Stop paging once the cap is reached — the list is already as large as we + // keep — otherwise carry the transport's cursor onward. + final reachedCap = merged.length >= maxOffersKept; + emit(state.copyWith( + offers: _capped(merged), + nextPageCursor: () => reachedCap ? null : page.nextCursor, + loadingMore: false, + )); + } catch (e) { + emit(state.copyWith(loadingMore: false, error: () => '$e')); + } + } + + /// The offer's identity for de-duplication: NIP-99 addressable events are keyed + /// by (author, `d`-tag id), so the same listing from two relays — or a stored + /// copy plus a live echo — collapses to one entry. + static String _key(Offer o) => '${o.authorPubkeyHex}:${o.id}'; + + /// Prepends a freshly-arrived (newer) [incoming] offer, dropping any existing + /// entry with the same identity so a live echo never doubles the listing. + static List _prepend(List current, Offer incoming) => [ incoming, + for (final o in current) + if (_key(o) != _key(incoming)) o, ]; + /// Appends an older [page] after [current] (older offers sort below newer), + /// skipping any already present. Keeps the newest-first ordering. + static List _mergePage(List current, List page) { + final seen = {for (final o in current) _key(o)}; + return [ + ...current, + for (final o in page) + if (seen.add(_key(o))) o, + ]; + } + + /// Caps the list to [maxOffersKept], keeping the newest (front) and dropping + /// the oldest (tail) — bounds memory in a busy area. + static List _capped(List offers) => offers.length <= maxOffersKept + ? offers + : offers.sublist(0, maxOffersKept); + /// Narrows the visible offers to those whose summary matches [query]. Purely /// local over the already-discovered list; does not re-hit the transport. void search(String query) => emit(state.copyWith(query: query)); diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index b4a7eeb..23941a5 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -414,23 +414,44 @@ class MarketBody extends StatelessWidget { ), ], ) - : ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), - itemCount: visible.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (context, i) { - final o = visible[i]; - final mine = o.authorPubkeyHex == selfPubkey; - return _OfferCard( - offer: o, - mine: mine, - onTap: () async { - await context.push('/market/offer', extra: o); - await onOfferClosed?.call(); - }, - ); + : NotificationListener( + // Page in older offers as the list nears its end, so a + // large area streams in on demand rather than all at + // once. The cubit guards against overlapping loads. + onNotification: (n) { + if (state.canLoadMore && + !state.loadingMore && + n.metrics.axis == Axis.vertical && + n.metrics.extentAfter < 500) { + cubit.loadNextPage(); + } + return false; }, + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + // A trailing spinner row while the next page loads. + itemCount: visible.length + (state.loadingMore ? 1 : 0), + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, i) { + if (i >= visible.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ); + } + final o = visible[i]; + final mine = o.authorPubkeyHex == selfPubkey; + return _OfferCard( + offer: o, + mine: mine, + onTap: () async { + await context.push('/market/offer', extra: o); + await onOfferClosed?.call(); + }, + ); + }, + ), ), ), ), diff --git a/apps/app_seeds/test/screenshots/screenshots_test.dart b/apps/app_seeds/test/screenshots/screenshots_test.dart index fac0139..7589506 100644 --- a/apps/app_seeds/test/screenshots/screenshots_test.dart +++ b/apps/app_seeds/test/screenshots/screenshots_test.dart @@ -156,7 +156,11 @@ class _SeededOffersCubit extends OffersCubit { /// A transport that is present (so the market reads as online) but never used. class _NoopOfferTransport implements OfferTransport { @override - Stream discover(DiscoveryQuery query) => const Stream.empty(); + Stream discover(DiscoveryQuery query, {int? since}) => + const Stream.empty(); + @override + Future discoverPage(DiscoveryQuery query) async => + const OfferPage(offers: []); @override Future publish(Offer offer) => throw UnimplementedError(); @override diff --git a/apps/app_seeds/test/state/offers_cubit_test.dart b/apps/app_seeds/test/state/offers_cubit_test.dart index d38a96e..650088a 100644 --- a/apps/app_seeds/test/state/offers_cubit_test.dart +++ b/apps/app_seeds/test/state/offers_cubit_test.dart @@ -25,7 +25,7 @@ class FakeOfferTransport implements OfferTransport { } @override - Stream discover(DiscoveryQuery query) { + Stream discover(DiscoveryQuery query, {int? since}) { final controller = StreamController(); for (final o in _offers) { if (o.approxGeohash.startsWith(query.geohashPrefix)) controller.add(o); @@ -33,6 +33,15 @@ class FakeOfferTransport implements OfferTransport { return controller.stream; // left open (live), like a real subscription } + @override + Future discoverPage(DiscoveryQuery query) async { + final matches = [ + for (final o in _offers) + if (o.approxGeohash.startsWith(query.geohashPrefix)) o, + ]; + return OfferPage(offers: matches); // short page → nothing older to load + } + @override Future retract(String offerId) async => _offers.removeWhere((o) => o.id == offerId); @@ -54,7 +63,7 @@ class DuplicatingOfferTransport implements OfferTransport { } @override - Stream discover(DiscoveryQuery query) { + Stream discover(DiscoveryQuery query, {int? since}) { final controller = StreamController(); for (final o in _offers) { if (o.approxGeohash.startsWith(query.geohashPrefix)) { @@ -65,6 +74,17 @@ class DuplicatingOfferTransport implements OfferTransport { return controller.stream; } + @override + Future discoverPage(DiscoveryQuery query) async { + // The stored copy (deduped at relay level); the live echo (the duplicate) + // still arrives via [discover], so the cubit's dedup is what's under test. + final matches = [ + for (final o in _offers) + if (o.approxGeohash.startsWith(query.geohashPrefix)) o, + ]; + return OfferPage(offers: matches); + } + @override Future retract(String offerId) async => _offers.removeWhere((o) => o.id == offerId); @@ -73,6 +93,59 @@ class DuplicatingOfferTransport implements OfferTransport { Future close() async {} } +/// A transport that serves offers in `until`-cursored pages (like a relay's +/// stored events), so the cubit's pagination and memory cap can be exercised. +/// Each seeded offer gets a descending createdAt; [discover] (live) is empty. +class PaginatingOfferTransport implements OfferTransport { + PaginatingOfferTransport(int count, {this.geohash = 'sp3e9'}) { + // Newest (highest createdAt) first: offer 0 is newest. + for (var i = 0; i < count; i++) { + _byCreatedAt.add(( + createdAt: 1000000 - i, + offer: Offer( + id: 'o$i', + authorPubkeyHex: 'ab' * 32, + summary: 'offer $i', + type: OfferType.gift, + approxGeohash: geohash, + ), + )); + } + } + + final String geohash; + final List<({int createdAt, Offer offer})> _byCreatedAt = []; + + @override + Stream discover(DiscoveryQuery query, {int? since}) => + const Stream.empty(); + + @override + Future discoverPage(DiscoveryQuery query) async { + final matched = _byCreatedAt + .where((e) => e.offer.approxGeohash.startsWith(query.geohashPrefix)) + .where((e) => query.until == null || e.createdAt <= query.until!) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final page = matched.take(query.limit).toList(); + final nextCursor = page.length >= query.limit && page.isNotEmpty + ? page.last.createdAt - 1 + : null; + return OfferPage( + offers: [for (final e in page) e.offer], + nextCursor: nextCursor, + ); + } + + @override + Future publish(Offer offer) async => + PublishResult(accepted: true, transportRef: offer.id); + @override + Future retract(String offerId) async {} + @override + Future close() async {} +} + void main() { group('OfferMapper', () { test('maps local sharing intent to the network offer type', () { @@ -701,4 +774,65 @@ void main() { await cubit.close(); }); }); + + group('OffersCubit pagination', () { + test('discover loads the first page and exposes a next-page cursor', + () async { + final cubit = OffersCubit(PaginatingOfferTransport(250)); + await cubit.discover('sp3'); + await pumpEventQueue(); + // One page (default limit 100) — not the whole 250 — is kept in memory. + expect(cubit.state.offers, hasLength(100)); + expect(cubit.state.canLoadMore, isTrue); + expect(cubit.state.searching, isFalse); + await cubit.close(); + }); + + test('loadNextPage accumulates older offers until the source is exhausted', + () async { + final cubit = OffersCubit(PaginatingOfferTransport(250)); + await cubit.discover('sp3'); + await pumpEventQueue(); + + await cubit.loadNextPage(); + expect(cubit.state.offers, hasLength(200)); + expect(cubit.state.canLoadMore, isTrue); + + await cubit.loadNextPage(); + // Only 250 exist: the third page is short, so there is nothing older. + expect(cubit.state.offers, hasLength(250)); + expect(cubit.state.canLoadMore, isFalse); + + // Paging past the end is a harmless no-op. + await cubit.loadNextPage(); + expect(cubit.state.offers, hasLength(250)); + await cubit.close(); + }); + + test('the in-memory list is capped and paging stops at the cap', () async { + final cubit = OffersCubit(PaginatingOfferTransport(600)); + await cubit.discover('sp3'); + await pumpEventQueue(); + // Keep paging until the cubit says there is no more. + var guard = 0; + while (cubit.state.canLoadMore && guard++ < 20) { + await cubit.loadNextPage(); + } + expect(cubit.state.offers, hasLength(OffersCubit.maxOffersKept)); + expect(cubit.state.canLoadMore, isFalse); + await cubit.close(); + }); + + test('offers are de-duplicated across pages by (author, id)', () async { + // Two pages that overlap on the boundary id must not double it. + final cubit = OffersCubit(PaginatingOfferTransport(150)); + await cubit.discover('sp3'); + await pumpEventQueue(); + await cubit.loadNextPage(); + final ids = cubit.state.offers.map((o) => o.id).toList(); + expect(ids.toSet(), hasLength(ids.length)); // no duplicates + expect(cubit.state.offers, hasLength(150)); + await cubit.close(); + }); + }); } diff --git a/apps/app_seeds/test/ui/inventory_list_lazy_test.dart b/apps/app_seeds/test/ui/inventory_list_lazy_test.dart new file mode 100644 index 0000000..a9475e1 --- /dev/null +++ b/apps/app_seeds/test/ui/inventory_list_lazy_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/ui/inventory_list_screen.dart'; + +import '../support/test_support.dart'; + +/// The inventory list must render lazily: with a large catalogue only the tiles +/// near the viewport are built, not one widget per row. Guards the regression +/// from `ListView(children: …)` (every tile built upfront) back in. +void main() { + testWidgets('builds only the on-screen tiles for a large inventory', + (tester) async { + final db = newTestDatabase(); + final repo = newTestRepository(db); + // Enough rows that an eager list would build hundreds of tiles at once. + for (var i = 0; i < 300; i++) { + await repo.addQuickVariety( + label: 'Variety ${i.toString().padLeft(3, '0')}', + category: i.isEven ? 'Poaceae' : 'Fabaceae', + ); + } + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + // Let the debounced inventory stream emit and the async load resolve + // (bounded pumps — the screen holds a live Drift stream, so pumpAndSettle + // would hang; see testing.md). + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 100)); + + // Every variety tile is a ListTile; a lazy list builds only a viewport-worth. + final built = find.byType(ListTile).evaluate().length; + expect(built, greaterThan(0), reason: 'the list rendered some tiles'); + expect( + built, + lessThan(50), + reason: 'lazy list should build ~a screenful, not all 300 ($built built)', + ); + + await disposeTree(tester); + await db.close(); + }); +} diff --git a/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart b/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart index c9408b9..305f842 100644 --- a/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart +++ b/packages/commons_core/lib/src/social/nostr/nostr_offer_transport.dart @@ -13,10 +13,12 @@ class NostrOfferTransport implements OfferTransport { final NostrChannel _conn; final Nip99Codec _codec = Nip99Codec(); - Filter _filter(DiscoveryQuery q) => Filter( + Filter _filter(DiscoveryQuery q, {int? since}) => Filter( kinds: const [Nip99Codec.kindActive], tagFilters: {'g': [q.geohashPrefix]}, limit: q.limit, + since: since, + until: q.until, ); @override @@ -37,11 +39,34 @@ class NostrOfferTransport implements OfferTransport { } @override - Stream discover(DiscoveryQuery query) => _conn - .subscribe(_filter(query)) + Stream discover(DiscoveryQuery query, {int? since}) => _conn + .subscribe(_filter(query, since: since)) .map(_codec.decode) .where((o) => query.types.isEmpty || query.types.contains(o.type)); + @override + Future discoverPage(DiscoveryQuery query) async { + final events = await _conn.reqOnce(_filter(query)) + // Newest first; the relays dedup within themselves and reqOnce dedups + // across them, but order is not guaranteed, so sort here. + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final offers = []; + for (final e in events) { + final offer = _codec.decode(e); + if (_matchesType(offer, query)) offers.add(offer); + } + // A full page means more may exist older than the oldest event we saw; a + // short page means we've reached the end. Base the cursor on ALL events + // (not just type-matched ones) so type filtering never makes us re-fetch. + final nextCursor = events.length >= query.limit && events.isNotEmpty + ? events.last.createdAt - 1 + : null; + return OfferPage(offers: offers, nextCursor: nextCursor); + } + + bool _matchesType(Offer o, DiscoveryQuery q) => + q.types.isEmpty || q.types.contains(o.type); + /// Collects matches up to EOSE (tests/one-shot browse). Future> discoverUntilEose(DiscoveryQuery query) async { final events = await _conn.reqOnce(_filter(query)); diff --git a/packages/commons_core/lib/src/social/offer.dart b/packages/commons_core/lib/src/social/offer.dart index 5baa1b1..97472d9 100644 --- a/packages/commons_core/lib/src/social/offer.dart +++ b/packages/commons_core/lib/src/social/offer.dart @@ -88,10 +88,28 @@ class DiscoveryQuery { required this.geohashPrefix, this.types = const {}, this.limit = 100, + this.until, }); /// Coarse geohash prefix to search near (e.g. "u09" ≈ tens of km). final String geohashPrefix; final Set types; final int limit; + + /// Pagination cursor (NIP-01 `until`): return only offers published at or + /// before this Unix time (seconds). Null asks for the newest page. Comes from + /// the previous page's [OfferPage.nextCursor]. + final int? until; +} + +/// One page of discovered offers plus the cursor to fetch the next (older) page. +class OfferPage { + const OfferPage({required this.offers, this.nextCursor}); + + /// The page's offers, newest first. + final List offers; + + /// Pass as the next query's [DiscoveryQuery.until] to page further back. Null + /// when the relays returned less than a full page — there is nothing older. + final int? nextCursor; } diff --git a/packages/commons_core/lib/src/social/offer_transport.dart b/packages/commons_core/lib/src/social/offer_transport.dart index 3dc3740..3d7928e 100644 --- a/packages/commons_core/lib/src/social/offer_transport.dart +++ b/packages/commons_core/lib/src/social/offer_transport.dart @@ -13,8 +13,16 @@ abstract interface class OfferTransport { Future publish(Offer offer); /// Streams offers matching [query]: stored matches first, then live ones, - /// until the caller cancels the subscription. - Stream discover(DiscoveryQuery query); + /// until the caller cancels the subscription. Pass [since] (Unix seconds) to + /// stream only offers published after it — used to keep a live subscription + /// for NEW offers while older ones are browsed via [discoverPage]. + Stream discover(DiscoveryQuery query, {int? since}); + + /// Fetches ONE page of stored offers matching [query] (up to EOSE), newest + /// first, with a cursor to page further back. Bounded — unlike [discover] it + /// does not hold a live subscription — so the UI can scroll a large result + /// set without accumulating every offer in memory. + Future discoverPage(DiscoveryQuery query); /// Retracts a previously published offer (relays that already replicated it /// drop it over time). diff --git a/packages/commons_core/test/social/nostr_offer_transport_page_test.dart b/packages/commons_core/test/social/nostr_offer_transport_page_test.dart new file mode 100644 index 0000000..25f9642 --- /dev/null +++ b/packages/commons_core/test/social/nostr_offer_transport_page_test.dart @@ -0,0 +1,109 @@ +import 'package:commons_core/commons_core.dart'; +import 'package:nostr/nostr.dart'; +import 'package:test/test.dart'; + +/// A stand-in relay channel: [reqOnce] honours the filter's `until` and `limit` +/// (newest first), like a relay serving stored events, so [discoverPage]'s +/// ordering and cursor maths can be asserted deterministically. +class _FakeChannel implements NostrChannel { + _FakeChannel(this._events); + + final List _events; + + @override + Future> reqOnce(Filter filter) async { + final until = filter.until; + final matched = _events + .where((e) => until == null || e.createdAt <= until) + .toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return matched.take(filter.limit ?? matched.length).toList(); + } + + @override + String get privateKeyHex => '00' * 32; + @override + String get publicKeyHex => 'ab' * 32; + @override + Future<({bool accepted, String message})> publish(Event event) async => + (accepted: true, message: ''); + @override + Stream subscribe(Filter filter) => const Stream.empty(); + @override + Future close() async {} +} + +Event _evt(String id, int createdAt, {String type = 'gift'}) => Event( + 'evt-$id', + 'ab' * 32, + createdAt, + Nip99Codec.kindActive, + [ + ['d', id], + ['g', 'sp3e9'], + ['offer_type', type], + ['title', 'offer $id'], + ], + 'offer $id', + '00' * 64, + verify: false, + ); + +void main() { + group('NostrOfferTransport.discoverPage', () { + test('returns offers newest-first with a cursor to the older page', + () async { + final transport = NostrOfferTransport(_FakeChannel([ + _evt('a', 100), // oldest + _evt('c', 300), // newest + _evt('b', 200), + ])); + + final page1 = await transport.discoverPage( + const DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 2), + ); + // A full page (2 of the 3) sorted newest-first, with a cursor set. + expect(page1.offers.map((o) => o.id), ['c', 'b']); + expect(page1.nextCursor, 199, reason: 'oldest kept (200) minus one'); + + final page2 = await transport.discoverPage( + DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 2, until: page1.nextCursor), + ); + // Only the last one remains — a short page, so nothing older. + expect(page2.offers.map((o) => o.id), ['a']); + expect(page2.nextCursor, isNull); + }); + + test('a short first page reports no next cursor', () async { + final transport = NostrOfferTransport(_FakeChannel([ + _evt('a', 100), + _evt('b', 200), + ])); + final page = await transport.discoverPage( + const DiscoveryQuery(geohashPrefix: 'sp3e9', limit: 100), + ); + expect(page.offers, hasLength(2)); + expect(page.nextCursor, isNull); + }); + + test('type filtering narrows the offers but not the cursor', () async { + // A full page of gifts with one sale mixed in; asking for sales only must + // still advance the cursor by the oldest EVENT seen, not the oldest match, + // so paging never skips unmatched offers. + final transport = NostrOfferTransport(_FakeChannel([ + _evt('g1', 300), + _evt('s1', 200, type: 'sale'), + _evt('g2', 100), + ])); + final page = await transport.discoverPage( + const DiscoveryQuery( + geohashPrefix: 'sp3e9', + limit: 3, + types: {OfferType.sale}, + ), + ); + expect(page.offers.map((o) => o.id), ['s1']); + expect(page.nextCursor, 99, reason: 'oldest event (100) minus one'); + }); + }); +} From 071be44851af35c193ba0e28ef042e9993f2d38d Mon Sep 17 00:00:00 2001 From: vjrj Date: Mon, 20 Jul 2026 22:50:57 +0200 Subject: [PATCH 3/3] perf(android): R8, bitmap downscaling, edge-to-edge; recover device compat; publish to production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device compatibility (regression fix): - The CAMERA permission (from zxing_barcode_scanner / image_picker) implicitly required android.hardware.camera, excluding camera-less devices — Play showed Automotive -96%, Chromebook -86%, TV -25%. Declare camera & location uses-feature required="false" to keep those devices supported. - Detect a real camera at runtime (PackageManager.FEATURE_CAMERA_ANY via the existing MethodChannel), cache it at bootstrap, and hide the QR scan button and the camera photo-source option when absent, so no broken actions are offered. Play "app optimization" recommendations: - Enable R8 (isMinifyEnabled + isShrinkResources) with keep rules for the OCR (tesseract4android), SQLCipher and notifications JNI/reflection code. - Bitmap downscaling: cacheWidth/cacheHeight (and ResizeImage for avatars) on list thumbnails and avatars so photos decode to on-screen size, not full res. - Edge-to-edge: opt in with transparent system bars in main(). Release flow: - Tagged builds now publish to the production track at 100% (was internal); add a manual deploy_internal lane as a QA safety net. - Document country/region availability as a Play Console setting (not in-repo). --- apps/app_seeds/android/app/build.gradle.kts | 11 +++++ apps/app_seeds/android/app/proguard-rules.pro | 40 +++++++++++++++++++ .../android/app/src/main/AndroidManifest.xml | 13 ++++++ .../kotlin/org/comunes/tane/MainActivity.kt | 10 +++++ apps/app_seeds/fastlane/Fastfile | 15 ++++++- apps/app_seeds/lib/bootstrap.dart | 6 +++ apps/app_seeds/lib/main.dart | 11 +++++ .../lib/services/camera_availability.dart | 35 ++++++++++++++++ apps/app_seeds/lib/ui/calendar_screen.dart | 10 ++++- apps/app_seeds/lib/ui/draft_triage.dart | 6 +++ apps/app_seeds/lib/ui/market_widgets.dart | 10 ++++- apps/app_seeds/lib/ui/peer_avatar.dart | 14 ++++++- apps/app_seeds/lib/ui/photo_pick.dart | 31 ++++++++------ apps/app_seeds/lib/ui/qr_scan.dart | 9 ++++- apps/app_seeds/lib/ui/quick_add_sheet.dart | 2 + .../lib/ui/variety_detail_screen.dart | 6 +++ docs/release.md | 24 ++++++++++- 17 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 apps/app_seeds/android/app/proguard-rules.pro create mode 100644 apps/app_seeds/lib/services/camera_availability.dart diff --git a/apps/app_seeds/android/app/build.gradle.kts b/apps/app_seeds/android/app/build.gradle.kts index b0e4713..ecc470d 100644 --- a/apps/app_seeds/android/app/build.gradle.kts +++ b/apps/app_seeds/android/app/build.gradle.kts @@ -73,6 +73,17 @@ android { } else { signingConfigs.getByName("debug") } + // R8: shrink + optimize + obfuscate the Java/Kotlin bytecode and strip + // unused resources — Play's "app optimization" recommendation (smaller + // download, less memory). Native .so libs are untouched, so this does + // not change device/ABI compatibility. Keep rules for reflection/JNI + // heavy deps (OCR, SQLCipher, notifications) live in proguard-rules.pro. + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } } } diff --git a/apps/app_seeds/android/app/proguard-rules.pro b/apps/app_seeds/android/app/proguard-rules.pro new file mode 100644 index 0000000..3d061e3 --- /dev/null +++ b/apps/app_seeds/android/app/proguard-rules.pro @@ -0,0 +1,40 @@ +# R8/ProGuard keep rules for Tane (org.comunes.tane). +# +# R8 is enabled for release builds (see build.gradle.kts). It shrinks/optimizes +# the Java/Kotlin bytecode and can break code reached only via reflection or JNI +# — the native plugins below load their Java classes from C, so R8 can't see the +# references and would strip/rename them. Keep them explicitly. Start +# conservative; trim only after a release smoke test proves a class is unused. + +# --- Flutter engine & embedding (defensive; usually handled by the plugin) --- +-keep class io.flutter.** { *; } +-keep class io.flutter.plugins.** { *; } +-dontwarn io.flutter.embedding.** + +# --- On-device OCR: tesseract4android (JNI) --- +# libtesseract/libleptonica call back into these Java classes by name. +-keep class com.googlecode.tesseract.android.** { *; } +-keep class com.googlecode.leptonica.android.** { *; } +-keep class io.paratoner.flutter_tesseract_ocr.** { *; } + +# --- QR scanning: zxing_barcode_scanner (pure ZXing, platform view) --- +-keep class com.shirisharyal.zxing_barcode_scanner.** { *; } + +# --- Encrypted DB: SQLCipher / sqlite3 native loader --- +# sqlite3 is reached over FFI (dlopen), but keep the loader plugin classes. +-keep class eu.simonbinder.sqlite3_flutter_libs.** { *; } +-keep class net.zetetic.** { *; } +-dontwarn net.zetetic.** + +# --- Local notifications --- +# Published keep rules for flutter_local_notifications' Gson-serialized models. +-keep class com.dexterous.** { *; } +-keep class com.google.gson.** { *; } +-keep class * extends com.google.gson.TypeAdapter +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn com.google.errorprone.annotations.** + +# --- Core library desugaring --- +-dontwarn java.lang.invoke.** +-dontwarn build.IgnoreJava8API diff --git a/apps/app_seeds/android/app/src/main/AndroidManifest.xml b/apps/app_seeds/android/app/src/main/AndroidManifest.xml index 677d645..3ea677f 100644 --- a/apps/app_seeds/android/app/src/main/AndroidManifest.xml +++ b/apps/app_seeds/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,19 @@ + + + + + + + when (call.method) { "getCoarseLatLon" -> getCoarseLatLon(result) + "hasCamera" -> result.success(hasCameraHardware()) else -> result.notImplemented() } } @@ -72,6 +73,15 @@ class MainActivity : FlutterActivity() { } } + /** + * Whether this device has any camera. Camera-less devices (Chromebooks, + * Android Automotive, many TVs) are supported — see AndroidManifest's + * uses-feature required="false" — so the QR scan and camera-capture UI + * hide themselves when this is false instead of offering broken actions. + */ + private fun hasCameraHardware(): Boolean = + packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + private fun hasLocationPermission(): Boolean = ContextCompat.checkSelfPermission( this, diff --git a/apps/app_seeds/fastlane/Fastfile b/apps/app_seeds/fastlane/Fastfile index 6a52523..5f3437a 100644 --- a/apps/app_seeds/fastlane/Fastfile +++ b/apps/app_seeds/fastlane/Fastfile @@ -16,13 +16,24 @@ default_platform(:android) AAB_PATH = "build/app/outputs/bundle/release/app-release.aab".freeze platform :android do - desc "Upload the signed AAB + store listing to Google Play (internal track)" + desc "Upload the signed AAB + store listing to Google Play (production, 100%)" lane :deploy_play do + supply( + track: "production", + aab: AAB_PATH, + release_status: "completed", # publish to 100% of users (subject to review) + skip_upload_apk: true, # we ship the AAB, not a raw APK + skip_upload_changelogs: false, + ) + end + + desc "Upload the signed AAB to the internal test track (manual QA safety net)" + lane :deploy_internal do supply( track: "internal", aab: AAB_PATH, release_status: "completed", # internal testing has no review delay - skip_upload_apk: true, # we ship the AAB, not a raw APK + skip_upload_apk: true, skip_upload_changelogs: false, ) end diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 57ab427..e0ecc26 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -9,6 +9,7 @@ import 'data/variety_repository.dart'; import 'di/injector.dart'; import 'i18n/strings.g.dart'; import 'services/auto_backup_service.dart'; +import 'services/camera_availability.dart'; import 'services/coarse_location.dart'; import 'services/inbox_service.dart'; import 'services/locale_store.dart'; @@ -50,6 +51,11 @@ class _BootstrapState extends State { Future _boot() async { await configureDependencies(); + // Detect camera presence once, before the home screen builds, so camera-less + // devices (Chromebooks, Automotive, some TVs) hide the QR scan / camera UI + // instead of offering broken actions. Non-Android platforms keep the default. + await initCameraAvailability(); + // The saved language lives in the keystore, so it can only be applied once // DI is up. Until now the splash showed in the device language (it has no // text), so there is no visible flip. diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index 0a4c2db..7509544 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'bootstrap.dart'; @@ -6,6 +7,16 @@ import 'ui/restart_widget.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); + // Draw behind the system bars (edge-to-edge) with transparent bars, so the app + // fills the screen on Android 15+ (where edge-to-edge is enforced) and stays + // consistent elsewhere. Scaffolds use SafeArea to keep content off the insets. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Color(0x00000000), + systemNavigationBarColor: Color(0x00000000), + ), + ); // Start from the device language, then let an explicit in-app choice win — // it's the only reliable way to reach languages the OS picker doesn't offer // (e.g. Asturian). The saved choice is restored inside [Bootstrap], after DI. diff --git a/apps/app_seeds/lib/services/camera_availability.dart b/apps/app_seeds/lib/services/camera_availability.dart new file mode 100644 index 0000000..d9704e6 --- /dev/null +++ b/apps/app_seeds/lib/services/camera_availability.dart @@ -0,0 +1,35 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Whether this device has any camera. Resolved once at startup (via the native +/// channel, `PackageManager.FEATURE_CAMERA_ANY`) and cached so `build()` methods +/// can read it synchronously — the QR scan button ([qrScanSupported]) and the +/// photo-source sheet hide their camera options when it is false. +/// +/// Defaults to `true` so a normal phone never loses camera UI over a transient +/// channel error; only genuinely camera-less devices (Chromebooks, Android +/// Automotive, some TVs — kept installable by the `uses-feature required=false` +/// in AndroidManifest) flip it to false. Non-Android platforms keep the default +/// (iOS devices have a camera; desktop/web gate camera UI elsewhere). +bool _deviceHasCamera = true; + +bool get deviceHasCamera => _deviceHasCamera; + +@visibleForTesting +set deviceHasCamera(bool value) => _deviceHasCamera = value; + +const _channel = MethodChannel('org.comunes.tane/coarse_location'); + +/// Queries the native camera-presence check and caches it. Called once during +/// bootstrap, before the first real frame, so synchronous readers see the right +/// value. Android-only; elsewhere the default stands. Never throws — a channel +/// error leaves the safe default in place. +Future initCameraAvailability() async { + if (defaultTargetPlatform != TargetPlatform.android) return; + try { + final has = await _channel.invokeMethod('hasCamera'); + if (has != null) _deviceHasCamera = has; + } catch (_) { + // Keep the default; a normal phone shouldn't lose camera UI over this. + } +} diff --git a/apps/app_seeds/lib/ui/calendar_screen.dart b/apps/app_seeds/lib/ui/calendar_screen.dart index d01df9e..717fb5b 100644 --- a/apps/app_seeds/lib/ui/calendar_screen.dart +++ b/apps/app_seeds/lib/ui/calendar_screen.dart @@ -235,8 +235,14 @@ class _CalendarRow extends StatelessWidget { leading: CircleAvatar( radius: 20, backgroundColor: swatch.fill, - foregroundImage: - entry.photo == null ? null : MemoryImage(entry.photo!), + // Decode to the 40px avatar size (× dpr), not the full photo. + foregroundImage: entry.photo == null + ? null + : ResizeImage( + MemoryImage(entry.photo!), + width: (40 * MediaQuery.devicePixelRatioOf(context)).round(), + height: (40 * MediaQuery.devicePixelRatioOf(context)).round(), + ), child: entry.photo == null ? Text( entry.label.isEmpty diff --git a/apps/app_seeds/lib/ui/draft_triage.dart b/apps/app_seeds/lib/ui/draft_triage.dart index 9f677b1..1605e52 100644 --- a/apps/app_seeds/lib/ui/draft_triage.dart +++ b/apps/app_seeds/lib/ui/draft_triage.dart @@ -108,6 +108,10 @@ class _DraftTile extends StatelessWidget { photo, width: 48, height: 48, + cacheWidth: + (48 * MediaQuery.devicePixelRatioOf(context)).round(), + cacheHeight: + (48 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), @@ -192,6 +196,8 @@ class _NameDraftDialogState extends State { child: Image.memory( widget.photo!, height: 140, + cacheHeight: + (140 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), diff --git a/apps/app_seeds/lib/ui/market_widgets.dart b/apps/app_seeds/lib/ui/market_widgets.dart index 20c9f31..92da91d 100644 --- a/apps/app_seeds/lib/ui/market_widgets.dart +++ b/apps/app_seeds/lib/ui/market_widgets.dart @@ -36,6 +36,7 @@ class OfferThumbnail extends StatelessWidget { return ClipRRect( borderRadius: BorderRadius.circular(10), child: _offerImage( + context, url, semanticLabel: semanticLabel, width: size, @@ -63,7 +64,7 @@ class OfferHeroImage extends StatelessWidget { borderRadius: BorderRadius.circular(14), child: AspectRatio( aspectRatio: 4 / 3, - child: _offerImage(url, semanticLabel: semanticLabel), + child: _offerImage(context, url, semanticLabel: semanticLabel), ), ); } @@ -73,6 +74,7 @@ class OfferHeroImage extends StatelessWidget { /// remote URL (via network), always cropping to fill and falling back to a /// neutral placeholder — a broken image must never block the card. Widget _offerImage( + BuildContext context, String url, { required String semanticLabel, double? width, @@ -80,10 +82,16 @@ Widget _offerImage( }) { final inline = decodeDataUri(url); if (inline != null) { + // For a sized thumbnail, decode down to its on-screen pixels instead of the + // photo's full resolution (Play's "bitmap downscaling"). The hero image + // passes no width, so it decodes at native size as intended. + final dpr = MediaQuery.devicePixelRatioOf(context); return Image.memory( inline, width: width, height: height, + cacheWidth: width == null ? null : (width * dpr).round(), + cacheHeight: height == null ? null : (height * dpr).round(), fit: BoxFit.cover, semanticLabel: semanticLabel, errorBuilder: (context, _, _) => diff --git a/apps/app_seeds/lib/ui/peer_avatar.dart b/apps/app_seeds/lib/ui/peer_avatar.dart index d501b9d..09bd3ef 100644 --- a/apps/app_seeds/lib/ui/peer_avatar.dart +++ b/apps/app_seeds/lib/ui/peer_avatar.dart @@ -34,7 +34,19 @@ class PeerAvatar extends StatelessWidget { if (pic.startsWith('data:')) { final bytes = decodeDataUri(pic); if (bytes != null) { - return CircleAvatar(radius: radius, backgroundImage: MemoryImage(bytes)); + // Decode the photo down to the avatar's on-screen size (in physical + // pixels) instead of full resolution — the "bitmap downscaling" Play + // recommends. A tiny disc never needs a multi-megapixel decode. + final side = (radius * 2 * MediaQuery.devicePixelRatioOf(context)) + .round(); + return CircleAvatar( + radius: radius, + backgroundImage: ResizeImage( + MemoryImage(bytes), + width: side, + height: side, + ), + ); } } else if (pic.startsWith(avatarGlyphPrefix)) { final glyph = avatarGlyphChar(pic.substring(avatarGlyphPrefix.length)); diff --git a/apps/app_seeds/lib/ui/photo_pick.dart b/apps/app_seeds/lib/ui/photo_pick.dart index f01f684..a7ae779 100644 --- a/apps/app_seeds/lib/ui/photo_pick.dart +++ b/apps/app_seeds/lib/ui/photo_pick.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../i18n/strings.g.dart'; +import '../services/camera_availability.dart'; /// Asks the user to take a photo or pick one from the gallery, then returns its /// bytes (or null if cancelled/unavailable). Camera failures — e.g. on desktop, @@ -16,12 +17,15 @@ Future pickPhoto(BuildContext context) async { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - key: const Key('photo.source.camera'), - leading: const Icon(Icons.photo_camera_outlined), - title: Text(t.photo.camera), - onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), - ), + // Only offer the camera when the device actually has one; camera-less + // devices (Chromebooks, Automotive, some TVs) fall back to gallery. + if (deviceHasCamera) + ListTile( + key: const Key('photo.source.camera'), + leading: const Icon(Icons.photo_camera_outlined), + title: Text(t.photo.camera), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), + ), ListTile( key: const Key('photo.source.gallery'), leading: const Icon(Icons.photo_library_outlined), @@ -57,12 +61,15 @@ Future> pickPhotos(BuildContext context) async { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - key: const Key('photos.source.camera'), - leading: const Icon(Icons.photo_camera_outlined), - title: Text(t.photo.camera), - onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), - ), + // Only offer the camera when the device actually has one; camera-less + // devices (Chromebooks, Automotive, some TVs) fall back to gallery. + if (deviceHasCamera) + ListTile( + key: const Key('photos.source.camera'), + leading: const Icon(Icons.photo_camera_outlined), + title: Text(t.photo.camera), + onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera), + ), ListTile( key: const Key('photos.source.gallery'), leading: const Icon(Icons.photo_library_outlined), diff --git a/apps/app_seeds/lib/ui/qr_scan.dart b/apps/app_seeds/lib/ui/qr_scan.dart index 05126ed..f765d81 100644 --- a/apps/app_seeds/lib/ui/qr_scan.dart +++ b/apps/app_seeds/lib/ui/qr_scan.dart @@ -7,12 +7,17 @@ import 'package:zxing_barcode_scanner/zxing_barcode_scanner.dart'; import '../data/species_repository.dart'; import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; +import '../services/camera_availability.dart'; import '../services/seed_label_scan.dart'; /// Whether this platform can open the camera scanner. Mobile-only for now /// (pure-ZXing platform views; no Google Play Services) — elsewhere the scan -/// button simply isn't offered, same pattern as the OCR capture. -bool get qrScanSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS); +/// button simply isn't offered, same pattern as the OCR capture. On Android it +/// also requires an actual camera, so camera-less devices (Chromebooks, +/// Automotive, some TVs) don't get a scan button that can't open — see +/// [deviceHasCamera]. +bool get qrScanSupported => + !kIsWeb && ((Platform.isAndroid && deviceHasCamera) || Platform.isIOS); /// Opens the full-screen camera scanner and returns the first decoded QR /// payload, or null if the person backed out. (Ğ1nkgo's scanner pattern on diff --git a/apps/app_seeds/lib/ui/quick_add_sheet.dart b/apps/app_seeds/lib/ui/quick_add_sheet.dart index 49574ab..349d247 100644 --- a/apps/app_seeds/lib/ui/quick_add_sheet.dart +++ b/apps/app_seeds/lib/ui/quick_add_sheet.dart @@ -199,6 +199,8 @@ class _MoreSection extends StatelessWidget { child: Image.memory( state.photoBytes!, height: 120, + cacheHeight: + (120 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 740713b..e9d6634 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -2244,10 +2244,16 @@ class _PhotoGalleryState extends State<_PhotoGallery> { itemBuilder: (_, i) => GestureDetector( key: Key('photo.thumb.$i'), onTap: () => _openPhotoViewer(context, cubit, i), + // Decode to the 140px thumbnail size (× dpr), not full photo + // resolution — the full-res decode happens in the viewer. child: Image.memory( photos[i].bytes, width: 140, height: 140, + cacheWidth: + (140 * MediaQuery.devicePixelRatioOf(context)).round(), + cacheHeight: + (140 * MediaQuery.devicePixelRatioOf(context)).round(), fit: BoxFit.cover, ), ), diff --git a/docs/release.md b/docs/release.md index 8b44a4f..3e70f01 100644 --- a/docs/release.md +++ b/docs/release.md @@ -15,7 +15,11 @@ git tag v0.1.0 && git push origin v0.1.0 ``` Forgejo Actions ([`../.forgejo/workflows/release.yml`](../.forgejo/workflows/release.yml)) -builds the signed AAB/APK and uploads to Play's **internal** track. No passwords are typed. +builds the signed AAB/APK and uploads to Play's **production** track at **100% +rollout**. No passwords are typed. Because a tag now publishes to everyone +(subject to Google review), run the [manual smoke test](#manual-smoke-test-before-shipping) +**before** tagging. To stage on the internal track first, run +`bundle exec fastlane deploy_internal` by hand. ## Versioning @@ -123,6 +127,24 @@ listing (titles, descriptions, changelogs, screenshots) is read straight from `fastlane/metadata/android/`. Data Safety and content-rating answers: [`legal/internal/play-compliance.md`](legal/internal/play-compliance.md). +Lanes: +- `deploy_play` — upload the AAB to **production** at 100% (what CI runs on tag). +- `deploy_internal` — same AAB to the **internal** test track (manual QA safety net). +- `deploy_metadata` — push only the store listing (no binary). +- `promote_production` — promote an internal release to production without a rebuild. + +### Country/region availability (Play Console, not this repo) + +Which countries the app is offered in is **not** in the repo and `fastlane supply` +does not manage it — it's a Play Console setting. To add countries: +**Play Console → (Tane) → Production → Countries / regions → Add countries/regions** +(under "Availability"). Play asks you to pick countries when you first move a +release to production; widen the list there for new markets. + +Note: the locales under `fastlane/metadata/android/` (`en-US`, `es-ES`) +are **listing languages**, not countries — adding a locale improves the store +page but does not by itself make the app available in a new country. + ## Publish to F-Droid (official repo) F-Droid builds from source after a merge request to `fdroiddata`. The build recipe