From e90a3831f20bf2fe53a538d74f755d48fe145f86 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 11:05:36 +0200 Subject: [PATCH] feat(backup): tap automatic-backups to make a copy now --- .../lib/services/auto_backup_service.dart | 13 ++++- apps/app_seeds/lib/ui/backup_section.dart | 49 ++++++++++++++++--- .../services/auto_backup_service_test.dart | 15 ++++++ .../test/ui/backup_section_test.dart | 44 +++++++++++++++++ 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/apps/app_seeds/lib/services/auto_backup_service.dart b/apps/app_seeds/lib/services/auto_backup_service.dart index f39e50c..57f5f97 100644 --- a/apps/app_seeds/lib/services/auto_backup_service.dart +++ b/apps/app_seeds/lib/services/auto_backup_service.dart @@ -45,10 +45,19 @@ class AutoBackupService { /// a backup must never crash startup or the move to the background). Future 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 backupNow() async { + try { + final now = _now(); final dir = await _directory(); await dir.create(recursive: true); final sealed = await _exporter.buildSealedBackup(); diff --git a/apps/app_seeds/lib/ui/backup_section.dart b/apps/app_seeds/lib/ui/backup_section.dart index f54b46b..953c1cd 100644 --- a/apps/app_seeds/lib/ui/backup_section.dart +++ b/apps/app_seeds/lib/ui/backup_section.dart @@ -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 _lastBackup = _read(); + bool _busy = false; + + Future _read() async => widget.service?.lastBackupAt(); + + Future _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( - 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, ); }, ); diff --git a/apps/app_seeds/test/services/auto_backup_service_test.dart b/apps/app_seeds/test/services/auto_backup_service_test.dart index 3ff4e08..79e27f3 100644 --- a/apps/app_seeds/test/services/auto_backup_service_test.dart +++ b/apps/app_seeds/test/services/auto_backup_service_test.dart @@ -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), diff --git a/apps/app_seeds/test/ui/backup_section_test.dart b/apps/app_seeds/test/ui/backup_section_test.dart index da2047a..0df8dcf 100644 --- a/apps/app_seeds/test/ui/backup_section_test.dart +++ b/apps/app_seeds/test/ui/backup_section_test.dart @@ -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 backupNow() async { + calls++; + return true; + } }