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).
144 lines
4.5 KiB
Dart
144 lines
4.5 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),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.playlist_add_outlined),
|
|
title: Text(t.backup.importCsv),
|
|
subtitle: Text(t.backup.importCsvSubtitle),
|
|
onTap: () => _runImportCsv(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);
|
|
}
|
|
}
|
|
|
|
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)));
|
|
}
|
|
}
|