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:
parent
21072633b5
commit
89b61bc9e4
11 changed files with 1070 additions and 2 deletions
|
|
@ -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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue