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

@ -45,6 +45,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$photo$es photo = _Translations$photo$es._(_root);
@override late final _Translations$menu$es menu = _Translations$menu$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$about$es about = _Translations$about$es._(_root);
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$es._(_root);
@ -144,6 +145,30 @@ class _Translations$settings$es extends Translations$settings$en {
@override String get aboutOpen => 'Acerca de Tanemaki';
}
// Path: backup
class _Translations$backup$es extends Translations$backup$en {
_Translations$backup$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Copia de seguridad';
@override String get exportCsv => 'Exportar a CSV';
@override String get exportCsvSubtitle => 'Para hojas de cálculo — sin fotos';
@override String get exportJson => 'Exportar a JSON';
@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 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 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 get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki';
@override String get failed => 'Algo ha salido mal';
}
// Path: about
class _Translations$about$es extends Translations$about$en {
_Translations$about$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -661,6 +686,21 @@ extension on TranslationsEs {
'settings.about' => 'Acerca de',
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
'settings.aboutOpen' => 'Acerca de Tanemaki',
'backup.title' => 'Copia de seguridad',
'backup.exportCsv' => 'Exportar a CSV',
'backup.exportCsvSubtitle' => 'Para hojas de cálculo — sin fotos',
'backup.exportJson' => 'Exportar a JSON',
'backup.exportJsonSubtitle' => 'Copia completa, se puede volver a importar',
'backup.importJson' => 'Importar desde JSON',
'backup.importJsonSubtitle' => 'Fusiona una copia guardada con este inventario',
'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.importAction' => 'Importar',
'backup.exportSaved' => 'Copia guardada',
'backup.cancelled' => 'Cancelado',
'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas',
'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki',
'backup.failed' => 'Algo ha salido mal',
'about.title' => 'Acerca de',
'about.kanji' => '種まき',
'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.',