diff --git a/sync_settings_ui/.gitignore b/sync_settings_ui/.gitignore index 08e2419..fd5818f 100644 --- a/sync_settings_ui/.gitignore +++ b/sync_settings_ui/.gitignore @@ -4,3 +4,4 @@ build/ pubspec.lock coverage/ *.iml +pubspec_overrides.yaml diff --git a/sync_settings_ui/lib/src/backup_slot.dart b/sync_settings_ui/lib/src/backup_slot.dart index 90c98b4..dd39034 100644 --- a/sync_settings_ui/lib/src/backup_slot.dart +++ b/sync_settings_ui/lib/src/backup_slot.dart @@ -1,12 +1,15 @@ +import 'package:data_transfer_flutter/data_transfer_flutter.dart'; + /// An injectable local export/import delegate for [SyncSettingsScreen]'s /// optional "Backup" section. /// -/// Deliberately not a shared implementation: the actual export/import format -/// differs per app (todo: Markdown notes; home_inventory: inventory export; -/// workout_app: a permission-gated `BackupService`), so this package only -/// carries the label and the two callbacks. Passing `null` for -/// `SyncSettingsScreen.backup` omits the section entirely (diet_guard, -/// wake_alarm have no local backup at all). +/// The export/import CONTENT differs per app (todo: Markdown notes; +/// home_inventory: inventory export; workout_app: a permission-gated +/// `BackupService`), so the default constructor only carries the label and +/// the two callbacks. Apps whose backup is a JSON file use [BackupSlot.json], +/// which adds the shared file plumbing (`data_transfer_flutter`). Passing +/// `null` for `SyncSettingsScreen.backup` omits the section entirely +/// (diet_guard, wake_alarm have no local backup at all). class BackupSlot { /// Creates a [BackupSlot] labelled [label] (e.g. `'notes'`, /// `'inventory'`), backed by [export] and [import]. @@ -16,6 +19,28 @@ class BackupSlot { required this.import, }); + /// A slot whose backup is a JSON file, using `data_transfer_flutter`. + /// + /// Export saves `build()` as [fileName] to device storage (system Save-as + /// on Android/iOS, a save dialog on desktop, a download on web). Import + /// lets the user pick a JSON file and hands the decoded object to + /// [restore]; a cancelled picker restores nothing. + factory BackupSlot.json({ + required String label, + required String fileName, + required Future> Function() build, + required Future Function(Map json) restore, + }) => BackupSlot( + label: label, + export: () async { + await exportJson(fileName: fileName, json: await build()); + }, + import: () async { + final json = await pickJson(); + if (json != null) await restore(json); + }, + ); + /// Noun describing what this exports/imports, used in the section's /// button text ("Export $label" / "Import $label"). final String label; diff --git a/sync_settings_ui/pubspec.yaml b/sync_settings_ui/pubspec.yaml index e543cf1..340fd41 100644 --- a/sync_settings_ui/pubspec.yaml +++ b/sync_settings_ui/pubspec.yaml @@ -5,7 +5,7 @@ description: >- injected closures -- no keystore or platform plugin access lives in this package itself. publish_to: 'none' -version: 0.4.3 +version: 0.5.0 environment: sdk: ^3.12.2 @@ -16,6 +16,11 @@ dependencies: url: https://github.com/kuhyx/utils ref: crdt_sync_dart-v0.11.3 path: crdt_sync_dart + data_transfer_flutter: + git: + url: https://github.com/kuhyx/utils + ref: data_transfer_flutter-v0.1.0 + path: data_transfer_flutter flutter: sdk: flutter diff --git a/sync_settings_ui/test/backup_slot_test.dart b/sync_settings_ui/test/backup_slot_test.dart index 6754c6e..defdd51 100644 --- a/sync_settings_ui/test/backup_slot_test.dart +++ b/sync_settings_ui/test/backup_slot_test.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:data_transfer_flutter/data_transfer_flutter.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sync_settings_ui/sync_settings_ui.dart'; @@ -16,4 +19,83 @@ void main() { expect(exported, isTrue); expect(imported, isTrue); }); + + group('BackupSlot.json', () { + late _FakePlatform fake; + late DataTransferPlatform original; + + setUp(() { + original = DataTransfer.platform; + fake = _FakePlatform(); + DataTransfer.platform = fake; + }); + + tearDown(() => DataTransfer.platform = original); + + test('export saves the built map under fileName', () async { + final slot = BackupSlot.json( + label: 'inventory', + fileName: 'inv.json', + build: () async => {'items': 3}, + restore: (_) async {}, + ); + + await slot.export(); + + expect(fake.saved.single.$1, 'inv.json'); + expect(jsonDecode(fake.saved.single.$2), {'items': 3}); + }); + + test('import hands the picked map to restore', () async { + fake.picked = '{"items":3}'; + Map? restored; + final slot = BackupSlot.json( + label: 'inventory', + fileName: 'inv.json', + build: () async => {}, + restore: (json) async => restored = json, + ); + + await slot.import(); + + expect(restored, {'items': 3}); + }); + + test('a cancelled picker restores nothing', () async { + var called = false; + final slot = BackupSlot.json( + label: 'inventory', + fileName: 'inv.json', + build: () async => {}, + restore: (_) async => called = true, + ); + + await slot.import(); + + expect(called, isFalse); + }); + }); +} + +class _FakePlatform implements DataTransferPlatform { + String? picked; + final saved = <(String, String)>[]; + + @override + Future saveText({ + required String fileName, + required String text, + }) async { + saved.add((fileName, text)); + return true; + } + + @override + Future shareText({ + required String fileName, + required String text, + }) async => true; + + @override + Future pickText() async => picked; }