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

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