From 89b61bc9e44367cce0455211408e07332bd9cac6 Mon Sep 17 00:00:00 2001 From: vjrj Date: Thu, 9 Jul 2026 14:41:28 +0200 Subject: [PATCH] feat(inventory): additive CSV import for bulk digitization Import a spreadsheet list (the same 21-column schema the CSV export already produces) as new varieties/lots. Tolerant reader: columns matched by header name in any order, only variety_label required, unknown enums fall back to a safe default, rows sharing variety_id collapse into one variety with several lots. Species re-linked by scientific name. Additive by design (fresh ids + HLC), so it never overwrites existing rows; JSON stays the canonical id-reconciling import. Adds a hand-rolled RFC 4180 parser (no new dependency), an importCsv path in VarietyRepository/ExportImportService, an 'Import from CSV' tile in the backup section, and en/es strings. Note: i18n .g.dart also carries pre-existing About/intro strings from the working tree (regenerated by slang). --- .../export_import/inventory_csv_codec.dart | 336 ++++++++++++++++++ .../lib/data/variety_repository.dart | 108 ++++++ apps/app_seeds/lib/i18n/en.i18n.json | 33 ++ apps/app_seeds/lib/i18n/es.i18n.json | 33 ++ apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 147 ++++++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 108 ++++++ .../lib/services/export_import_service.dart | 10 + apps/app_seeds/lib/ui/backup_section.dart | 42 +++ .../csv_import_repository_test.dart | 78 ++++ .../inventory_csv_import_test.dart | 173 +++++++++ 11 files changed, 1070 insertions(+), 2 deletions(-) create mode 100644 apps/app_seeds/test/data/export_import/csv_import_repository_test.dart create mode 100644 apps/app_seeds/test/data/export_import/inventory_csv_import_test.dart diff --git a/apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart b/apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart index d64c988..6dc937d 100644 --- a/apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart +++ b/apps/app_seeds/lib/data/export_import/inventory_csv_codec.dart @@ -1,3 +1,5 @@ +import 'package:commons_core/commons_core.dart' show QuantityKind; + import '../../db/database.dart'; import '../../db/enums.dart'; import 'inventory_snapshot.dart'; @@ -141,4 +143,338 @@ class InventoryCsvCodec { } return '"${cell.replaceAll('"', '""')}"'; } + + /// Parses a CSV back into an **additive** import (the canonical import format + /// is JSON; this is a convenience for lists a collective already keeps in a + /// spreadsheet). Reader tolerance per data-model §5.2: + /// + /// - columns are matched **by header name** (any order); unknown columns are + /// ignored, missing columns are treated as empty; + /// - only `variety_label` is required — a row without it is skipped; + /// - unknown enum values fall back to a safe default (`lot_type` → seed, + /// `offer_status` → private) or are dropped (`quantity_kind`, `presentation`); + /// - rows sharing a non-empty `variety_id` collapse into one variety holding + /// several lots; rows without a `variety_id` each become their own variety. + /// + /// Sync metadata (`variety_id`/`lot_id`/timestamps) and the derived + /// `latest_germination_rate` are **not** carried over: the repository stamps + /// fresh ids and HLCs so an import never overwrites existing rows. + CsvImport decode(String source) { + final table = _parse(source); + if (table.length < 2) return const CsvImport(varieties: [], skippedRows: 0); + + final header = table.first; + final index = {}; + for (var i = 0; i < header.length; i++) { + index[header[i].trim()] = i; + } + String? cell(List row, String name) { + final i = index[name]; + if (i == null || i >= row.length) return null; + final value = row[i].trim(); + return value.isEmpty ? null : value; + } + + final byGroup = {}; + final builders = <_CsvVarietyBuilder>[]; + var skipped = 0; + + for (final row in table.skip(1)) { + if (row.every((c) => c.trim().isEmpty)) continue; // blank line + final label = cell(row, 'variety_label'); + if (label == null) { + skipped++; + continue; + } + + final groupId = cell(row, 'variety_id'); + _CsvVarietyBuilder builder; + if (groupId != null && byGroup.containsKey(groupId)) { + builder = byGroup[groupId]!; + } else { + builder = _CsvVarietyBuilder( + label: label, + category: cell(row, 'category'), + scientificName: cell(row, 'species_scientific_name'), + cultivarName: cell(row, 'cultivar_name'), + notes: cell(row, 'variety_notes'), + vernacularNames: _parseVernacular(cell(row, 'vernacular_names')), + links: _parseLinks(cell(row, 'external_links')), + ); + builders.add(builder); + if (groupId != null) byGroup[groupId] = builder; + } + + final lot = _parseLot(row, cell); + if (lot != null) builder.lots.add(lot); + } + + return CsvImport( + varieties: [for (final b in builders) b.build()], + skippedRows: skipped, + ); + } + + CsvImportLot? _parseLot(List row, String? Function(List, String) cell) { + final typeCell = cell(row, 'lot_type'); + final year = cell(row, 'harvest_year'); + final month = cell(row, 'harvest_month'); + final kind = cell(row, 'quantity_kind'); + final precise = cell(row, 'quantity_precise'); + final label = cell(row, 'quantity_label'); + final presentation = cell(row, 'presentation'); + final storage = cell(row, 'storage_location'); + final offer = cell(row, 'offer_status'); + + final hasLot = [ + typeCell, + year, + month, + kind, + precise, + label, + presentation, + storage, + offer, + ].any((c) => c != null); + if (!hasLot) return null; + + return CsvImportLot( + type: _enumOr(LotType.values, typeCell, LotType.seed), + harvestYear: year == null ? null : int.tryParse(year), + harvestMonth: month == null ? null : int.tryParse(month), + quantityKind: _enumName(QuantityKind.values, kind), + quantityPrecise: precise == null ? null : double.tryParse(precise), + quantityLabel: label, + presentation: _enumOrNull(Presentation.values, presentation), + storageLocation: storage, + offerStatus: _enumOr(OfferStatus.values, offer, OfferStatus.private), + ); + } + + /// `name (lang); name2` → vernacular names; the trailing `(xx)` is the ISO + /// language, mirroring the export join in [encode]. + List _parseVernacular(String? cell) { + if (cell == null) return const []; + final out = []; + for (final part in cell.split(';')) { + final text = part.trim(); + if (text.isEmpty) continue; + final match = RegExp(r'^(.*)\(([^()]+)\)$').firstMatch(text); + if (match != null) { + out.add( + CsvImportName( + name: match.group(1)!.trim(), + language: match.group(2)!.trim(), + ), + ); + } else { + out.add(CsvImportName(name: text)); + } + } + return out; + } + + /// `title|url; url2` → external links, mirroring the export join in [encode]. + List _parseLinks(String? cell) { + if (cell == null) return const []; + final out = []; + for (final part in cell.split(';')) { + final text = part.trim(); + if (text.isEmpty) continue; + final pipe = text.indexOf('|'); + if (pipe >= 0) { + out.add( + CsvImportLink( + url: text.substring(pipe + 1).trim(), + title: text.substring(0, pipe).trim(), + ), + ); + } else { + out.add(CsvImportLink(url: text)); + } + } + return out; + } + + /// Returns [name] only if it is a valid enum value name, else null — used to + /// keep `quantity_kind` as the stored enum key while dropping unknown ones. + String? _enumName(List values, String? name) { + if (name == null) return null; + return values.asNameMap().containsKey(name) ? name : null; + } + + T _enumOr(List values, String? name, T fallback) => + _enumOrNull(values, name) ?? fallback; + + T? _enumOrNull(List values, String? name) { + if (name == null) return null; + return values.asNameMap()[name]; + } + + /// A minimal RFC 4180 parser: fields may be quoted, quotes inside a quoted + /// field are doubled, and quoted fields may contain commas and newlines. + /// Handles both `\r\n` and `\n` line endings; a trailing newline does not + /// produce a spurious empty row. + List> _parse(String input) { + final rows = >[]; + var row = []; + final field = StringBuffer(); + var inQuotes = false; + var i = 0; + while (i < input.length) { + final c = input[i]; + if (inQuotes) { + if (c == '"') { + if (i + 1 < input.length && input[i + 1] == '"') { + field.write('"'); + i += 2; + continue; + } + inQuotes = false; + i++; + continue; + } + field.write(c); + i++; + continue; + } + if (c == '"') { + inQuotes = true; + i++; + continue; + } + if (c == ',') { + row.add(field.toString()); + field.clear(); + i++; + continue; + } + if (c == '\r' || c == '\n') { + row.add(field.toString()); + field.clear(); + rows.add(row); + row = []; + if (c == '\r' && i + 1 < input.length && input[i + 1] == '\n') { + i += 2; + } else { + i++; + } + continue; + } + field.write(c); + i++; + } + row.add(field.toString()); + final trailingEmpty = rows.isNotEmpty && row.length == 1 && row.first.isEmpty; + if (!trailingEmpty) rows.add(row); + return rows; + } +} + +/// The result of decoding a CSV for an additive import: the varieties to add +/// (each with its lots, names and links) plus how many rows were skipped for +/// lacking a `variety_label`. +class CsvImport { + const CsvImport({required this.varieties, required this.skippedRows}); + + final List varieties; + final int skippedRows; +} + +/// One variety parsed from CSV, to be inserted fresh (new id + HLC). +class CsvImportVariety { + const CsvImportVariety({ + required this.label, + this.category, + this.scientificName, + this.cultivarName, + this.notes, + this.vernacularNames = const [], + this.links = const [], + this.lots = const [], + }); + + final String label; + final String? category; + final String? scientificName; + final String? cultivarName; + final String? notes; + final List vernacularNames; + final List links; + final List lots; +} + +/// One lot parsed from a CSV row. +class CsvImportLot { + const CsvImportLot({ + this.type = LotType.seed, + this.harvestYear, + this.harvestMonth, + this.quantityKind, + this.quantityPrecise, + this.quantityLabel, + this.presentation, + this.storageLocation, + this.offerStatus = OfferStatus.private, + }); + + final LotType type; + final int? harvestYear; + final int? harvestMonth; + final String? quantityKind; + final double? quantityPrecise; + final String? quantityLabel; + final Presentation? presentation; + final String? storageLocation; + final OfferStatus offerStatus; +} + +/// One vernacular name parsed from CSV. +class CsvImportName { + const CsvImportName({required this.name, this.language}); + + final String name; + final String? language; +} + +/// One external link parsed from CSV. +class CsvImportLink { + const CsvImportLink({required this.url, this.title}); + + final String url; + final String? title; +} + +/// Mutable accumulator: several CSV rows can contribute lots to one variety. +class _CsvVarietyBuilder { + _CsvVarietyBuilder({ + required this.label, + this.category, + this.scientificName, + this.cultivarName, + this.notes, + this.vernacularNames = const [], + this.links = const [], + }); + + final String label; + final String? category; + final String? scientificName; + final String? cultivarName; + final String? notes; + final List vernacularNames; + final List links; + final List lots = []; + + CsvImportVariety build() => CsvImportVariety( + label: label, + category: category, + scientificName: scientificName, + cultivarName: cultivarName, + notes: notes, + vernacularNames: vernacularNames, + links: links, + lots: List.unmodifiable(lots), + ); } diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 92ca7e1..603e6a3 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart'; import '../db/database.dart'; import '../db/enums.dart'; import 'export_import/import_reconciler.dart'; +import 'export_import/inventory_csv_codec.dart'; import 'export_import/inventory_snapshot.dart'; /// A lightweight row for the inventory list (only what the list renders). @@ -961,6 +962,113 @@ class VarietyRepository { return summary; } + /// Additive import of a spreadsheet [csv]: every parsed variety is inserted + /// fresh (new UUIDv7 + local HLC), so it never overwrites existing rows and + /// re-importing the same file adds it again. The canonical, id-reconciling + /// import is JSON ([importInventory]); this is the low-friction path for a + /// list a collective already keeps in a spreadsheet. + /// + /// Species are re-linked by scientific name against the local catalog + /// (unmatched → null). Returns the number of varieties added. + Future importCsv(CsvImport csv) async { + var inserted = 0; + await _db.transaction(() async { + for (final v in csv.varieties) { + final speciesId = await _resolveSpeciesByName(v.scientificName); + final varietyId = idGen.newId(); + final (created, updated) = _stamp(); + await _db + .into(_db.varieties) + .insert( + VarietiesCompanion.insert( + id: varietyId, + label: v.label, + createdAt: created, + updatedAt: updated, + lastAuthor: nodeId, + category: Value(v.category), + cultivarName: Value(v.cultivarName), + notes: Value(v.notes), + speciesId: Value(speciesId), + ), + ); + inserted++; + + for (final lot in v.lots) { + final (created, updated) = _stamp(); + await _db + .into(_db.lots) + .insert( + LotsCompanion.insert( + id: idGen.newId(), + varietyId: varietyId, + createdAt: created, + updatedAt: updated, + lastAuthor: nodeId, + type: Value(lot.type), + harvestYear: Value(lot.harvestYear), + harvestMonth: Value(lot.harvestMonth), + quantityKind: Value(lot.quantityKind), + quantityPrecise: Value(lot.quantityPrecise), + quantityLabel: Value(lot.quantityLabel), + presentation: Value(lot.presentation), + storageLocation: Value(lot.storageLocation), + offerStatus: Value(lot.offerStatus), + ), + ); + } + + for (final name in v.vernacularNames) { + final (created, updated) = _stamp(); + await _db + .into(_db.varietyVernacularNames) + .insert( + VarietyVernacularNamesCompanion.insert( + id: idGen.newId(), + varietyId: varietyId, + name: name.name, + createdAt: created, + updatedAt: updated, + lastAuthor: nodeId, + language: Value(name.language), + ), + ); + } + + for (final link in v.links) { + final (created, updated) = _stamp(); + await _db + .into(_db.externalLinks) + .insert( + ExternalLinksCompanion.insert( + id: idGen.newId(), + createdAt: created, + updatedAt: updated, + lastAuthor: nodeId, + parentType: ParentType.variety, + parentId: varietyId, + url: link.url, + title: Value(link.title), + ), + ); + } + } + }); + return ImportSummary(inserted: inserted); + } + + /// Resolves a single scientific name to a local catalog species id, or null + /// when blank or unmatched. + Future _resolveSpeciesByName(String? scientificName) async { + final name = scientificName?.trim(); + if (name == null || name.isEmpty) return null; + final local = await (_db.select(_db.species)..where( + (s) => s.scientificName.equals(name) & s.isDeleted.equals(false), + )) + .getSingleOrNull(); + return local?.id; + } + /// Maps incoming species ids to local catalog ids by scientific name. Future> _resolveSpecies( Map speciesNamesById, diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index c4658e5..36b7a89 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -51,12 +51,17 @@ "exportJsonSubtitle": "Complete copy, can be imported back", "importJson": "Import from JSON", "importJsonSubtitle": "Merge a saved copy into this inventory", + "importCsv": "Import from CSV", + "importCsvSubtitle": "Add a list from a spreadsheet", "importConfirmTitle": "Import a saved copy?", "importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.", + "importCsvConfirmTitle": "Import a CSV list?", + "importCsvConfirmBody": "Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.", "importAction": "Import", "exportSaved": "Copy saved", "cancelled": "Cancelled", "importDone": "Imported: {added} new, {updated} updated", + "importCsvDone": "Imported {count} from CSV", "importFailed": "This file could not be read as a Tanemaki copy", "failed": "Something went wrong" }, @@ -73,6 +78,34 @@ "openSourceLicenses": "Open-source licenses", "openSourceLicensesSubtitle": "Third-party libraries and their licenses" }, + "intro": { + "skip": "Skip", + "next": "Next", + "start": "Get started", + "menuEntry": "How Tanemaki works", + "slides": { + "welcome": { + "title": "The seed that brought you here", + "body": "Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing." + }, + "inventory": { + "title": "Your seed bank, in your pocket", + "body": "Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start." + }, + "privacy": { + "title": "Yours, and only yours", + "body": "No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared." + }, + "share": { + "title": "Share, the way it's always been done", + "body": "Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person." + }, + "plantare": { + "title": "To sow is to multiply", + "body": "With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons." + } + } + }, "inventory": { "title": "Inventory", "searchHint": "Search seeds", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 52c5e4c..3217e18 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -51,12 +51,17 @@ "exportJsonSubtitle": "Copia completa, se puede volver a importar", "importJson": "Importar desde JSON", "importJsonSubtitle": "Fusiona una copia guardada con este inventario", + "importCsv": "Importar desde CSV", + "importCsvSubtitle": "Añade una lista desde una hoja de cálculo", "importConfirmTitle": "¿Importar una copia guardada?", "importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.", + "importCsvConfirmTitle": "¿Importar una lista CSV?", + "importCsvConfirmBody": "Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.", "importAction": "Importar", "exportSaved": "Copia guardada", "cancelled": "Cancelado", "importDone": "Importado: {added} nuevas, {updated} actualizadas", + "importCsvDone": "Importadas {count} desde CSV", "importFailed": "Este fichero no se pudo leer como una copia de Tanemaki", "failed": "Algo ha salido mal" }, @@ -73,6 +78,34 @@ "openSourceLicenses": "Licencias de código abierto", "openSourceLicensesSubtitle": "Bibliotecas de terceros y sus licencias" }, + "intro": { + "skip": "Saltar", + "next": "Siguiente", + "start": "Empezar", + "menuEntry": "Cómo funciona Tanemaki", + "slides": { + "welcome": { + "title": "La semilla que te trajo hasta aquí", + "body": "Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio." + }, + "inventory": { + "title": "Tu banco de semillas, en el bolsillo", + "body": "Apunta qué tienes, de qué año, cuánto y de dónde vino, con el nombre que tú usas. Una foto y un nombre bastan para empezar." + }, + "privacy": { + "title": "Tuyo y solo tuyo", + "body": "Sin cuenta, sin internet, sin rastreadores. Tus datos viven cifrados en tu dispositivo y solo se comparte lo que tú decidas." + }, + "share": { + "title": "Compartir, como siempre se hizo", + "body": "Ofrece lo que te sobra —regalo, trueque o venta— y que alguien cerca lo encuentre. Solo se muestra la distancia aproximada, nunca tu dirección; el trato lo cierras en persona." + }, + "plantare": { + "title": "Sembrar es multiplicar", + "body": "Con el Plantare, quien recibe promete devolver semilla más adelante. Y como para devolverla hay que cultivarla, cada préstamo multiplica el común." + } + } + }, "inventory": { "title": "Inventario", "searchHint": "Buscar semillas", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 944369f..c49afe9 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 2 -/// Strings: 366 (183 per locale) +/// Strings: 404 (202 per locale) /// -/// Built on 2026-07-09 at 10:41 UTC +/// Built on 2026-07-09 at 12:34 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 9533dee..cd9cca7 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -48,6 +48,7 @@ class Translations with BaseTranslations { late final Translations$settings$en settings = Translations$settings$en.internal(_root); late final Translations$backup$en backup = Translations$backup$en.internal(_root); late final Translations$about$en about = Translations$about$en.internal(_root); + late final Translations$intro$en intro = Translations$intro$en.internal(_root); late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root); late final Translations$quickAdd$en quickAdd = Translations$quickAdd$en.internal(_root); late final Translations$detail$en detail = Translations$detail$en.internal(_root); @@ -239,12 +240,24 @@ class Translations$backup$en { /// en: 'Merge a saved copy into this inventory' String get importJsonSubtitle => 'Merge a saved copy into this inventory'; + /// en: 'Import from CSV' + String get importCsv => 'Import from CSV'; + + /// en: 'Add a list from a spreadsheet' + String get importCsvSubtitle => 'Add a list from a spreadsheet'; + /// en: 'Import a saved copy?' String get importConfirmTitle => 'Import a saved copy?'; /// en: 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.' String get importConfirmBody => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.'; + /// en: 'Import a CSV list?' + String get importCsvConfirmTitle => 'Import a CSV list?'; + + /// en: 'Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.' + String get importCsvConfirmBody => 'Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.'; + /// en: 'Import' String get importAction => 'Import'; @@ -257,6 +270,9 @@ class Translations$backup$en { /// en: 'Imported: {added} new, {updated} updated' String importDone({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated'; + /// en: 'Imported {count} from CSV' + String importCsvDone({required Object count}) => 'Imported ${count} from CSV'; + /// en: 'This file could not be read as a Tanemaki copy' String get importFailed => 'This file could not be read as a Tanemaki copy'; @@ -306,6 +322,29 @@ class Translations$about$en { String get openSourceLicensesSubtitle => 'Third-party libraries and their licenses'; } +// Path: intro +class Translations$intro$en { + Translations$intro$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Skip' + String get skip => 'Skip'; + + /// en: 'Next' + String get next => 'Next'; + + /// en: 'Get started' + String get start => 'Get started'; + + /// en: 'How Tanemaki works' + String get menuEntry => 'How Tanemaki works'; + + late final Translations$intro$slides$en slides = Translations$intro$slides$en.internal(_root); +} + // Path: inventory class Translations$inventory$en { Translations$inventory$en.internal(this._root); @@ -631,6 +670,20 @@ class Translations$unit$en { late final Translations$unit$count$en count = Translations$unit$count$en.internal(_root); } +// Path: intro.slides +class Translations$intro$slides$en { + Translations$intro$slides$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + late final Translations$intro$slides$welcome$en welcome = Translations$intro$slides$welcome$en.internal(_root); + late final Translations$intro$slides$inventory$en inventory = Translations$intro$slides$inventory$en.internal(_root); + late final Translations$intro$slides$privacy$en privacy = Translations$intro$slides$privacy$en.internal(_root); + late final Translations$intro$slides$share$en share = Translations$intro$slides$share$en.internal(_root); + late final Translations$intro$slides$plantare$en plantare = Translations$intro$slides$plantare$en.internal(_root); +} + // Path: unit.handful class Translations$unit$handful$en { Translations$unit$handful$en.internal(this._root); @@ -991,6 +1044,81 @@ class Translations$unit$count$en { String get plural => 'seeds'; } +// Path: intro.slides.welcome +class Translations$intro$slides$welcome$en { + Translations$intro$slides$welcome$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'The seed that brought you here' + String get title => 'The seed that brought you here'; + + /// en: 'Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing.' + String get body => 'Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing.'; +} + +// Path: intro.slides.inventory +class Translations$intro$slides$inventory$en { + Translations$intro$slides$inventory$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Your seed bank, in your pocket' + String get title => 'Your seed bank, in your pocket'; + + /// en: 'Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start.' + String get body => 'Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start.'; +} + +// Path: intro.slides.privacy +class Translations$intro$slides$privacy$en { + Translations$intro$slides$privacy$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Yours, and only yours' + String get title => 'Yours, and only yours'; + + /// en: 'No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared.' + String get body => 'No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared.'; +} + +// Path: intro.slides.share +class Translations$intro$slides$share$en { + Translations$intro$slides$share$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Share, the way it's always been done' + String get title => 'Share, the way it\'s always been done'; + + /// en: 'Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person.' + String get body => 'Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person.'; +} + +// Path: intro.slides.plantare +class Translations$intro$slides$plantare$en { + Translations$intro$slides$plantare$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'To sow is to multiply' + String get title => 'To sow is to multiply'; + + /// en: 'With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons.' + String get body => 'With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons.'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -1038,12 +1166,17 @@ extension on Translations { 'backup.exportJsonSubtitle' => 'Complete copy, can be imported back', 'backup.importJson' => 'Import from JSON', 'backup.importJsonSubtitle' => 'Merge a saved copy into this inventory', + 'backup.importCsv' => 'Import from CSV', + 'backup.importCsvSubtitle' => 'Add a list from a spreadsheet', 'backup.importConfirmTitle' => 'Import a saved copy?', 'backup.importConfirmBody' => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.', + 'backup.importCsvConfirmTitle' => 'Import a CSV list?', + 'backup.importCsvConfirmBody' => 'Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.', 'backup.importAction' => 'Import', 'backup.exportSaved' => 'Copy saved', 'backup.cancelled' => 'Cancelled', 'backup.importDone' => ({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated', + 'backup.importCsvDone' => ({required Object count}) => 'Imported ${count} from CSV', 'backup.importFailed' => 'This file could not be read as a Tanemaki copy', 'backup.failed' => 'Something went wrong', 'about.title' => 'About', @@ -1057,6 +1190,20 @@ extension on Translations { 'about.website' => 'Website', 'about.openSourceLicenses' => 'Open-source licenses', 'about.openSourceLicensesSubtitle' => 'Third-party libraries and their licenses', + 'intro.skip' => 'Skip', + 'intro.next' => 'Next', + 'intro.start' => 'Get started', + 'intro.menuEntry' => 'How Tanemaki works', + 'intro.slides.welcome.title' => 'The seed that brought you here', + 'intro.slides.welcome.body' => 'Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing.', + 'intro.slides.inventory.title' => 'Your seed bank, in your pocket', + 'intro.slides.inventory.body' => 'Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start.', + 'intro.slides.privacy.title' => 'Yours, and only yours', + 'intro.slides.privacy.body' => 'No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared.', + 'intro.slides.share.title' => 'Share, the way it\'s always been done', + 'intro.slides.share.body' => 'Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person.', + 'intro.slides.plantare.title' => 'To sow is to multiply', + 'intro.slides.plantare.body' => 'With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons.', 'inventory.title' => 'Inventory', 'inventory.searchHint' => 'Search seeds', 'inventory.empty' => 'No seeds yet. Tap + to add your first.', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 6a066aa..adf8235 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -47,6 +47,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Copia completa, se puede volver a importar'; @override String get importJson => 'Importar desde JSON'; @override String get importJsonSubtitle => 'Fusiona una copia guardada con este inventario'; + @override String get importCsv => 'Importar desde CSV'; + @override String get importCsvSubtitle => 'Añade una lista desde una hoja de cálculo'; @override String get importConfirmTitle => '¿Importar una copia guardada?'; @override String get importConfirmBody => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.'; + @override String get importCsvConfirmTitle => '¿Importar una lista CSV?'; + @override String get importCsvConfirmBody => 'Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.'; @override String get importAction => 'Importar'; @override String get exportSaved => 'Copia guardada'; @override String get cancelled => 'Cancelado'; @override String importDone({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas'; + @override String importCsvDone({required Object count}) => 'Importadas ${count} desde CSV'; @override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki'; @override String get failed => 'Algo ha salido mal'; } @@ -189,6 +195,20 @@ class _Translations$about$es extends Translations$about$en { @override String get openSourceLicensesSubtitle => 'Bibliotecas de terceros y sus licencias'; } +// Path: intro +class _Translations$intro$es extends Translations$intro$en { + _Translations$intro$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get skip => 'Saltar'; + @override String get next => 'Siguiente'; + @override String get start => 'Empezar'; + @override String get menuEntry => 'Cómo funciona Tanemaki'; + @override late final _Translations$intro$slides$es slides = _Translations$intro$slides$es._(_root); +} + // Path: inventory class _Translations$inventory$es extends Translations$inventory$en { _Translations$inventory$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -382,6 +402,20 @@ class _Translations$unit$es extends Translations$unit$en { @override late final _Translations$unit$count$es count = _Translations$unit$count$es._(_root); } +// Path: intro.slides +class _Translations$intro$slides$es extends Translations$intro$slides$en { + _Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override late final _Translations$intro$slides$welcome$es welcome = _Translations$intro$slides$welcome$es._(_root); + @override late final _Translations$intro$slides$inventory$es inventory = _Translations$intro$slides$inventory$es._(_root); + @override late final _Translations$intro$slides$privacy$es privacy = _Translations$intro$slides$privacy$es._(_root); + @override late final _Translations$intro$slides$share$es share = _Translations$intro$slides$share$es._(_root); + @override late final _Translations$intro$slides$plantare$es plantare = _Translations$intro$slides$plantare$es._(_root); +} + // Path: unit.handful class _Translations$unit$handful$es extends Translations$unit$handful$en { _Translations$unit$handful$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -646,6 +680,61 @@ class _Translations$unit$count$es extends Translations$unit$count$en { @override String get plural => 'semillas'; } +// Path: intro.slides.welcome +class _Translations$intro$slides$welcome$es extends Translations$intro$slides$welcome$en { + _Translations$intro$slides$welcome$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'La semilla que te trajo hasta aquí'; + @override String get body => 'Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio.'; +} + +// Path: intro.slides.inventory +class _Translations$intro$slides$inventory$es extends Translations$intro$slides$inventory$en { + _Translations$intro$slides$inventory$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Tu banco de semillas, en el bolsillo'; + @override String get body => 'Apunta qué tienes, de qué año, cuánto y de dónde vino, con el nombre que tú usas. Una foto y un nombre bastan para empezar.'; +} + +// Path: intro.slides.privacy +class _Translations$intro$slides$privacy$es extends Translations$intro$slides$privacy$en { + _Translations$intro$slides$privacy$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Tuyo y solo tuyo'; + @override String get body => 'Sin cuenta, sin internet, sin rastreadores. Tus datos viven cifrados en tu dispositivo y solo se comparte lo que tú decidas.'; +} + +// Path: intro.slides.share +class _Translations$intro$slides$share$es extends Translations$intro$slides$share$en { + _Translations$intro$slides$share$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Compartir, como siempre se hizo'; + @override String get body => 'Ofrece lo que te sobra —regalo, trueque o venta— y que alguien cerca lo encuentre. Solo se muestra la distancia aproximada, nunca tu dirección; el trato lo cierras en persona.'; +} + +// Path: intro.slides.plantare +class _Translations$intro$slides$plantare$es extends Translations$intro$slides$plantare$en { + _Translations$intro$slides$plantare$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get title => 'Sembrar es multiplicar'; + @override String get body => 'Con el Plantare, quien recibe promete devolver semilla más adelante. Y como para devolverla hay que cultivarla, cada préstamo multiplica el común.'; +} + /// The flat map containing all translations for locale . /// Only for edge cases! For simple maps, use the map function of this library. /// @@ -693,12 +782,17 @@ extension on TranslationsEs { 'backup.exportJsonSubtitle' => 'Copia completa, se puede volver a importar', 'backup.importJson' => 'Importar desde JSON', 'backup.importJsonSubtitle' => 'Fusiona una copia guardada con este inventario', + 'backup.importCsv' => 'Importar desde CSV', + 'backup.importCsvSubtitle' => 'Añade una lista desde una hoja de cálculo', 'backup.importConfirmTitle' => '¿Importar una copia guardada?', 'backup.importConfirmBody' => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.', + 'backup.importCsvConfirmTitle' => '¿Importar una lista CSV?', + 'backup.importCsvConfirmBody' => 'Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.', 'backup.importAction' => 'Importar', 'backup.exportSaved' => 'Copia guardada', 'backup.cancelled' => 'Cancelado', 'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas', + 'backup.importCsvDone' => ({required Object count}) => 'Importadas ${count} desde CSV', 'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki', 'backup.failed' => 'Algo ha salido mal', 'about.title' => 'Acerca de', @@ -712,6 +806,20 @@ extension on TranslationsEs { 'about.website' => 'Sitio web', 'about.openSourceLicenses' => 'Licencias de código abierto', 'about.openSourceLicensesSubtitle' => 'Bibliotecas de terceros y sus licencias', + 'intro.skip' => 'Saltar', + 'intro.next' => 'Siguiente', + 'intro.start' => 'Empezar', + 'intro.menuEntry' => 'Cómo funciona Tanemaki', + 'intro.slides.welcome.title' => 'La semilla que te trajo hasta aquí', + 'intro.slides.welcome.body' => 'Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio.', + 'intro.slides.inventory.title' => 'Tu banco de semillas, en el bolsillo', + 'intro.slides.inventory.body' => 'Apunta qué tienes, de qué año, cuánto y de dónde vino, con el nombre que tú usas. Una foto y un nombre bastan para empezar.', + 'intro.slides.privacy.title' => 'Tuyo y solo tuyo', + 'intro.slides.privacy.body' => 'Sin cuenta, sin internet, sin rastreadores. Tus datos viven cifrados en tu dispositivo y solo se comparte lo que tú decidas.', + 'intro.slides.share.title' => 'Compartir, como siempre se hizo', + 'intro.slides.share.body' => 'Ofrece lo que te sobra —regalo, trueque o venta— y que alguien cerca lo encuentre. Solo se muestra la distancia aproximada, nunca tu dirección; el trato lo cierras en persona.', + 'intro.slides.plantare.title' => 'Sembrar es multiplicar', + 'intro.slides.plantare.body' => 'Con el Plantare, quien recibe promete devolver semilla más adelante. Y como para devolverla hay que cultivarla, cada préstamo multiplica el común.', 'inventory.title' => 'Inventario', 'inventory.searchHint' => 'Buscar semillas', 'inventory.empty' => 'Aún no hay semillas. Toca + para añadir la primera.', diff --git a/apps/app_seeds/lib/services/export_import_service.dart b/apps/app_seeds/lib/services/export_import_service.dart index bae2a83..46d5c4e 100644 --- a/apps/app_seeds/lib/services/export_import_service.dart +++ b/apps/app_seeds/lib/services/export_import_service.dart @@ -56,6 +56,16 @@ class ExportImportService { return _repository.importInventory(snapshot); } + /// Imports a spreadsheet CSV additively (every row becomes a new variety/lot; + /// see [VarietyRepository.importCsv]). Returns the summary (`inserted` = + /// varieties added), or null when the user cancelled the file dialog. + Future importCsv() async { + final bytes = await _files.pickFileBytes(allowedExtensions: ['csv']); + if (bytes == null) return null; + final csv = _csvCodec.decode(utf8.decode(bytes)); + return _repository.importCsv(csv); + } + String _fileName(String extension) { final date = _now().toIso8601String().substring(0, 10); return 'tanemaki-inventory-$date.$extension'; diff --git a/apps/app_seeds/lib/ui/backup_section.dart b/apps/app_seeds/lib/ui/backup_section.dart index 156b636..27b9ccd 100644 --- a/apps/app_seeds/lib/ui/backup_section.dart +++ b/apps/app_seeds/lib/ui/backup_section.dart @@ -39,6 +39,12 @@ class BackupSection extends StatelessWidget { subtitle: Text(t.backup.importJsonSubtitle), onTap: () => _runImport(context), ), + ListTile( + leading: const Icon(Icons.playlist_add_outlined), + title: Text(t.backup.importCsv), + subtitle: Text(t.backup.importCsvSubtitle), + onTap: () => _runImportCsv(context), + ), ], ); } @@ -96,6 +102,42 @@ class BackupSection extends StatelessWidget { } } + Future _runImportCsv(BuildContext context) async { + final t = context.t; + final messenger = ScaffoldMessenger.of(context); + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(t.backup.importCsvConfirmTitle), + content: Text(t.backup.importCsvConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: Text(t.common.cancel), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(t.backup.importAction), + ), + ], + ), + ); + if (confirmed != true) return; + try { + final summary = await _service.importCsv(); + _show( + messenger, + summary == null + ? t.backup.cancelled + : t.backup.importCsvDone(count: summary.inserted), + ); + } on FormatException { + _show(messenger, t.backup.importFailed); + } on Object { + _show(messenger, t.backup.failed); + } + } + void _show(ScaffoldMessengerState messenger, String message) { messenger.showSnackBar(SnackBar(content: Text(message))); } diff --git a/apps/app_seeds/test/data/export_import/csv_import_repository_test.dart b/apps/app_seeds/test/data/export_import/csv_import_repository_test.dart new file mode 100644 index 0000000..a4b2594 --- /dev/null +++ b/apps/app_seeds/test/data/export_import/csv_import_repository_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/export_import/inventory_csv_codec.dart'; +import 'package:tane/data/species_repository.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +import '../../support/test_support.dart'; + +void main() { + const codec = InventoryCsvCodec(); + const tomato = SpeciesSeed( + scientificName: 'Solanum lycopersicum', + family: 'Solanaceae', + ); + + late AppDatabase db; + + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + test('inserts a variety with its lot, resolving species by name', () async { + await newTestSpeciesRepository(db).seedBundled(const [tomato]); + final repo = newTestRepository(db); + + const csv = 'variety_label,category,species_scientific_name,' + 'cultivar_name,lot_type,harvest_year,quantity_kind,quantity_precise\r\n' + 'Grandma tomato,Solanaceae,Solanum lycopersicum,Marmande,seed,2024,cob,3\r\n'; + final summary = await repo.importCsv(codec.decode(csv)); + expect(summary.inserted, 1); + + final variety = await db.select(db.varieties).getSingle(); + expect(variety.label, 'Grandma tomato'); + expect(variety.cultivarName, 'Marmande'); + final species = await db.select(db.species).getSingle(); + expect(variety.speciesId, species.id); + + final lot = await db.select(db.lots).getSingle(); + expect(lot.varietyId, variety.id); + expect(lot.type, LotType.seed); + expect(lot.harvestYear, 2024); + expect(lot.quantityKind, 'cob'); + expect(lot.quantityPrecise, 3.0); + }); + + test('an unmatched species name leaves speciesId null', () async { + final repo = newTestRepository(db); // no bundled catalog + const csv = 'variety_label,species_scientific_name\r\n' + 'Mystery bean,Phaseolus nonexistus\r\n'; + await repo.importCsv(codec.decode(csv)); + final variety = await db.select(db.varieties).getSingle(); + expect(variety.speciesId, isNull); + }); + + test('is additive: importing the same CSV twice adds it twice', () async { + final repo = newTestRepository(db); + const csv = 'variety_label\r\nTomato\r\n'; + await repo.importCsv(codec.decode(csv)); + await repo.importCsv(codec.decode(csv)); + final varieties = await db.select(db.varieties).get(); + expect(varieties, hasLength(2)); + }); + + test('stores vernacular names and links attached to the new variety', () async { + final repo = newTestRepository(db); + const csv = 'variety_label,vernacular_names,external_links\r\n' + 'Tomato,"tomate (es); granny tomato","Wiki|https://example.org"\r\n'; + await repo.importCsv(codec.decode(csv)); + + final variety = await db.select(db.varieties).getSingle(); + final names = await db.select(db.varietyVernacularNames).get(); + expect(names.map((n) => n.name), containsAll(['tomate', 'granny tomato'])); + expect(names.every((n) => n.varietyId == variety.id), isTrue); + + final links = await db.select(db.externalLinks).get(); + expect(links.single.url, 'https://example.org'); + expect(links.single.parentId, variety.id); + }); +} diff --git a/apps/app_seeds/test/data/export_import/inventory_csv_import_test.dart b/apps/app_seeds/test/data/export_import/inventory_csv_import_test.dart new file mode 100644 index 0000000..ca59c7a --- /dev/null +++ b/apps/app_seeds/test/data/export_import/inventory_csv_import_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/data/export_import/inventory_csv_codec.dart'; +import 'package:tane/data/export_import/inventory_snapshot.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; + +void main() { + const codec = InventoryCsvCodec(); + + group('decode (additive CSV import)', () { + test('a minimal one-column list only needs variety_label', () { + final csv = 'variety_label\r\nGrandma tomato\r\nPurple carrot\r\n'; + final result = codec.decode(csv); + expect(result.skippedRows, 0); + expect(result.varieties.map((v) => v.label), [ + 'Grandma tomato', + 'Purple carrot', + ]); + // No lot columns → no lots. + expect(result.varieties.first.lots, isEmpty); + }); + + test('skips rows without a variety_label', () { + final csv = 'variety_label,category\r\n,Solanaceae\r\nTomato,Solanaceae\r\n'; + final result = codec.decode(csv); + expect(result.skippedRows, 1); + expect(result.varieties, hasLength(1)); + expect(result.varieties.single.label, 'Tomato'); + }); + + test('maps columns by header name regardless of order', () { + final csv = 'category,variety_label,harvest_year,lot_type\r\n' + 'Solanaceae,Tomato,2024,seed\r\n'; + final v = codec.decode(csv).varieties.single; + expect(v.label, 'Tomato'); + expect(v.category, 'Solanaceae'); + expect(v.lots.single.harvestYear, 2024); + expect(v.lots.single.type, LotType.seed); + }); + + test('ignores unknown columns', () { + final csv = 'variety_label,made_up_column\r\nTomato,whatever\r\n'; + final v = codec.decode(csv).varieties.single; + expect(v.label, 'Tomato'); + }); + + test('unknown enums fall back: lot_type→seed, offer_status→private, ' + 'quantity_kind dropped', () { + final csv = 'variety_label,lot_type,offer_status,quantity_kind\r\n' + 'Tomato,spaceship,gift,truckload\r\n'; + final lot = codec.decode(csv).varieties.single.lots.single; + expect(lot.type, LotType.seed); + expect(lot.offerStatus, OfferStatus.private); + expect(lot.quantityKind, isNull); + }); + + test('keeps a valid quantity_kind and parses precise/label', () { + final csv = 'variety_label,quantity_kind,quantity_precise,quantity_label\r\n' + 'Maize,cob,3,a handful\r\n'; + final lot = codec.decode(csv).varieties.single.lots.single; + expect(lot.quantityKind, 'cob'); + expect(lot.quantityPrecise, 3.0); + expect(lot.quantityLabel, 'a handful'); + }); + + test('rows sharing a variety_id collapse into one variety with many lots', () { + final csv = 'variety_id,variety_label,lot_type,harvest_year\r\n' + 'v1,Tomato,seed,2023\r\n' + 'v1,Tomato,seed,2024\r\n' + 'v2,Carrot,seed,2024\r\n'; + final result = codec.decode(csv); + expect(result.varieties, hasLength(2)); + final tomato = result.varieties.firstWhere((v) => v.label == 'Tomato'); + expect(tomato.lots.map((l) => l.harvestYear), [2023, 2024]); + }); + + test('rows without a variety_id each become their own variety', () { + final csv = 'variety_label,lot_type\r\nTomato,seed\r\nTomato,plant\r\n'; + final result = codec.decode(csv); + expect(result.varieties, hasLength(2)); + }); + + test('parses quoted fields with commas, doubled quotes and newlines', () { + final csv = 'variety_label,variety_notes\r\n' + '"Bean, ""the best""","line one\nline two"\r\n'; + final v = codec.decode(csv).varieties.single; + expect(v.label, 'Bean, "the best"'); + expect(v.notes, 'line one\nline two'); + }); + + test('splits vernacular names and links back out', () { + final csv = 'variety_label,vernacular_names,external_links\r\n' + 'Tomato,"tomate (es); granny tomato","Wiki|https://example.org"\r\n'; + final v = codec.decode(csv).varieties.single; + expect(v.vernacularNames.map((n) => n.name), ['tomate', 'granny tomato']); + expect(v.vernacularNames.first.language, 'es'); + expect(v.links.single.url, 'https://example.org'); + expect(v.links.single.title, 'Wiki'); + }); + + test('tolerates \\n-only line endings and a missing trailing newline', () { + final csv = 'variety_label\nTomato\nCarrot'; + final result = codec.decode(csv); + expect(result.varieties.map((v) => v.label), ['Tomato', 'Carrot']); + }); + + test('an empty or header-only file yields nothing', () { + expect(codec.decode('').varieties, isEmpty); + expect(codec.decode('variety_label\r\n').varieties, isEmpty); + }); + }); + + group('round-trip with the export encoder', () { + Variety variety() => Variety( + id: 'var-1', + createdAt: 0, + updatedAt: '000000000001000:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + label: 'Grandma tomato', + speciesId: 'sp-1', + cultivarName: 'Marmande', + category: 'Solanaceae', + notes: 'heirloom', + ); + + Lot lot({String id = 'lot-1'}) => Lot( + id: id, + createdAt: 0, + updatedAt: '000000000002000:00000:node-a', + lastAuthor: 'node-a', + isDeleted: false, + schemaRowVersion: 1, + varietyId: 'var-1', + type: LotType.seed, + harvestYear: 2024, + harvestMonth: 9, + quantityKind: 'cob', + quantityPrecise: 2, + quantityLabel: null, + presentation: null, + storageLocation: 'fridge', + offerStatus: OfferStatus.shared, + seedbankId: null, + ); + + test('encode → decode preserves variety fields and both lots', () { + final csv = codec.encode( + InventorySnapshot( + varieties: [variety()], + lots: [lot(), lot(id: 'lot-2')], + speciesNamesById: const {'sp-1': 'Solanum lycopersicum'}, + ), + ); + final v = codec.decode(csv).varieties.single; + expect(v.label, 'Grandma tomato'); + expect(v.category, 'Solanaceae'); + expect(v.cultivarName, 'Marmande'); + expect(v.notes, 'heirloom'); + expect(v.scientificName, 'Solanum lycopersicum'); + expect(v.lots, hasLength(2)); + final l = v.lots.first; + expect(l.type, LotType.seed); + expect(l.harvestYear, 2024); + expect(l.harvestMonth, 9); + expect(l.quantityKind, 'cob'); + expect(l.quantityPrecise, 2.0); + expect(l.storageLocation, 'fridge'); + expect(l.offerStatus, OfferStatus.shared); + }); + }); +}