A silent safety net against in-app data loss or DB corruption, with zero friction. Driven off the app lifecycle (startup + when hidden), it writes a sealed .tanemaki copy into the app's private storage once a week, keeping the newest 3 (rotating). Reuses the manual backup's encryption via the extracted ExportImportService.buildSealedBackup(); never throws, so a failed copy can't crash startup or backgrounding. Not registered on web (no file storage). Settings shows a quiet reassurance line with the date of the last automatic copy (localised via intl), hidden where no automatic backup exists.
348 lines
11 KiB
Dart
348 lines
11 KiB
Dart
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../data/export_import/inventory_snapshot.dart';
|
|
import '../di/injector.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../services/auto_backup_service.dart';
|
|
import '../services/export_import_service.dart';
|
|
import '../services/recovery_sheet_service.dart';
|
|
|
|
/// The backup actions shown in Settings. The primary pair — save/restore a
|
|
/// full copy — comes first, then the recovery sheet, then the spreadsheet
|
|
/// list export/import as a secondary convenience. File formats and crypto are
|
|
/// implementation details and never surface in the UI. Restore/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, this.sheet, this.autoBackup, super.key});
|
|
|
|
/// Injectable for widget tests; the app resolves them lazily (on tap) from
|
|
/// the service locator so merely mounting Settings needs no DI setup.
|
|
final ExportImportService? service;
|
|
final RecoverySheetService? sheet;
|
|
|
|
/// Drives the reassurance line about automatic copies. When null (and none is
|
|
/// registered — e.g. web) the line is simply hidden.
|
|
final AutoBackupService? autoBackup;
|
|
|
|
ExportImportService get _service => service ?? getIt<ExportImportService>();
|
|
RecoverySheetService get _sheet => sheet ?? getIt<RecoverySheetService>();
|
|
|
|
AutoBackupService? get _autoBackup =>
|
|
autoBackup ??
|
|
(getIt.isRegistered<AutoBackupService>()
|
|
? getIt<AutoBackupService>()
|
|
: null);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return Column(
|
|
children: [
|
|
_AutoBackupTile(service: _autoBackup),
|
|
ListTile(
|
|
leading: const Icon(Icons.save_alt_outlined),
|
|
title: Text(t.backup.exportJson),
|
|
subtitle: Text(t.backup.exportJsonSubtitle),
|
|
onTap: () => _runExport(context, () => _service.exportBackup()),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.settings_backup_restore_outlined),
|
|
title: Text(t.backup.importJson),
|
|
subtitle: Text(t.backup.importJsonSubtitle),
|
|
onTap: () => _runRestore(context),
|
|
),
|
|
ListTile(
|
|
key: const Key('backup.recovery'),
|
|
leading: const Icon(Icons.qr_code_2_outlined),
|
|
title: Text(t.backup.recoveryTitle),
|
|
subtitle: Text(t.backup.recoverySubtitle),
|
|
onTap: () => _showRecoverySheet(context),
|
|
),
|
|
const Divider(),
|
|
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.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);
|
|
}
|
|
}
|
|
|
|
/// Shows this device's recovery code and offers to save the printable
|
|
/// sheet (QR + code + instructions).
|
|
Future<void> _showRecoverySheet(BuildContext context) async {
|
|
final t = context.t;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final code = await _service.recoveryCode();
|
|
if (!context.mounted) return;
|
|
final save = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(t.backup.recoveryTitle),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(t.backup.recoveryIntro),
|
|
const SizedBox(height: 12),
|
|
SelectableText(
|
|
code,
|
|
key: const Key('backup.recoveryCode'),
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
key: const Key('backup.recoveryCopy'),
|
|
onPressed: () async {
|
|
await Clipboard.setData(ClipboardData(text: code));
|
|
if (dialogContext.mounted) Navigator.pop(dialogContext, false);
|
|
},
|
|
child: Text(t.backup.recoveryCopy),
|
|
),
|
|
FilledButton(
|
|
key: const Key('backup.recoverySave'),
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
child: Text(t.backup.recoverySave),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (save != true) return;
|
|
try {
|
|
final saved = await _sheet.saveSheet(
|
|
title: t.backup.recoverySheetTitle,
|
|
intro: t.backup.recoveryIntro,
|
|
code: code,
|
|
suggestedName: 'tanemaki-recovery.pdf',
|
|
);
|
|
_show(messenger, saved ? t.backup.exportSaved : t.backup.cancelled);
|
|
} on Object {
|
|
_show(messenger, t.backup.failed);
|
|
}
|
|
}
|
|
|
|
Future<void> _runRestore(BuildContext context) async {
|
|
final t = context.t;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final confirmed = await _confirm(
|
|
context,
|
|
title: t.backup.importConfirmTitle,
|
|
body: t.backup.importConfirmBody,
|
|
);
|
|
if (confirmed != true || !context.mounted) return;
|
|
|
|
final PendingBackup? pending;
|
|
try {
|
|
pending = await _service.pickBackup();
|
|
} on Object {
|
|
_show(messenger, t.backup.failed);
|
|
return;
|
|
}
|
|
if (pending == null) {
|
|
_show(messenger, t.backup.cancelled);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
_report(messenger, t, await _service.restoreBackup(pending));
|
|
return;
|
|
} on BackupAuthException {
|
|
// Sealed under another identity: ask for the printed code below.
|
|
} on FormatException {
|
|
_show(messenger, t.backup.importFailed);
|
|
return;
|
|
} on Object {
|
|
_show(messenger, t.backup.failed);
|
|
return;
|
|
}
|
|
|
|
if (!context.mounted) return;
|
|
final code = await _askRecoveryCode(context);
|
|
if (code == null) {
|
|
_show(messenger, t.backup.cancelled);
|
|
return;
|
|
}
|
|
try {
|
|
_report(
|
|
messenger,
|
|
t,
|
|
await _service.restoreBackup(pending, recoveryCode: code),
|
|
);
|
|
} on BackupAuthException {
|
|
_show(messenger, t.backup.recoveryWrongCode);
|
|
} on FormatException {
|
|
_show(messenger, t.backup.recoveryWrongCode);
|
|
} on Object {
|
|
_show(messenger, t.backup.failed);
|
|
}
|
|
}
|
|
|
|
/// Asks for the code printed on the recovery sheet.
|
|
Future<String?> _askRecoveryCode(BuildContext context) {
|
|
final t = context.t;
|
|
final controller = TextEditingController();
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(t.backup.recoveryPromptTitle),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(t.backup.recoveryPromptBody),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
key: const Key('backup.recoveryField'),
|
|
controller: controller,
|
|
autofocus: true,
|
|
textCapitalization: TextCapitalization.characters,
|
|
decoration: InputDecoration(
|
|
labelText: t.backup.recoveryTitle,
|
|
border: const OutlineInputBorder(
|
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(t.common.cancel),
|
|
),
|
|
FilledButton(
|
|
key: const Key('backup.recoveryConfirm'),
|
|
onPressed: () =>
|
|
Navigator.pop(dialogContext, controller.text.trim()),
|
|
child: Text(t.backup.importAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _runImportCsv(BuildContext context) async {
|
|
final t = context.t;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final confirmed = await _confirm(
|
|
context,
|
|
title: t.backup.importCsvConfirmTitle,
|
|
body: t.backup.importCsvConfirmBody,
|
|
);
|
|
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);
|
|
}
|
|
}
|
|
|
|
Future<bool?> _confirm(
|
|
BuildContext context, {
|
|
required String title,
|
|
required String body,
|
|
}) {
|
|
final t = context.t;
|
|
return showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(title),
|
|
content: Text(body),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _report(
|
|
ScaffoldMessengerState messenger,
|
|
Translations t,
|
|
ImportSummary summary,
|
|
) {
|
|
_show(
|
|
messenger,
|
|
t.backup.importDone(added: summary.inserted, updated: summary.updated),
|
|
);
|
|
}
|
|
|
|
void _show(ScaffoldMessengerState messenger, String message) {
|
|
messenger.showSnackBar(SnackBar(content: Text(message)));
|
|
}
|
|
}
|
|
|
|
/// A quiet reassurance line: the app already keeps copies on its own, and this
|
|
/// says when the last one was made. Hidden entirely when there is no automatic
|
|
/// backup on this platform.
|
|
class _AutoBackupTile extends StatelessWidget {
|
|
const _AutoBackupTile({required this.service});
|
|
|
|
final AutoBackupService? service;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final service = this.service;
|
|
if (service == null) return const SizedBox.shrink();
|
|
final t = context.t;
|
|
return FutureBuilder<DateTime?>(
|
|
future: service.lastBackupAt(),
|
|
builder: (context, snapshot) {
|
|
final last = snapshot.data;
|
|
final subtitle = last == null
|
|
? t.backup.autoBackupNone
|
|
: t.backup.autoBackupLast(
|
|
date: DateFormat.yMMMd(
|
|
LocaleSettings.currentLocale.languageCode,
|
|
).format(last),
|
|
);
|
|
return ListTile(
|
|
leading: const Icon(Icons.shield_outlined),
|
|
title: Text(t.backup.autoBackupTitle),
|
|
subtitle: Text(subtitle),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|