diff --git a/phone_app/lib/screens/home_screen.dart b/phone_app/lib/screens/home_screen.dart index 79e200e..6c0086a 100644 --- a/phone_app/lib/screens/home_screen.dart +++ b/phone_app/lib/screens/home_screen.dart @@ -204,7 +204,9 @@ class _HomeScreenState extends State { onPressed: () async { await Navigator.push( context, - MaterialPageRoute(builder: (_) => const SettingsScreen()), + MaterialPageRoute( + builder: (_) => SettingsScreen(alarmService: _service), + ), ); // Publish too, not just reload: a tag registered in Settings // reaches the backend now, not on the next cold start. diff --git a/phone_app/lib/screens/settings_screen.dart b/phone_app/lib/screens/settings_screen.dart index 2ff8fd5..05c8ee3 100644 --- a/phone_app/lib/screens/settings_screen.dart +++ b/phone_app/lib/screens/settings_screen.dart @@ -1,3 +1,5 @@ +import 'dart:developer' as developer; + import 'package:flutter/material.dart'; import 'package:sync_settings_ui/sync_settings_ui.dart'; @@ -5,6 +7,8 @@ import 'package:wake_alarm_sync/screens/github_mirror_screen.dart'; import 'package:wake_alarm_sync/screens/home_location_screen.dart'; import 'package:wake_alarm_sync/screens/room_proof_screen.dart'; import 'package:wake_alarm_sync/screens/session_tags_screen.dart'; +import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; +import 'package:wake_alarm_sync/services/data_transfer.dart'; import 'package:wake_alarm_sync/services/firebase_backend.dart'; import 'package:wake_alarm_sync/services/google_sign_in_backend.dart'; @@ -19,12 +23,77 @@ import 'package:wake_alarm_sync/services/google_sign_in_backend.dart'; /// the alarm can skip them when it rings somewhere else. class SettingsScreen extends StatelessWidget { /// Creates a [SettingsScreen]. - const SettingsScreen({super.key}); + const SettingsScreen({ + super.key, + this.alarmService, + this.onExport, + this.onImport, + }); + + /// The home screen's live alarm store, reused by export/import. + final AlarmSyncService? alarmService; + + /// Export action; defaults to [saveExport]. + final Future Function()? onExport; + + /// Import action, false on cancel; defaults to [pickAndImport]. + final Future Function()? onImport; + + Future _run( + BuildContext context, + Future Function() action, + ) async { + final messenger = ScaffoldMessenger.of(context); + final colors = Theme.of(context).colorScheme; + var ok = false; + try { + if (!await action()) return; + ok = true; + } on Object catch (e, stack) { + developer.log( + 'data transfer failed: $e', + name: 'WakeAlarmSync', + error: e, + stackTrace: stack, + ); + } + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Icon( + ok ? Icons.check_circle : Icons.error_outline, + color: ok ? colors.primary : colors.error, + semanticLabel: ok ? 'Done' : 'Failed', + ), + ), + ); + } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Settings')), + appBar: AppBar( + title: const Text('Settings'), + actions: [ + IconButton( + key: const Key('settings-export'), + icon: const Icon(Icons.upload_file, semanticLabel: 'Export data'), + onPressed: () => _run(context, () async { + await (onExport ?? () => saveExport(alarm: alarmService))(); + return true; + }), + ), + IconButton( + key: const Key('settings-import'), + icon: const Icon(Icons.download, semanticLabel: 'Import data'), + onPressed: () => _run( + context, + onImport ?? () => pickAndImport(alarm: alarmService), + ), + ), + ], + ), body: ListView( padding: const EdgeInsets.all(16), children: [ diff --git a/phone_app/lib/services/alarm_sync_service.dart b/phone_app/lib/services/alarm_sync_service.dart index cea5d94..15f835a 100644 --- a/phone_app/lib/services/alarm_sync_service.dart +++ b/phone_app/lib/services/alarm_sync_service.dart @@ -117,6 +117,12 @@ class AlarmSyncService { return null; } + /// The whole log, for the user-facing export. + Log snapshot() => _store.snapshot(); + + /// Replaces the log with a merged result and persists it (import). + Future replaceAll(Log merged) => _store.replaceAll(merged); + /// Stores [hour]:[minute] locally, stamped with a fresh monotonic HLC. /// /// Both fields share one clock tick — they are always set together, so a diff --git a/phone_app/lib/services/data_export.dart b/phone_app/lib/services/data_export.dart new file mode 100644 index 0000000..c91a87d --- /dev/null +++ b/phone_app/lib/services/data_export.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:data_transfer_flutter/data_transfer_flutter.dart'; +import 'package:wake_alarm_sync/services/ramp_progress.dart'; + +/// One crdt log the export covers. +typedef LogSlot = ({ + Log Function() snapshot, + Future Function(Log merged) replaceAll, +}); + +/// Marks the file as this app's export. +const exportFormat = 'wake_alarm_export'; + +/// Shape version. +const exportVersion = 1; + +/// Every slot's log plus the ramp dates as JSON; no device id or secrets. +Future buildExport(Map slots, {DateTime? now}) async { + final dates = (await RampProgress.load()).toList()..sort(); + return const JsonEncoder.withIndent(' ').convert( + buildEnvelope( + format: exportFormat, + version: exportVersion, + now: now ?? DateTime.now(), + body: { + 'logs': { + for (final entry in slots.entries) + entry.key: jsonDecode(logToJson(entry.value.snapshot())), + }, + 'ramp_earned_dates': dates, + }, + ), + ); +} + +/// Merges via [mergeLogs] only, after validating all; throws [FormatException]. +Future importExport(String text, Map slots) async { + final parsed = _parse(text); + for (final entry in parsed.logs.entries) { + final slot = slots[entry.key]; + if (slot == null) continue; + await slot.replaceAll(mergeLogs(slot.snapshot(), entry.value)); + } + await RampProgress.merge(parsed.rampDates); +} + +typedef _Parsed = ({Map logs, List rampDates}); + +_Parsed _parse(String text) { + final decoded = parseEnvelope( + text, + format: exportFormat, + version: exportVersion, + ); + final rawLogs = decoded['logs']; + if (rawLogs is! Map) { + throw const FormatException('export has no "logs" object'); + } + final logs = {}; + for (final entry in rawLogs.entries) { + try { + logs[entry.key] = logFromJson(jsonEncode(entry.value)); + } on Object catch (error) { + throw FormatException('log "${entry.key}" is malformed ($error)'); + } + } + final rawDates = decoded['ramp_earned_dates']; + final dates = rawDates is List ? rawDates.whereType() : []; + return (logs: logs, rampDates: dates.toList()); +} diff --git a/phone_app/lib/services/data_transfer.dart b/phone_app/lib/services/data_transfer.dart new file mode 100644 index 0000000..d1f1d19 --- /dev/null +++ b/phone_app/lib/services/data_transfer.dart @@ -0,0 +1,38 @@ +import 'package:data_transfer_flutter/data_transfer_flutter.dart'; +import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; +import 'package:wake_alarm_sync/services/data_export.dart'; +import 'package:wake_alarm_sync/services/free_day_pool.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; + +/// The three logs; pass the live [alarm] so no stale twin overwrites it. +Future> openLogSlots({AlarmSyncService? alarm}) async { + final alarmStore = alarm ?? await AlarmSyncService.open(); + final free = await FreeDayPool.open(); + final sick = await SickDayPool.open(); + return { + 'alarm': (snapshot: alarmStore.snapshot, replaceAll: alarmStore.replaceAll), + 'free_days': (snapshot: free.snapshot, replaceAll: free.replaceAll), + 'sick_days': (snapshot: sick.snapshot, replaceAll: sick.replaceAll), + }; +} + +/// Saves the export to device storage via the system Save-as picker. +/// +/// False when the picker was cancelled. +Future saveExport({AlarmSyncService? alarm}) async { + final now = DateTime.now(); + final json = await buildExport(await openLogSlots(alarm: alarm), now: now); + final day = now.toIso8601String().substring(0, 10); + return await exportJsonText( + fileName: 'wake-alarm-export-$day.json', + text: json, + ); +} + +/// Picks an export and merges it; false when the picker was cancelled. +Future pickAndImport({AlarmSyncService? alarm}) async { + final text = await pickJsonText(); + if (text == null) return false; + await importExport(text, await openLogSlots(alarm: alarm)); + return true; +} diff --git a/phone_app/pubspec.lock b/phone_app/pubspec.lock index ce2fbbf..64a6c58 100644 --- a/phone_app/pubspec.lock +++ b/phone_app/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8" + url: "https://pub.dev" + source: hosted + version: "2.1.2" android_intent_plus: dependency: "direct main" description: @@ -98,6 +106,14 @@ packages: url: "https://github.com/kuhyx/utils" source: git version: "0.11.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -114,6 +130,23 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + data_transfer_flutter: + dependency: "direct main" + description: + path: data_transfer_flutter + ref: "data_transfer_flutter-v0.2.0" + resolved-ref: bbb7f0f0c0cd891518418472955c8a2ac6cbca4d + url: "https://github.com/kuhyx/utils" + source: git + version: "0.2.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612 + url: "https://pub.dev" + source: hosted + version: "0.8.0" fake_async: dependency: transitive description: @@ -146,6 +179,110 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: transitive + description: + name: file_picker + sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592" + url: "https://pub.dev" + source: hosted + version: "13.3.1" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da + url: "https://pub.dev" + source: hosted + version: "2.0.2" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "336e7168306834a2b68e6168ed768e00d3fabeac23154d5793e277ca9f97cf20" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c + url: "https://pub.dev" + source: hosted + version: "4.2.0" + file_selector: + dependency: transitive + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c + url: "https://pub.dev" + source: hosted + version: "0.5.2+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "5b42ec0ef7626124b4624ea244341e9df780406793c204e89fa68a2e71af115c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba + url: "https://pub.dev" + source: hosted + version: "0.9.5+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -394,6 +531,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" objective_c: dependency: transitive description: @@ -514,6 +659,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + share_plus: + dependency: transitive + description: + name: share_plus + sha256: db40767f130189d53f3ae8a6393c217ff4a492da1baff718a9873679c0c00224 + url: "https://pub.dev" + source: hosted + version: "13.3.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" shared_preferences: dependency: "direct main" description: @@ -752,6 +913,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.4.0" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072" + url: "https://pub.dev" + source: hosted + version: "2.0.1" xdg_directories: dependency: transitive description: @@ -778,4 +947,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.13.0 <4.0.0" - flutter: ">=3.44.0" + flutter: ">=3.47.0" diff --git a/phone_app/pubspec.yaml b/phone_app/pubspec.yaml index 913340e..b0e37f9 100644 --- a/phone_app/pubspec.yaml +++ b/phone_app/pubspec.yaml @@ -15,6 +15,13 @@ dependencies: ref: crdt_sync_dart-v0.11.1 path: crdt_sync_dart cupertino_icons: 2.0.0 + + # Shared JSON export/import plumbing (Save-as, picker, envelope). + data_transfer_flutter: + git: + url: https://github.com/kuhyx/utils + ref: data_transfer_flutter-v0.2.0 + path: data_transfer_flutter flutter: sdk: flutter flutter_secure_storage: 11.2.0 diff --git a/phone_app/test/services/data_export_test.dart b/phone_app/test/services/data_export_test.dart new file mode 100644 index 0000000..28e60db --- /dev/null +++ b/phone_app/test/services/data_export_test.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; +import 'package:wake_alarm_sync/services/data_export.dart'; +import 'package:wake_alarm_sync/services/free_day_pool.dart'; +import 'package:wake_alarm_sync/services/ramp_progress.dart'; +import 'package:wake_alarm_sync/services/sick_day_pool.dart'; + +import '../fake_day_pool_store.dart'; + +final _now = DateTime(2026, 11, 2); + +/// One install's three stores, on in-memory persistence. +class _Device { + _Device._(this.alarm, this.free, this.sick); + + final AlarmSyncService alarm; + final FreeDayPool free; + final SickDayPool sick; + + static Future<_Device> open(String id) async => _Device._( + await AlarmSyncService.open(persistence: MemPersistence(), deviceId: id), + await FreeDayPool.open(persistence: MemPersistence(), deviceId: id), + await SickDayPool.open(persistence: MemPersistence(), deviceId: id), + ); + + Map get slots => { + 'alarm': (snapshot: alarm.snapshot, replaceAll: alarm.replaceAll), + 'free_days': (snapshot: free.snapshot, replaceAll: free.replaceAll), + 'sick_days': (snapshot: sick.snapshot, replaceAll: sick.replaceAll), + }; + + /// Every log as plain JSON, so equality is structural. + Map get state => { + for (final e in slots.entries) + e.key: jsonDecode(logToJson(e.value.snapshot())), + }; +} + +Future<_Device> _populated() async { + final device = await _Device.open('phone-a'); + await device.alarm.setLocal(7, 42); + await device.free.mark(DateTime(2026, 11, 3), reason: 'trip', now: _now); + await device.sick.add( + DateTime(2026, 11, 5), + isFree: device.free.isFree, + now: _now, + ); + return device; +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test( + 'export -> import into an empty store reproduces every record', + () async { + await RampProgress.merge(['2026-10-25', '2026-10-26']); + final source = await _populated(); + final file = await buildExport(source.slots, now: _now); + + final decoded = jsonDecode(file) as Map; + expect(decoded['format'], 'wake_alarm_export'); + expect(decoded['version'], 1); + + SharedPreferences.setMockInitialValues({}); + final target = await _Device.open('phone-b'); + await importExport(file, target.slots); + + expect(target.state, equals(source.state)); + expect(await RampProgress.load(), {'2026-10-25', '2026-10-26'}); + }, + ); + + test('importing the same file twice equals importing it once', () async { + final file = await buildExport((await _populated()).slots, now: _now); + final target = await _Device.open('phone-b'); + + await importExport(file, target.slots); + final once = target.state; + await importExport(file, target.slots); + + expect(target.state, equals(once)); + }); + + test('import into a non-empty store keeps the existing records', () async { + final file = await buildExport((await _populated()).slots, now: _now); + await RampProgress.merge(['2026-10-18']); + final target = await _Device.open('phone-b'); + await target.free.mark(DateTime(2026, 11, 9), now: _now); + final before = target.state; + + await importExport(file, target.slots); + + expect(target.free.isFree(DateTime(2026, 11, 9)), isTrue); + expect(target.free.isFree(DateTime(2026, 11, 3)), isTrue); + expect(target.sick.isSick(DateTime(2026, 11, 5)), isTrue); + expect(target.alarm.currentAlarm, (hour: 7, minute: 42)); + final freeBefore = (before['free_days']! as Map)['2026-11-09']; + expect((target.state['free_days']! as Map)['2026-11-09'], freeBefore); + expect(await RampProgress.load(), contains('2026-10-18')); + }); + + test('a file that is not a wake_alarm export changes nothing', () async { + final target = await _populated(); + final before = target.state; + + for (final bad in [ + 'not json', + '[]', + '{"days": []}', + '{"format": "wake_alarm_export", "version": 1, "logs": {"alarm": 3}}', + ]) { + await expectLater(importExport(bad, target.slots), throwsFormatException); + } + expect(target.state, equals(before)); + }); +} diff --git a/phone_app/test/settings_screen_test.dart b/phone_app/test/settings_screen_test.dart index dfdc7d3..ebdd02b 100644 --- a/phone_app/test/settings_screen_test.dart +++ b/phone_app/test/settings_screen_test.dart @@ -21,4 +21,82 @@ void main() { expect(find.byType(GitHubMirrorScreen), findsOneWidget); }); + + testWidgets('export and import are icon-only, labelled, and report back', ( + tester, + ) async { + var exported = 0; + var imported = 0; + await tester.pumpWidget( + MaterialApp( + home: SettingsScreen( + onExport: () async => exported++, + onImport: () async { + imported++; + throw const FormatException('bad file'); + }, + ), + ), + ); + + await tester.tap(find.bySemanticsLabel('Export data')); + await tester.pump(); + expect(exported, 1); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('Import data')); + await tester.pumpAndSettle(const Duration(seconds: 5)); + expect(imported, 1); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + }); + + testWidgets('a successful import shows the check icon', (tester) async { + var imported = 0; + await tester.pumpWidget( + MaterialApp( + home: SettingsScreen( + onImport: () async { + imported++; + return true; + }, + ), + ), + ); + + await tester.tap(find.bySemanticsLabel('Import data')); + await tester.pump(); + expect(imported, 1); + expect(find.byIcon(Icons.check_circle), findsOneWidget); + }); + + testWidgets('a cancelled import shows no feedback', (tester) async { + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(onImport: () async => false)), + ); + + await tester.tap(find.bySemanticsLabel('Import data')); + await tester.pump(); + expect(find.byType(SnackBar), findsNothing); + }); + + testWidgets('import feedback replaces a still-visible export one', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: SettingsScreen( + onExport: () async {}, + onImport: () async => throw const FormatException('bad file'), + ), + ), + ); + + await tester.tap(find.bySemanticsLabel('Export data')); + await tester.pump(); + await tester.tap(find.bySemanticsLabel('Import data')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(find.byIcon(Icons.check_circle), findsNothing); + }); }