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/enums.dart';
import 'inventory_snapshot.dart';
@ -141,4 +143,338 @@ class InventoryCsvCodec {
}
return '"${cell.replaceAll('"', '""')}"';
}
/// Parses a CSV back into an **additive** import (the canonical import format
/// is JSON; this is a convenience for lists a collective already keeps in a
/// spreadsheet). Reader tolerance per data-model §5.2:
///
/// - columns are matched **by header name** (any order); unknown columns are
/// ignored, missing columns are treated as empty;
/// - only `variety_label` is required a row without it is skipped;
/// - unknown enum values fall back to a safe default (`lot_type` seed,
/// `offer_status` private) or are dropped (`quantity_kind`, `presentation`);
/// - rows sharing a non-empty `variety_id` collapse into one variety holding
/// several lots; rows without a `variety_id` each become their own variety.
///
/// Sync metadata (`variety_id`/`lot_id`/timestamps) and the derived
/// `latest_germination_rate` are **not** carried over: the repository stamps
/// fresh ids and HLCs so an import never overwrites existing rows.
CsvImport decode(String source) {
final table = _parse(source);
if (table.length < 2) return const CsvImport(varieties: [], skippedRows: 0);
final header = table.first;
final index = <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/enums.dart';
import 'export_import/import_reconciler.dart';
import 'export_import/inventory_csv_codec.dart';
import 'export_import/inventory_snapshot.dart';
/// A lightweight row for the inventory list (only what the list renders).
@ -961,6 +962,113 @@ class VarietyRepository {
return summary;
}
/// Additive import of a spreadsheet [csv]: every parsed variety is inserted
/// fresh (new UUIDv7 + local HLC), so it never overwrites existing rows and
/// re-importing the same file adds it again. The canonical, id-reconciling
/// import is JSON ([importInventory]); this is the low-friction path for a
/// list a collective already keeps in a spreadsheet.
///
/// Species are re-linked by scientific name against the local catalog
/// (unmatched null). Returns the number of varieties added.
Future<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.
Future<Map<String, String?>> _resolveSpecies(
Map<String, String> speciesNamesById,

View file

@ -51,12 +51,17 @@
"exportJsonSubtitle": "Complete copy, can be imported back",
"importJson": "Import from JSON",
"importJsonSubtitle": "Merge a saved copy into this inventory",
"importCsv": "Import from CSV",
"importCsvSubtitle": "Add a list from a spreadsheet",
"importConfirmTitle": "Import a saved copy?",
"importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.",
"importCsvConfirmTitle": "Import a CSV list?",
"importCsvConfirmBody": "Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.",
"importAction": "Import",
"exportSaved": "Copy saved",
"cancelled": "Cancelled",
"importDone": "Imported: {added} new, {updated} updated",
"importCsvDone": "Imported {count} from CSV",
"importFailed": "This file could not be read as a Tanemaki copy",
"failed": "Something went wrong"
},
@ -73,6 +78,34 @@
"openSourceLicenses": "Open-source licenses",
"openSourceLicensesSubtitle": "Third-party libraries and their licenses"
},
"intro": {
"skip": "Skip",
"next": "Next",
"start": "Get started",
"menuEntry": "How Tanemaki works",
"slides": {
"welcome": {
"title": "The seed that brought you here",
"body": "Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing."
},
"inventory": {
"title": "Your seed bank, in your pocket",
"body": "Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start."
},
"privacy": {
"title": "Yours, and only yours",
"body": "No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared."
},
"share": {
"title": "Share, the way it's always been done",
"body": "Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person."
},
"plantare": {
"title": "To sow is to multiply",
"body": "With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons."
}
}
},
"inventory": {
"title": "Inventory",
"searchHint": "Search seeds",

View file

@ -51,12 +51,17 @@
"exportJsonSubtitle": "Copia completa, se puede volver a importar",
"importJson": "Importar desde JSON",
"importJsonSubtitle": "Fusiona una copia guardada con este inventario",
"importCsv": "Importar desde CSV",
"importCsvSubtitle": "Añade una lista desde una hoja de cálculo",
"importConfirmTitle": "¿Importar una copia guardada?",
"importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.",
"importCsvConfirmTitle": "¿Importar una lista CSV?",
"importCsvConfirmBody": "Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.",
"importAction": "Importar",
"exportSaved": "Copia guardada",
"cancelled": "Cancelado",
"importDone": "Importado: {added} nuevas, {updated} actualizadas",
"importCsvDone": "Importadas {count} desde CSV",
"importFailed": "Este fichero no se pudo leer como una copia de Tanemaki",
"failed": "Algo ha salido mal"
},
@ -73,6 +78,34 @@
"openSourceLicenses": "Licencias de código abierto",
"openSourceLicensesSubtitle": "Bibliotecas de terceros y sus licencias"
},
"intro": {
"skip": "Saltar",
"next": "Siguiente",
"start": "Empezar",
"menuEntry": "Cómo funciona Tanemaki",
"slides": {
"welcome": {
"title": "La semilla que te trajo hasta aquí",
"body": "Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio."
},
"inventory": {
"title": "Tu banco de semillas, en el bolsillo",
"body": "Apunta qué tienes, de qué año, cuánto y de dónde vino, con el nombre que tú usas. Una foto y un nombre bastan para empezar."
},
"privacy": {
"title": "Tuyo y solo tuyo",
"body": "Sin cuenta, sin internet, sin rastreadores. Tus datos viven cifrados en tu dispositivo y solo se comparte lo que tú decidas."
},
"share": {
"title": "Compartir, como siempre se hizo",
"body": "Ofrece lo que te sobra —regalo, trueque o venta— y que alguien cerca lo encuentre. Solo se muestra la distancia aproximada, nunca tu dirección; el trato lo cierras en persona."
},
"plantare": {
"title": "Sembrar es multiplicar",
"body": "Con el Plantare, quien recibe promete devolver semilla más adelante. Y como para devolverla hay que cultivarla, cada préstamo multiplica el común."
}
}
},
"inventory": {
"title": "Inventario",
"searchHint": "Buscar semillas",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 366 (183 per locale)
/// Strings: 404 (202 per locale)
///
/// Built on 2026-07-09 at 10:41 UTC
/// Built on 2026-07-09 at 12:34 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

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$backup$en backup = Translations$backup$en.internal(_root);
late final Translations$about$en about = Translations$about$en.internal(_root);
late final Translations$intro$en intro = Translations$intro$en.internal(_root);
late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root);
late final Translations$quickAdd$en quickAdd = Translations$quickAdd$en.internal(_root);
late final Translations$detail$en detail = Translations$detail$en.internal(_root);
@ -239,12 +240,24 @@ class Translations$backup$en {
/// en: 'Merge a saved copy into this inventory'
String get importJsonSubtitle => 'Merge a saved copy into this inventory';
/// en: 'Import from CSV'
String get importCsv => 'Import from CSV';
/// en: 'Add a list from a spreadsheet'
String get importCsvSubtitle => 'Add a list from a spreadsheet';
/// en: 'Import a saved copy?'
String get importConfirmTitle => 'Import a saved copy?';
/// en: 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.'
String get importConfirmBody => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.';
/// en: 'Import a CSV list?'
String get importCsvConfirmTitle => 'Import a CSV list?';
/// en: 'Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.'
String get importCsvConfirmBody => 'Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.';
/// en: 'Import'
String get importAction => 'Import';
@ -257,6 +270,9 @@ class Translations$backup$en {
/// en: 'Imported: {added} new, {updated} updated'
String importDone({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated';
/// en: 'Imported {count} from CSV'
String importCsvDone({required Object count}) => 'Imported ${count} from CSV';
/// en: 'This file could not be read as a Tanemaki copy'
String get importFailed => 'This file could not be read as a Tanemaki copy';
@ -306,6 +322,29 @@ class Translations$about$en {
String get openSourceLicensesSubtitle => 'Third-party libraries and their licenses';
}
// Path: intro
class Translations$intro$en {
Translations$intro$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Skip'
String get skip => 'Skip';
/// en: 'Next'
String get next => 'Next';
/// en: 'Get started'
String get start => 'Get started';
/// en: 'How Tanemaki works'
String get menuEntry => 'How Tanemaki works';
late final Translations$intro$slides$en slides = Translations$intro$slides$en.internal(_root);
}
// Path: inventory
class Translations$inventory$en {
Translations$inventory$en.internal(this._root);
@ -631,6 +670,20 @@ class Translations$unit$en {
late final Translations$unit$count$en count = Translations$unit$count$en.internal(_root);
}
// Path: intro.slides
class Translations$intro$slides$en {
Translations$intro$slides$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
late final Translations$intro$slides$welcome$en welcome = Translations$intro$slides$welcome$en.internal(_root);
late final Translations$intro$slides$inventory$en inventory = Translations$intro$slides$inventory$en.internal(_root);
late final Translations$intro$slides$privacy$en privacy = Translations$intro$slides$privacy$en.internal(_root);
late final Translations$intro$slides$share$en share = Translations$intro$slides$share$en.internal(_root);
late final Translations$intro$slides$plantare$en plantare = Translations$intro$slides$plantare$en.internal(_root);
}
// Path: unit.handful
class Translations$unit$handful$en {
Translations$unit$handful$en.internal(this._root);
@ -991,6 +1044,81 @@ class Translations$unit$count$en {
String get plural => 'seeds';
}
// Path: intro.slides.welcome
class Translations$intro$slides$welcome$en {
Translations$intro$slides$welcome$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'The seed that brought you here'
String get title => 'The seed that brought you here';
/// en: 'Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing.'
String get body => 'Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing.';
}
// Path: intro.slides.inventory
class Translations$intro$slides$inventory$en {
Translations$intro$slides$inventory$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Your seed bank, in your pocket'
String get title => 'Your seed bank, in your pocket';
/// en: 'Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start.'
String get body => 'Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start.';
}
// Path: intro.slides.privacy
class Translations$intro$slides$privacy$en {
Translations$intro$slides$privacy$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Yours, and only yours'
String get title => 'Yours, and only yours';
/// en: 'No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared.'
String get body => 'No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared.';
}
// Path: intro.slides.share
class Translations$intro$slides$share$en {
Translations$intro$slides$share$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Share, the way it's always been done'
String get title => 'Share, the way it\'s always been done';
/// en: 'Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person.'
String get body => 'Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person.';
}
// Path: intro.slides.plantare
class Translations$intro$slides$plantare$en {
Translations$intro$slides$plantare$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'To sow is to multiply'
String get title => 'To sow is to multiply';
/// en: 'With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons.'
String get body => 'With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons.';
}
/// The flat map containing all translations for locale <en>.
/// Only for edge cases! For simple maps, use the map function of this library.
///
@ -1038,12 +1166,17 @@ extension on Translations {
'backup.exportJsonSubtitle' => 'Complete copy, can be imported back',
'backup.importJson' => 'Import from JSON',
'backup.importJsonSubtitle' => 'Merge a saved copy into this inventory',
'backup.importCsv' => 'Import from CSV',
'backup.importCsvSubtitle' => 'Add a list from a spreadsheet',
'backup.importConfirmTitle' => 'Import a saved copy?',
'backup.importConfirmBody' => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.',
'backup.importCsvConfirmTitle' => 'Import a CSV list?',
'backup.importCsvConfirmBody' => 'Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.',
'backup.importAction' => 'Import',
'backup.exportSaved' => 'Copy saved',
'backup.cancelled' => 'Cancelled',
'backup.importDone' => ({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated',
'backup.importCsvDone' => ({required Object count}) => 'Imported ${count} from CSV',
'backup.importFailed' => 'This file could not be read as a Tanemaki copy',
'backup.failed' => 'Something went wrong',
'about.title' => 'About',
@ -1057,6 +1190,20 @@ extension on Translations {
'about.website' => 'Website',
'about.openSourceLicenses' => 'Open-source licenses',
'about.openSourceLicensesSubtitle' => 'Third-party libraries and their licenses',
'intro.skip' => 'Skip',
'intro.next' => 'Next',
'intro.start' => 'Get started',
'intro.menuEntry' => 'How Tanemaki works',
'intro.slides.welcome.title' => 'The seed that brought you here',
'intro.slides.welcome.body' => 'Every traditional seed is a letter written by thousands of generations, passed from hand to hand. We are what we are thanks to that sharing.',
'intro.slides.inventory.title' => 'Your seed bank, in your pocket',
'intro.slides.inventory.body' => 'Note what you have, from which year, how much and where it came from — with the name you use. A photo and a name are enough to start.',
'intro.slides.privacy.title' => 'Yours, and only yours',
'intro.slides.privacy.body' => 'No account, no internet, no trackers. Your data lives encrypted on your device, and only what you choose is ever shared.',
'intro.slides.share.title' => 'Share, the way it\'s always been done',
'intro.slides.share.body' => 'Offer what you have spare — gift, swap or sell — and let someone nearby find it. Only an approximate distance is shown, never your address; you close the deal in person.',
'intro.slides.plantare.title' => 'To sow is to multiply',
'intro.slides.plantare.body' => 'With a Plantare, whoever receives seed promises to return some later. And since returning it means growing it, every loan multiplies the commons.',
'inventory.title' => 'Inventory',
'inventory.searchHint' => 'Search seeds',
'inventory.empty' => 'No seeds yet. Tap + to add your first.',

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$backup$es backup = _Translations$backup$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$quickAdd$es quickAdd = _Translations$quickAdd$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 importJson => 'Importar desde JSON';
@override String get importJsonSubtitle => 'Fusiona una copia guardada con este inventario';
@override String get importCsv => 'Importar desde CSV';
@override String get importCsvSubtitle => 'Añade una lista desde una hoja de cálculo';
@override String get importConfirmTitle => '¿Importar una copia guardada?';
@override String get importConfirmBody => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.';
@override String get importCsvConfirmTitle => '¿Importar una lista CSV?';
@override String get importCsvConfirmBody => 'Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.';
@override String get importAction => 'Importar';
@override String get exportSaved => 'Copia guardada';
@override String get cancelled => 'Cancelado';
@override String importDone({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas';
@override String importCsvDone({required Object count}) => 'Importadas ${count} desde CSV';
@override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki';
@override String get failed => 'Algo ha salido mal';
}
@ -189,6 +195,20 @@ class _Translations$about$es extends Translations$about$en {
@override String get openSourceLicensesSubtitle => 'Bibliotecas de terceros y sus licencias';
}
// Path: intro
class _Translations$intro$es extends Translations$intro$en {
_Translations$intro$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get skip => 'Saltar';
@override String get next => 'Siguiente';
@override String get start => 'Empezar';
@override String get menuEntry => 'Cómo funciona Tanemaki';
@override late final _Translations$intro$slides$es slides = _Translations$intro$slides$es._(_root);
}
// Path: inventory
class _Translations$inventory$es extends Translations$inventory$en {
_Translations$inventory$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -382,6 +402,20 @@ class _Translations$unit$es extends Translations$unit$en {
@override late final _Translations$unit$count$es count = _Translations$unit$count$es._(_root);
}
// Path: intro.slides
class _Translations$intro$slides$es extends Translations$intro$slides$en {
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override late final _Translations$intro$slides$welcome$es welcome = _Translations$intro$slides$welcome$es._(_root);
@override late final _Translations$intro$slides$inventory$es inventory = _Translations$intro$slides$inventory$es._(_root);
@override late final _Translations$intro$slides$privacy$es privacy = _Translations$intro$slides$privacy$es._(_root);
@override late final _Translations$intro$slides$share$es share = _Translations$intro$slides$share$es._(_root);
@override late final _Translations$intro$slides$plantare$es plantare = _Translations$intro$slides$plantare$es._(_root);
}
// Path: unit.handful
class _Translations$unit$handful$es extends Translations$unit$handful$en {
_Translations$unit$handful$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -646,6 +680,61 @@ class _Translations$unit$count$es extends Translations$unit$count$en {
@override String get plural => 'semillas';
}
// Path: intro.slides.welcome
class _Translations$intro$slides$welcome$es extends Translations$intro$slides$welcome$en {
_Translations$intro$slides$welcome$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'La semilla que te trajo hasta aquí';
@override String get body => 'Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio.';
}
// Path: intro.slides.inventory
class _Translations$intro$slides$inventory$es extends Translations$intro$slides$inventory$en {
_Translations$intro$slides$inventory$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Tu banco de semillas, en el bolsillo';
@override String get body => 'Apunta qué tienes, de qué año, cuánto y de dónde vino, con el nombre que tú usas. Una foto y un nombre bastan para empezar.';
}
// Path: intro.slides.privacy
class _Translations$intro$slides$privacy$es extends Translations$intro$slides$privacy$en {
_Translations$intro$slides$privacy$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Tuyo y solo tuyo';
@override String get body => 'Sin cuenta, sin internet, sin rastreadores. Tus datos viven cifrados en tu dispositivo y solo se comparte lo que tú decidas.';
}
// Path: intro.slides.share
class _Translations$intro$slides$share$es extends Translations$intro$slides$share$en {
_Translations$intro$slides$share$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Compartir, como siempre se hizo';
@override String get body => 'Ofrece lo que te sobra —regalo, trueque o venta— y que alguien cerca lo encuentre. Solo se muestra la distancia aproximada, nunca tu dirección; el trato lo cierras en persona.';
}
// Path: intro.slides.plantare
class _Translations$intro$slides$plantare$es extends Translations$intro$slides$plantare$en {
_Translations$intro$slides$plantare$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Sembrar es multiplicar';
@override String get body => 'Con el Plantare, quien recibe promete devolver semilla más adelante. Y como para devolverla hay que cultivarla, cada préstamo multiplica el común.';
}
/// The flat map containing all translations for locale <es>.
/// Only for edge cases! For simple maps, use the map function of this library.
///
@ -693,12 +782,17 @@ extension on TranslationsEs {
'backup.exportJsonSubtitle' => 'Copia completa, se puede volver a importar',
'backup.importJson' => 'Importar desde JSON',
'backup.importJsonSubtitle' => 'Fusiona una copia guardada con este inventario',
'backup.importCsv' => 'Importar desde CSV',
'backup.importCsvSubtitle' => 'Añade una lista desde una hoja de cálculo',
'backup.importConfirmTitle' => '¿Importar una copia guardada?',
'backup.importConfirmBody' => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.',
'backup.importCsvConfirmTitle' => '¿Importar una lista CSV?',
'backup.importCsvConfirmBody' => 'Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.',
'backup.importAction' => 'Importar',
'backup.exportSaved' => 'Copia guardada',
'backup.cancelled' => 'Cancelado',
'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas',
'backup.importCsvDone' => ({required Object count}) => 'Importadas ${count} desde CSV',
'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki',
'backup.failed' => 'Algo ha salido mal',
'about.title' => 'Acerca de',
@ -712,6 +806,20 @@ extension on TranslationsEs {
'about.website' => 'Sitio web',
'about.openSourceLicenses' => 'Licencias de código abierto',
'about.openSourceLicensesSubtitle' => 'Bibliotecas de terceros y sus licencias',
'intro.skip' => 'Saltar',
'intro.next' => 'Siguiente',
'intro.start' => 'Empezar',
'intro.menuEntry' => 'Cómo funciona Tanemaki',
'intro.slides.welcome.title' => 'La semilla que te trajo hasta aquí',
'intro.slides.welcome.body' => 'Cada semilla tradicional es una carta escrita por miles de generaciones, que pasó de mano en mano. Somos lo que somos gracias a ese intercambio.',
'intro.slides.inventory.title' => 'Tu banco de semillas, en el bolsillo',
'intro.slides.inventory.body' => 'Apunta qué tienes, de qué año, cuánto y de dónde vino, con el nombre que tú usas. Una foto y un nombre bastan para empezar.',
'intro.slides.privacy.title' => 'Tuyo y solo tuyo',
'intro.slides.privacy.body' => 'Sin cuenta, sin internet, sin rastreadores. Tus datos viven cifrados en tu dispositivo y solo se comparte lo que tú decidas.',
'intro.slides.share.title' => 'Compartir, como siempre se hizo',
'intro.slides.share.body' => 'Ofrece lo que te sobra —regalo, trueque o venta— y que alguien cerca lo encuentre. Solo se muestra la distancia aproximada, nunca tu dirección; el trato lo cierras en persona.',
'intro.slides.plantare.title' => 'Sembrar es multiplicar',
'intro.slides.plantare.body' => 'Con el Plantare, quien recibe promete devolver semilla más adelante. Y como para devolverla hay que cultivarla, cada préstamo multiplica el común.',
'inventory.title' => 'Inventario',
'inventory.searchHint' => 'Buscar semillas',
'inventory.empty' => 'Aún no hay semillas. Toca + para añadir la primera.',

View file

@ -56,6 +56,16 @@ class ExportImportService {
return _repository.importInventory(snapshot);
}
/// Imports a spreadsheet CSV additively (every row becomes a new variety/lot;
/// see [VarietyRepository.importCsv]). Returns the summary (`inserted` =
/// varieties added), or null when the user cancelled the file dialog.
Future<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) {
final date = _now().toIso8601String().substring(0, 10);
return 'tanemaki-inventory-$date.$extension';

View file

@ -39,6 +39,12 @@ class BackupSection extends StatelessWidget {
subtitle: Text(t.backup.importJsonSubtitle),
onTap: () => _runImport(context),
),
ListTile(
leading: const Icon(Icons.playlist_add_outlined),
title: Text(t.backup.importCsv),
subtitle: Text(t.backup.importCsvSubtitle),
onTap: () => _runImportCsv(context),
),
],
);
}
@ -96,6 +102,42 @@ class BackupSection extends StatelessWidget {
}
}
Future<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) {
messenger.showSnackBar(SnackBar(content: Text(message)));
}