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).
This commit is contained in:
vjrj 2026-07-09 14:41:28 +02:00
parent 21072633b5
commit 89b61bc9e4
11 changed files with 1070 additions and 2 deletions

View file

@ -1,3 +1,5 @@
import 'package:commons_core/commons_core.dart' show QuantityKind;
import '../../db/database.dart'; import '../../db/database.dart';
import '../../db/enums.dart'; import '../../db/enums.dart';
import 'inventory_snapshot.dart'; import 'inventory_snapshot.dart';
@ -141,4 +143,338 @@ class InventoryCsvCodec {
} }
return '"${cell.replaceAll('"', '""')}"'; 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 = <String, int>{};
for (var i = 0; i < header.length; i++) {
index[header[i].trim()] = i;
}
String? cell(List<String> 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 = <String, _CsvVarietyBuilder>{};
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<String> row, String? Function(List<String>, 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<CsvImportName> _parseVernacular(String? cell) {
if (cell == null) return const [];
final out = <CsvImportName>[];
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<CsvImportLink> _parseLinks(String? cell) {
if (cell == null) return const [];
final out = <CsvImportLink>[];
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<T extends Enum>(List<T> values, String? name) {
if (name == null) return null;
return values.asNameMap().containsKey(name) ? name : null;
}
T _enumOr<T extends Enum>(List<T> values, String? name, T fallback) =>
_enumOrNull(values, name) ?? fallback;
T? _enumOrNull<T extends Enum>(List<T> 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<List<String>> _parse(String input) {
final rows = <List<String>>[];
var row = <String>[];
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 = <String>[];
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<CsvImportVariety> 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<CsvImportName> vernacularNames;
final List<CsvImportLink> links;
final List<CsvImportLot> 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<CsvImportName> vernacularNames;
final List<CsvImportLink> links;
final List<CsvImportLot> lots = [];
CsvImportVariety build() => CsvImportVariety(
label: label,
category: category,
scientificName: scientificName,
cultivarName: cultivarName,
notes: notes,
vernacularNames: vernacularNames,
links: links,
lots: List.unmodifiable(lots),
);
} }

View file

@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart';
import '../db/database.dart'; import '../db/database.dart';
import '../db/enums.dart'; import '../db/enums.dart';
import 'export_import/import_reconciler.dart'; import 'export_import/import_reconciler.dart';
import 'export_import/inventory_csv_codec.dart';
import 'export_import/inventory_snapshot.dart'; import 'export_import/inventory_snapshot.dart';
/// A lightweight row for the inventory list (only what the list renders). /// A lightweight row for the inventory list (only what the list renders).
@ -961,6 +962,113 @@ class VarietyRepository {
return summary; 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<ImportSummary> 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<String?> _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. /// Maps incoming species ids to local catalog ids by scientific name.
Future<Map<String, String?>> _resolveSpecies( Future<Map<String, String?>> _resolveSpecies(
Map<String, String> speciesNamesById, Map<String, String> speciesNamesById,

View file

@ -51,12 +51,17 @@
"exportJsonSubtitle": "Complete copy, can be imported back", "exportJsonSubtitle": "Complete copy, can be imported back",
"importJson": "Import from JSON", "importJson": "Import from JSON",
"importJsonSubtitle": "Merge a saved copy into this inventory", "importJsonSubtitle": "Merge a saved copy into this inventory",
"importCsv": "Import from CSV",
"importCsvSubtitle": "Add a list from a spreadsheet",
"importConfirmTitle": "Import a saved copy?", "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.", "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", "importAction": "Import",
"exportSaved": "Copy saved", "exportSaved": "Copy saved",
"cancelled": "Cancelled", "cancelled": "Cancelled",
"importDone": "Imported: {added} new, {updated} updated", "importDone": "Imported: {added} new, {updated} updated",
"importCsvDone": "Imported {count} from CSV",
"importFailed": "This file could not be read as a Tanemaki copy", "importFailed": "This file could not be read as a Tanemaki copy",
"failed": "Something went wrong" "failed": "Something went wrong"
}, },
@ -73,6 +78,34 @@
"openSourceLicenses": "Open-source licenses", "openSourceLicenses": "Open-source licenses",
"openSourceLicensesSubtitle": "Third-party libraries and their 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": { "inventory": {
"title": "Inventory", "title": "Inventory",
"searchHint": "Search seeds", "searchHint": "Search seeds",

View file

@ -51,12 +51,17 @@
"exportJsonSubtitle": "Copia completa, se puede volver a importar", "exportJsonSubtitle": "Copia completa, se puede volver a importar",
"importJson": "Importar desde JSON", "importJson": "Importar desde JSON",
"importJsonSubtitle": "Fusiona una copia guardada con este inventario", "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?", "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.", "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", "importAction": "Importar",
"exportSaved": "Copia guardada", "exportSaved": "Copia guardada",
"cancelled": "Cancelado", "cancelled": "Cancelado",
"importDone": "Importado: {added} nuevas, {updated} actualizadas", "importDone": "Importado: {added} nuevas, {updated} actualizadas",
"importCsvDone": "Importadas {count} desde CSV",
"importFailed": "Este fichero no se pudo leer como una copia de Tanemaki", "importFailed": "Este fichero no se pudo leer como una copia de Tanemaki",
"failed": "Algo ha salido mal" "failed": "Algo ha salido mal"
}, },
@ -73,6 +78,34 @@
"openSourceLicenses": "Licencias de código abierto", "openSourceLicenses": "Licencias de código abierto",
"openSourceLicensesSubtitle": "Bibliotecas de terceros y sus licencias" "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": { "inventory": {
"title": "Inventario", "title": "Inventario",
"searchHint": "Buscar semillas", "searchHint": "Buscar semillas",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 2 /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -48,6 +48,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$settings$en settings = Translations$settings$en.internal(_root); 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$backup$en backup = Translations$backup$en.internal(_root);
late final Translations$about$en about = Translations$about$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$inventory$en inventory = Translations$inventory$en.internal(_root);
late final Translations$quickAdd$en quickAdd = Translations$quickAdd$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); 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' /// en: 'Merge a saved copy into this inventory'
String get importJsonSubtitle => '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?' /// en: 'Import a saved copy?'
String get importConfirmTitle => '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.' /// 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.'; 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' /// en: 'Import'
String get importAction => 'Import'; String get importAction => 'Import';
@ -257,6 +270,9 @@ class Translations$backup$en {
/// en: 'Imported: {added} new, {updated} updated' /// en: 'Imported: {added} new, {updated} updated'
String importDone({required Object added, required Object updated}) => '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' /// en: 'This file could not be read as a Tanemaki copy'
String get importFailed => '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'; 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 // Path: inventory
class Translations$inventory$en { class Translations$inventory$en {
Translations$inventory$en.internal(this._root); 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); 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 // Path: unit.handful
class Translations$unit$handful$en { class Translations$unit$handful$en {
Translations$unit$handful$en.internal(this._root); Translations$unit$handful$en.internal(this._root);
@ -991,6 +1044,81 @@ class Translations$unit$count$en {
String get plural => 'seeds'; 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 <en>. /// The flat map containing all translations for locale <en>.
/// Only for edge cases! For simple maps, use the map function of this library. /// 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.exportJsonSubtitle' => 'Complete copy, can be imported back',
'backup.importJson' => 'Import from JSON', 'backup.importJson' => 'Import from JSON',
'backup.importJsonSubtitle' => 'Merge a saved copy into this inventory', '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.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.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.importAction' => 'Import',
'backup.exportSaved' => 'Copy saved', 'backup.exportSaved' => 'Copy saved',
'backup.cancelled' => 'Cancelled', 'backup.cancelled' => 'Cancelled',
'backup.importDone' => ({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated', '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.importFailed' => 'This file could not be read as a Tanemaki copy',
'backup.failed' => 'Something went wrong', 'backup.failed' => 'Something went wrong',
'about.title' => 'About', 'about.title' => 'About',
@ -1057,6 +1190,20 @@ extension on Translations {
'about.website' => 'Website', 'about.website' => 'Website',
'about.openSourceLicenses' => 'Open-source licenses', 'about.openSourceLicenses' => 'Open-source licenses',
'about.openSourceLicensesSubtitle' => 'Third-party libraries and their 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.title' => 'Inventory',
'inventory.searchHint' => 'Search seeds', 'inventory.searchHint' => 'Search seeds',
'inventory.empty' => 'No seeds yet. Tap + to add your first.', 'inventory.empty' => 'No seeds yet. Tap + to add your first.',

View file

@ -47,6 +47,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$settings$es settings = _Translations$settings$es._(_root); @override late final _Translations$settings$es settings = _Translations$settings$es._(_root);
@override late final _Translations$backup$es backup = _Translations$backup$es._(_root); @override late final _Translations$backup$es backup = _Translations$backup$es._(_root);
@override late final _Translations$about$es about = _Translations$about$es._(_root); @override late final _Translations$about$es about = _Translations$about$es._(_root);
@override late final _Translations$intro$es intro = _Translations$intro$es._(_root);
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root); @override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$es._(_root); @override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$es._(_root);
@override late final _Translations$detail$es detail = _Translations$detail$es._(_root); @override late final _Translations$detail$es detail = _Translations$detail$es._(_root);
@ -159,12 +160,17 @@ class _Translations$backup$es extends Translations$backup$en {
@override String get exportJsonSubtitle => 'Copia completa, se puede volver a importar'; @override String get exportJsonSubtitle => 'Copia completa, se puede volver a importar';
@override String get importJson => 'Importar desde JSON'; @override String get importJson => 'Importar desde JSON';
@override String get importJsonSubtitle => 'Fusiona una copia guardada con este inventario'; @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 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 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 importAction => 'Importar';
@override String get exportSaved => 'Copia guardada'; @override String get exportSaved => 'Copia guardada';
@override String get cancelled => 'Cancelado'; @override String get cancelled => 'Cancelado';
@override String importDone({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas'; @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 importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki';
@override String get failed => 'Algo ha salido mal'; @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'; @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 // Path: inventory
class _Translations$inventory$es extends Translations$inventory$en { class _Translations$inventory$es extends Translations$inventory$en {
_Translations$inventory$es._(TranslationsEs root) : this._root = root, super.internal(root); _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); @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 // Path: unit.handful
class _Translations$unit$handful$es extends Translations$unit$handful$en { class _Translations$unit$handful$es extends Translations$unit$handful$en {
_Translations$unit$handful$es._(TranslationsEs root) : this._root = root, super.internal(root); _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'; @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 <es>. /// The flat map containing all translations for locale <es>.
/// Only for edge cases! For simple maps, use the map function of this library. /// 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.exportJsonSubtitle' => 'Copia completa, se puede volver a importar',
'backup.importJson' => 'Importar desde JSON', 'backup.importJson' => 'Importar desde JSON',
'backup.importJsonSubtitle' => 'Fusiona una copia guardada con este inventario', '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.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.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.importAction' => 'Importar',
'backup.exportSaved' => 'Copia guardada', 'backup.exportSaved' => 'Copia guardada',
'backup.cancelled' => 'Cancelado', 'backup.cancelled' => 'Cancelado',
'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas', '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.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki',
'backup.failed' => 'Algo ha salido mal', 'backup.failed' => 'Algo ha salido mal',
'about.title' => 'Acerca de', 'about.title' => 'Acerca de',
@ -712,6 +806,20 @@ extension on TranslationsEs {
'about.website' => 'Sitio web', 'about.website' => 'Sitio web',
'about.openSourceLicenses' => 'Licencias de código abierto', 'about.openSourceLicenses' => 'Licencias de código abierto',
'about.openSourceLicensesSubtitle' => 'Bibliotecas de terceros y sus licencias', '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.title' => 'Inventario',
'inventory.searchHint' => 'Buscar semillas', 'inventory.searchHint' => 'Buscar semillas',
'inventory.empty' => 'Aún no hay semillas. Toca + para añadir la primera.', 'inventory.empty' => 'Aún no hay semillas. Toca + para añadir la primera.',

View file

@ -56,6 +56,16 @@ class ExportImportService {
return _repository.importInventory(snapshot); 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<ImportSummary?> 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) { String _fileName(String extension) {
final date = _now().toIso8601String().substring(0, 10); final date = _now().toIso8601String().substring(0, 10);
return 'tanemaki-inventory-$date.$extension'; return 'tanemaki-inventory-$date.$extension';

View file

@ -39,6 +39,12 @@ class BackupSection extends StatelessWidget {
subtitle: Text(t.backup.importJsonSubtitle), subtitle: Text(t.backup.importJsonSubtitle),
onTap: () => _runImport(context), 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<void> _runImportCsv(BuildContext context) async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final confirmed = await showDialog<bool>(
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) { void _show(ScaffoldMessengerState messenger, String message) {
messenger.showSnackBar(SnackBar(content: Text(message))); messenger.showSnackBar(SnackBar(content: Text(message)));
} }

View file

@ -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);
});
}

View file

@ -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);
});
});
}