tane/apps/app_seeds/lib/ui/backup_section.dart
vjrj 2812c99280 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.
2026-07-09 12:46:53 +02:00

102 lines
3.2 KiB
Dart

import 'package:flutter/material.dart';
import '../di/injector.dart';
import '../i18n/strings.g.dart';
import '../services/export_import_service.dart';
/// The three backup actions shown in Settings: export CSV, export JSON and
/// import JSON. Import asks for confirmation first (it merges into the live
/// inventory), then reports what happened in a SnackBar.
class BackupSection extends StatelessWidget {
const BackupSection({this.service, super.key});
/// Injectable for widget tests; the app resolves it lazily (on tap) from
/// the service locator so merely mounting Settings needs no DI setup.
final ExportImportService? service;
ExportImportService get _service => service ?? getIt<ExportImportService>();
@override
Widget build(BuildContext context) {
final t = context.t;
return Column(
children: [
ListTile(
leading: const Icon(Icons.table_view_outlined),
title: Text(t.backup.exportCsv),
subtitle: Text(t.backup.exportCsvSubtitle),
onTap: () => _runExport(context, () => _service.exportCsv()),
),
ListTile(
leading: const Icon(Icons.file_download_outlined),
title: Text(t.backup.exportJson),
subtitle: Text(t.backup.exportJsonSubtitle),
onTap: () => _runExport(context, () => _service.exportJson()),
),
ListTile(
leading: const Icon(Icons.file_upload_outlined),
title: Text(t.backup.importJson),
subtitle: Text(t.backup.importJsonSubtitle),
onTap: () => _runImport(context),
),
],
);
}
Future<void> _runExport(
BuildContext context,
Future<bool> Function() export,
) async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
try {
final saved = await export();
_show(messenger, saved ? t.backup.exportSaved : t.backup.cancelled);
} on Object {
_show(messenger, t.backup.failed);
}
}
Future<void> _runImport(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.importConfirmTitle),
content: Text(t.backup.importConfirmBody),
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.importJson();
_show(
messenger,
summary == null
? t.backup.cancelled
: t.backup.importDone(
added: summary.inserted,
updated: summary.updated,
),
);
} 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)));
}
}