mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 13:07:32 +01:00
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:
parent
69be24d4c0
commit
05738db15a
@ -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.
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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
|
||||
|
||||
72
phone_app/lib/services/data_export.dart
Normal file
72
phone_app/lib/services/data_export.dart
Normal 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());
|
||||
}
|
||||
38
phone_app/lib/services/data_transfer.dart
Normal file
38
phone_app/lib/services/data_transfer.dart
Normal 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;
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
121
phone_app/test/services/data_export_test.dart
Normal file
121
phone_app/test/services/data_export_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user