feat(backup): tap automatic-backups to make a copy now

This commit is contained in:
vjrj 2026-07-10 11:05:36 +02:00
parent 7cba4f7fcf
commit e90a3831f2
4 changed files with 112 additions and 9 deletions

View file

@ -45,10 +45,19 @@ class AutoBackupService {
/// a backup must never crash startup or the move to the background).
Future<bool> runIfDue({Duration interval = const Duration(days: 7)}) async {
try {
final now = _now();
final last = await _store.lastBackupAt();
if (last != null && now.difference(last) < interval) return false;
if (last != null && _now().difference(last) < interval) return false;
} on Object {
return false;
}
return backupNow();
}
/// Makes a copy right now, ignoring the schedule for an explicit "back up
/// now" tap. Returns true on success, false on any failure (never throws).
Future<bool> backupNow() async {
try {
final now = _now();
final dir = await _directory();
await dir.create(recursive: true);
final sealed = await _exporter.buildSealedBackup();

View file

@ -313,21 +313,48 @@ class BackupSection extends StatelessWidget {
}
}
/// 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 {
/// The automatic-copies line: the app keeps sealed copies on its own, and this
/// says when the last one was made. Tapping it makes one right now (handy after
/// a big change, and so the entry visibly *does* something). Hidden entirely
/// when there is no automatic backup on this platform.
class _AutoBackupTile extends StatefulWidget {
const _AutoBackupTile({required this.service});
final AutoBackupService? service;
@override
State<_AutoBackupTile> createState() => _AutoBackupTileState();
}
class _AutoBackupTileState extends State<_AutoBackupTile> {
late Future<DateTime?> _lastBackup = _read();
bool _busy = false;
Future<DateTime?> _read() async => widget.service?.lastBackupAt();
Future<void> _backupNow() async {
final service = widget.service;
if (service == null || _busy) return;
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
setState(() => _busy = true);
final ok = await service.backupNow();
if (!mounted) return;
setState(() {
_busy = false;
_lastBackup = _read();
});
messenger.showSnackBar(
SnackBar(content: Text(ok ? t.backup.exportSaved : t.backup.failed)),
);
}
@override
Widget build(BuildContext context) {
final service = this.service;
if (service == null) return const SizedBox.shrink();
if (widget.service == null) return const SizedBox.shrink();
final t = context.t;
return FutureBuilder<DateTime?>(
future: service.lastBackupAt(),
future: _lastBackup,
builder: (context, snapshot) {
final last = snapshot.data;
final subtitle = last == null
@ -341,6 +368,14 @@ class _AutoBackupTile extends StatelessWidget {
leading: const Icon(Icons.shield_outlined),
title: Text(t.backup.autoBackupTitle),
subtitle: Text(subtitle),
trailing: _busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.backup_outlined),
onTap: _busy ? null : _backupNow,
);
},
);

View file

@ -139,6 +139,21 @@ void main() {
]);
});
test('backupNow writes even when a copy is not yet due', () async {
final store = AutoBackupStore(InMemorySecretStore());
await store.markBackupAt(DateTime.utc(2026, 7, 10)); // today: not due
final service = newService(
newExporter(),
store: store,
now: DateTime.utc(2026, 7, 10),
);
// runIfDue is a no-op today, but an explicit tap still makes a copy.
expect(await service.runIfDue(), isFalse);
expect(await service.backupNow(), isTrue);
expect(autoCopies(), ['tanemaki-auto-2026-07-10.tanemaki']);
});
test('a failed backup returns false and never throws', () async {
final service = newService(
_ThrowingExporter(db),

View file

@ -9,6 +9,8 @@ import 'package:tane/data/export_import/inventory_json_codec.dart';
import 'package:tane/db/database.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/security/secure_key_store.dart';
import 'package:tane/services/auto_backup_service.dart';
import 'package:tane/services/auto_backup_store.dart';
import 'package:tane/services/export_import_service.dart';
import 'package:tane/services/file_service.dart';
import 'package:tane/services/recovery_sheet_service.dart';
@ -232,4 +234,46 @@ void main() {
// of the sheet. Just assert it saved and confirmed.
expect(find.text('Copy saved'), findsOneWidget);
});
testWidgets('tapping the automatic-backups line makes a copy now', (
tester,
) async {
final spy = _SpyAutoBackup(db);
await tester.pumpWidget(
wrap(
Scaffold(
body: BackupSection(service: service, sheet: sheet, autoBackup: spy),
),
),
);
await tester.pump(); // resolve the "last copy" future
await tester.tap(find.text('Automatic backups'));
await tester.pumpAndSettle();
expect(spy.calls, 1);
expect(find.text('Copy saved'), findsOneWidget);
});
}
/// Counts explicit "back up now" taps without touching the disk.
class _SpyAutoBackup extends AutoBackupService {
_SpyAutoBackup(AppDatabase db)
: super(
exporter: ExportImportService(
repository: newTestRepository(db),
files: FakeFileService(),
keys: SecureKeyStore(store: InMemorySecretStore()),
),
store: AutoBackupStore(InMemorySecretStore()),
directory: () async => throw UnimplementedError(),
);
int calls = 0;
@override
Future<bool> backupNow() async {
calls++;
return true;
}
}