feat(inventory): CSV/JSON export and JSON import with LWW reconciliation

Interchange export/import for Phase 1 (data-model §7):

- JSON: canonical, versioned envelope (formatVersion 1) with all sync
  metadata verbatim, photos embedded as base64, tombstones excluded.
  Species are re-resolved on import by scientific name (catalog ids are
  per-install). Reader tolerates unknown fields/enum values (§5.2) and
  rejects newer format versions with a clear error.
- CSV: export-only spreadsheet flatten, one row per lot, RFC 4180
  escaping, never any photo bytes.
- Import merges by UUIDv7 id in one transaction: insert-if-unknown
  preserving original stamps, last-writer-wins by packed HLC for known
  mutable rows, append-only movements; afterwards the local clock
  receiveEvent()s the newest imported stamp so it never runs behind.
- Settings gains a Backup section (export CSV/JSON, import JSON with
  confirmation); file dialogs behind a FileService interface backed by
  file_picker (MIT).
- Tests: codec round-trip and tolerance, reconciler LWW, repository
  export→import round-trip (fresh DB, idempotent re-import, newer-local
  wins, clock monotonicity, species re-resolution), backup widget flows.
This commit is contained in:
vjrj 2026-07-09 12:46:53 +02:00
parent 136ed701a7
commit 2812c99280
25 changed files with 2207 additions and 7 deletions

View file

@ -46,6 +46,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$photo$en photo = Translations$photo$en.internal(_root);
late final Translations$menu$en menu = Translations$menu$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$about$en about = Translations$about$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);
@ -209,6 +210,60 @@ class Translations$settings$en {
String get aboutOpen => 'About Tanemaki';
}
// Path: backup
class Translations$backup$en {
Translations$backup$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Backup'
String get title => 'Backup';
/// en: 'Export as CSV'
String get exportCsv => 'Export as CSV';
/// en: 'For spreadsheets — no photos'
String get exportCsvSubtitle => 'For spreadsheets — no photos';
/// en: 'Export as JSON'
String get exportJson => 'Export as JSON';
/// en: 'Complete copy, can be imported back'
String get exportJsonSubtitle => 'Complete copy, can be imported back';
/// en: 'Import from JSON'
String get importJson => 'Import from JSON';
/// en: 'Merge a saved copy into this inventory'
String get importJsonSubtitle => 'Merge a saved copy into this inventory';
/// 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'
String get importAction => 'Import';
/// en: 'Copy saved'
String get exportSaved => 'Copy saved';
/// en: 'Cancelled'
String get cancelled => 'Cancelled';
/// en: 'Imported: {added} new, {updated} updated'
String importDone({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated';
/// en: 'This file could not be read as a Tanemaki copy'
String get importFailed => 'This file could not be read as a Tanemaki copy';
/// en: 'Something went wrong'
String get failed => 'Something went wrong';
}
// Path: about
class Translations$about$en {
Translations$about$en.internal(this._root);
@ -976,6 +1031,21 @@ extension on Translations {
'settings.about' => 'About',
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
'settings.aboutOpen' => 'About Tanemaki',
'backup.title' => 'Backup',
'backup.exportCsv' => 'Export as CSV',
'backup.exportCsvSubtitle' => 'For spreadsheets — no photos',
'backup.exportJson' => 'Export as JSON',
'backup.exportJsonSubtitle' => 'Complete copy, can be imported back',
'backup.importJson' => 'Import from JSON',
'backup.importJsonSubtitle' => 'Merge a saved copy into this inventory',
'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.importAction' => 'Import',
'backup.exportSaved' => 'Copy saved',
'backup.cancelled' => 'Cancelled',
'backup.importDone' => ({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated',
'backup.importFailed' => 'This file could not be read as a Tanemaki copy',
'backup.failed' => 'Something went wrong',
'about.title' => 'About',
'about.kanji' => '種まき',
'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.',