tane/apps/app_seeds/lib/ui/backup_section.dart
vjrj d6781870d9 feat(backup): sealed backups + printable recovery sheet
'Save a backup' now writes a sealed .tanemaki file (AES-256-GCM under a
key HKDF-derived from the root seed; format TANEBK v1, open and stable).
Restoring on the same identity is silent; a copy sealed by another
identity asks for the printed recovery code (TANE1 base32, typo-tolerant)
and adopts that seed — recovering your bank recovers you. Legacy plain
exports still restore. Settings gains a recovery sheet (QR + code PDF).
BackupBox/RecoveryCode live in commons_core, pure Dart, TDD.
2026-07-09 22:32:13 +02:00

301 lines
9.4 KiB
Dart

import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../data/export_import/inventory_snapshot.dart';
import '../di/injector.dart';
import '../i18n/strings.g.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, 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;
ExportImportService get _service => service ?? getIt<ExportImportService>();
RecoverySheetService get _sheet => sheet ?? getIt<RecoverySheetService>();
@override
Widget build(BuildContext context) {
final t = context.t;
return Column(
children: [
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)));
}
}