feat(backup): add automatic weekly encrypted backups

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.
This commit is contained in:
vjrj 2026-07-10 10:17:36 +02:00
parent cf6975b748
commit 9c449964fd
19 changed files with 559 additions and 29 deletions

View file

@ -1,10 +1,12 @@
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';
@ -15,21 +17,32 @@ import '../services/recovery_sheet_service.dart';
/// 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});
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),
@ -299,3 +312,37 @@ class BackupSection extends StatelessWidget {
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),
);
},
);
}
}