feat: JSON export/import on device

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 19:55:04 +01:00
parent 69be24d4c0
commit 05738db15a
9 changed files with 566 additions and 4 deletions

View File

@ -204,7 +204,9 @@ class _HomeScreenState extends State<HomeScreen> {
onPressed: () async {
await Navigator.push<void>(
context,
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
MaterialPageRoute<void>(
builder: (_) => SettingsScreen(alarmService: _service),
),
);
// Publish too, not just reload: a tag registered in Settings
// reaches the backend now, not on the next cold start.

View File

@ -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<void> Function()? onExport;
/// Import action, false on cancel; defaults to [pickAndImport].
final Future<bool> Function()? onImport;
Future<void> _run(
BuildContext context,
Future<bool> 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: [

View File

@ -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<void> 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

View File

@ -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<void> 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<String> buildExport(Map<String, LogSlot> 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: <String, Object?>{
'logs': <String, Object?>{
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<void> importExport(String text, Map<String, LogSlot> 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<String, Log> logs, List<String> rampDates});
_Parsed _parse(String text) {
final decoded = parseEnvelope(
text,
format: exportFormat,
version: exportVersion,
);
final rawLogs = decoded['logs'];
if (rawLogs is! Map<String, dynamic>) {
throw const FormatException('export has no "logs" object');
}
final logs = <String, Log>{};
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<String>() : <String>[];
return (logs: logs, rampDates: dates.toList());
}

View File

@ -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<Map<String, LogSlot>> 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<bool> 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<bool> pickAndImport({AlarmSyncService? alarm}) async {
final text = await pickJsonText();
if (text == null) return false;
await importExport(text, await openLogSlots(alarm: alarm));
return true;
}

View File

@ -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"

View File

@ -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

View File

@ -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<String, LogSlot> 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<String, Object?> 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(<String, Object>{}));
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<String, dynamic>;
expect(decoded['format'], 'wake_alarm_export');
expect(decoded['version'], 1);
SharedPreferences.setMockInitialValues(<String, Object>{});
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));
});
}

View File

@ -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);
});
}