fix: preserve queued settings and recover interrupted or corrupt state
This commit is contained in:
@@ -85,7 +85,10 @@ class AppController extends ChangeNotifier {
|
||||
Future<void> leaveDemo() => _run(() async {
|
||||
isDemo = false;
|
||||
});
|
||||
Future<void> updateSettings(AppSettings value) => _run(() async {
|
||||
Future<void> updateSettings(
|
||||
AppSettings Function(AppSettings current) change,
|
||||
) => _run(() async {
|
||||
final value = change(snapshot.settings);
|
||||
if (![5, 10, 15, 30, 60].contains(value.reminderMinutes)) {
|
||||
throw ArgumentError('提醒时间无效');
|
||||
}
|
||||
@@ -170,43 +173,54 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
calendarSyncError = null;
|
||||
});
|
||||
Future<void> enableCalendarFromUserAction(
|
||||
String calendarId,
|
||||
) => _run(() async {
|
||||
if (isDemo) throw StateError('示例课表不会导入日历');
|
||||
if (snapshot.settings.semester == null) throw StateError('请先设置学期');
|
||||
if (!await calendar.requestAccess()) {
|
||||
calendarSyncError = '日历权限未获允许';
|
||||
return;
|
||||
}
|
||||
if (snapshot.calendarCleanupPending) {
|
||||
await _cleanupCalendar();
|
||||
}
|
||||
final calendars = await calendar.listWritableCalendars();
|
||||
if (!calendars.any((c) => c.id == calendarId)) {
|
||||
calendarSyncError = '请选择可写入的日历';
|
||||
return;
|
||||
}
|
||||
final settings = snapshot.settings.copyWith(
|
||||
calendarEnabled: true,
|
||||
calendarId: calendarId,
|
||||
);
|
||||
// Keep the chosen calendar for recovery if the first native sync fails midway.
|
||||
await _save(
|
||||
snapshot.copyWith(settings: settings.copyWith(calendarEnabled: false)),
|
||||
);
|
||||
try {
|
||||
final ids = await calendar.sync(
|
||||
ScheduleEngine.occurrences(snapshot.courses, settings.semester!),
|
||||
calendarId,
|
||||
snapshot.calendarEventIds,
|
||||
);
|
||||
await _save(snapshot.copyWith(settings: settings, calendarEventIds: ids));
|
||||
calendarSyncError = null;
|
||||
} catch (_) {
|
||||
calendarSyncError = '日历同步失败,可重试';
|
||||
}
|
||||
});
|
||||
Future<void> enableCalendarFromUserAction(String calendarId) =>
|
||||
_run(() async {
|
||||
if (isDemo) throw StateError('示例课表不会导入日历');
|
||||
if (snapshot.settings.semester == null) throw StateError('请先设置学期');
|
||||
if (!await calendar.requestAccess()) {
|
||||
calendarSyncError = '日历权限未获允许';
|
||||
return;
|
||||
}
|
||||
if (snapshot.calendarCleanupPending) {
|
||||
await _cleanupCalendar();
|
||||
}
|
||||
final calendars = await calendar.listWritableCalendars();
|
||||
if (!calendars.any((c) => c.id == calendarId)) {
|
||||
calendarSyncError = '请选择可写入的日历';
|
||||
return;
|
||||
}
|
||||
final settings = snapshot.settings.copyWith(
|
||||
calendarEnabled: true,
|
||||
calendarId: calendarId,
|
||||
);
|
||||
// Keep the chosen calendar for recovery if the first native sync fails midway.
|
||||
await _save(
|
||||
snapshot.copyWith(
|
||||
settings: settings.copyWith(calendarEnabled: false),
|
||||
calendarCleanupPending: true,
|
||||
calendarEventIds: snapshot.settings.calendarId == calendarId
|
||||
? snapshot.calendarEventIds
|
||||
: {},
|
||||
),
|
||||
);
|
||||
try {
|
||||
final ids = await calendar.sync(
|
||||
ScheduleEngine.occurrences(snapshot.courses, settings.semester!),
|
||||
calendarId,
|
||||
snapshot.calendarEventIds,
|
||||
);
|
||||
await _save(
|
||||
snapshot.copyWith(
|
||||
settings: settings,
|
||||
calendarEventIds: ids,
|
||||
calendarCleanupPending: false,
|
||||
),
|
||||
);
|
||||
calendarSyncError = null;
|
||||
} catch (_) {
|
||||
calendarSyncError = '日历同步失败,可重试';
|
||||
}
|
||||
});
|
||||
Future<void> enableRemindersFromUserAction() => _run(() async {
|
||||
if (isDemo) throw StateError('示例课表不会生成提醒');
|
||||
await notifications.requestPermissions();
|
||||
@@ -218,6 +232,16 @@ class AppController extends ChangeNotifier {
|
||||
await _reconcile();
|
||||
});
|
||||
Future<void> refreshServices() => _run(_reconcile);
|
||||
Future<void> startFreshData() => _run(() async {
|
||||
if (storageReady) {
|
||||
throw StateError('保存文件正常,无需重新建立');
|
||||
}
|
||||
snapshot = await store.startFresh();
|
||||
storageReady = true;
|
||||
isDemo = false;
|
||||
lastError = null;
|
||||
await _reconcile();
|
||||
});
|
||||
Future<void> retryCalendarCleanup() => _run(() async {
|
||||
if (await calendar.requestAccess()) {
|
||||
await _cleanupCalendar();
|
||||
|
||||
@@ -159,6 +159,31 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startFresh() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('重新建立课表'),
|
||||
content: const Text(
|
||||
'损坏文件会保留为恢复副本,然后重新建立空课表。已有课程通知会取消;手机日历中原有课程日程需手动处理。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('保留损坏文件并重新建立'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await widget.controller.startFreshData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
@@ -198,6 +223,11 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
||||
onPressed: widget.controller.restoreBackup,
|
||||
child: const Text('恢复最近备份'),
|
||||
),
|
||||
if (!widget.controller.storageReady)
|
||||
TextButton(
|
||||
onPressed: _startFresh,
|
||||
child: const Text('重新建立课表'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -46,4 +46,18 @@ class FileSnapshotStore extends SnapshotStore {
|
||||
await temporary.rename(_file.path);
|
||||
return previous;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AppSnapshot> startFresh() async {
|
||||
await directory.create(recursive: true);
|
||||
final stamp = DateTime.now().toUtc().microsecondsSinceEpoch;
|
||||
for (final file in [_file, _backup]) {
|
||||
if (await file.exists()) {
|
||||
await file.rename('${file.path}.damaged-$stamp');
|
||||
}
|
||||
}
|
||||
final fresh = AppSnapshot();
|
||||
await save(fresh);
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ abstract class SnapshotStore {
|
||||
Future<AppSnapshot?> load();
|
||||
Future<void> save(AppSnapshot snapshot);
|
||||
Future<AppSnapshot> restoreBackup() => Future.error(StateError('没有可恢复的备份'));
|
||||
Future<AppSnapshot> startFresh() => Future.error(StateError('无法重新建立保存文件'));
|
||||
}
|
||||
|
||||
@@ -138,14 +138,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
label: const Text('多彩'),
|
||||
selected: s.colorMode == ColorMode.multicolor,
|
||||
onSelected: (_) => c.updateSettings(
|
||||
s.copyWith(colorMode: ColorMode.multicolor),
|
||||
(current) =>
|
||||
current.copyWith(colorMode: ColorMode.multicolor),
|
||||
),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('单色'),
|
||||
selected: s.colorMode == ColorMode.single,
|
||||
onSelected: (_) => c.updateSettings(
|
||||
s.copyWith(colorMode: ColorMode.single),
|
||||
(current) =>
|
||||
current.copyWith(colorMode: ColorMode.single),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -166,7 +168,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
button: true,
|
||||
child: InkWell(
|
||||
onTap: () => c.updateSettings(
|
||||
s.copyWith(
|
||||
(current) => current.copyWith(
|
||||
singleColorArgb: color,
|
||||
colorMode: ColorMode.single,
|
||||
),
|
||||
@@ -217,7 +219,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
await c.enableRemindersFromUserAction();
|
||||
} else {
|
||||
await c.updateSettings(
|
||||
s.copyWith(remindersEnabled: false),
|
||||
(current) =>
|
||||
current.copyWith(remindersEnabled: false),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -234,7 +237,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
onSelected: c.isDemo
|
||||
? null
|
||||
: (_) => c.updateSettings(
|
||||
s.copyWith(reminderMinutes: minutes),
|
||||
(current) => current.copyWith(
|
||||
reminderMinutes: minutes,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -361,7 +366,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
});
|
||||
if (_weeksError != null || _dateError != null) return;
|
||||
await c.updateSettings(
|
||||
s.copyWith(
|
||||
(current) => current.copyWith(
|
||||
semester: Semester(
|
||||
startDate: _start!,
|
||||
totalWeeks: weeks!,
|
||||
|
||||
@@ -38,6 +38,60 @@ void main() {
|
||||
);
|
||||
});
|
||||
tearDown(() => controller.dispose());
|
||||
test(
|
||||
'queued changes to different settings preserve disabling reminders',
|
||||
() async {
|
||||
await controller.initialize();
|
||||
|
||||
await Future.wait([
|
||||
controller.updateSettings(
|
||||
(current) => current.copyWith(remindersEnabled: false),
|
||||
),
|
||||
controller.updateSettings(
|
||||
(current) => current.copyWith(colorMode: ColorMode.single),
|
||||
),
|
||||
]);
|
||||
expect(store.value!.settings.remindersEnabled, isFalse);
|
||||
expect(store.value!.settings.colorMode, ColorMode.single);
|
||||
},
|
||||
);
|
||||
test('partial first calendar sync survives restart and is cleaned before another destination', () async {
|
||||
store.value = store.value!.copyWith(
|
||||
courses: [
|
||||
course(id: 'a'),
|
||||
course(id: 'b'),
|
||||
],
|
||||
);
|
||||
final provider = CalendarProvider()..failAfterFirst = true;
|
||||
final first = AppController(
|
||||
store,
|
||||
notifications,
|
||||
AndroidCalendarService(provider),
|
||||
controller.clock,
|
||||
);
|
||||
await first.initialize();
|
||||
await first.enableCalendarFromUserAction('local');
|
||||
expect(provider.records, hasLength(1));
|
||||
first.dispose();
|
||||
provider.failAfterFirst = false;
|
||||
final reopened = AppController(
|
||||
store,
|
||||
notifications,
|
||||
AndroidCalendarService(provider),
|
||||
controller.clock,
|
||||
);
|
||||
await reopened.initialize();
|
||||
expect(provider.records, isEmpty);
|
||||
await reopened.enableCalendarFromUserAction('other');
|
||||
expect(
|
||||
provider.records.values.every((e) => e.calendarId == 'other'),
|
||||
isTrue,
|
||||
);
|
||||
expect(provider.records, hasLength(2));
|
||||
await reopened.disableCalendar(deleteEvents: true);
|
||||
expect(provider.records, isEmpty);
|
||||
reopened.dispose();
|
||||
});
|
||||
test('turning off after interrupted first sync cleans events even without saved IDs', () async {
|
||||
store.value = store.value!.copyWith(
|
||||
courses: [
|
||||
@@ -122,7 +176,7 @@ void main() {
|
||||
await controller.initialize();
|
||||
store.failSave = true;
|
||||
await controller.updateSettings(
|
||||
controller.snapshot.settings.copyWith(reminderMinutes: 30),
|
||||
(current) => current.copyWith(reminderMinutes: 30),
|
||||
);
|
||||
expect(controller.lastError, isNotNull);
|
||||
await controller.showDemo();
|
||||
@@ -148,7 +202,7 @@ void main() {
|
||||
await controller.initialize();
|
||||
store.failSave = true;
|
||||
await controller.updateSettings(
|
||||
controller.snapshot.settings.copyWith(reminderMinutes: 30),
|
||||
(current) => current.copyWith(reminderMinutes: 30),
|
||||
);
|
||||
expect(controller.snapshot.settings.reminderMinutes, 15);
|
||||
expect(controller.lastError, isNotNull);
|
||||
@@ -157,10 +211,10 @@ void main() {
|
||||
await controller.initialize();
|
||||
await Future.wait([
|
||||
controller.updateSettings(
|
||||
controller.snapshot.settings.copyWith(reminderMinutes: 30),
|
||||
(current) => current.copyWith(reminderMinutes: 30),
|
||||
),
|
||||
controller.updateSettings(
|
||||
controller.snapshot.settings.copyWith(reminderMinutes: 60),
|
||||
(current) => current.copyWith(reminderMinutes: 60),
|
||||
),
|
||||
]);
|
||||
expect(store.value!.settings.reminderMinutes, 60);
|
||||
|
||||
@@ -15,6 +15,37 @@ void main() {
|
||||
tearDown(() async {
|
||||
await directory.delete(recursive: true);
|
||||
});
|
||||
for (final badBackup in [false, true]) {
|
||||
test(
|
||||
'fresh recovery archives corrupt primary and ${badBackup ? 'corrupt' : 'missing'} backup before saving',
|
||||
() async {
|
||||
await File('${directory.path}/hepan_snapshot.json')
|
||||
.writeAsString('damaged primary');
|
||||
if (badBackup) {
|
||||
await File('${directory.path}/hepan_snapshot.bak')
|
||||
.writeAsString('damaged backup');
|
||||
}
|
||||
final store = FileSnapshotStore(directory);
|
||||
await expectLater(store.load(), throwsFormatException);
|
||||
await store.startFresh();
|
||||
final archives = await directory
|
||||
.list()
|
||||
.where((e) => e.path.contains('.damaged-'))
|
||||
.toList();
|
||||
expect(archives, hasLength(badBackup ? 2 : 1));
|
||||
final contents = await Future.wait(
|
||||
archives.map((e) => File(e.path).readAsString()),
|
||||
);
|
||||
expect(contents, contains('damaged primary'));
|
||||
if (badBackup) {
|
||||
expect(contents, contains('damaged backup'));
|
||||
}
|
||||
expect((await store.load())!.courses, isEmpty);
|
||||
await store.save(AppSnapshot(courses: [course()]));
|
||||
expect((await store.load())!.courses.single.id, 'math');
|
||||
},
|
||||
);
|
||||
}
|
||||
test(
|
||||
'reopening restores courses, settings and calendar identifiers',
|
||||
() async {
|
||||
|
||||
@@ -16,6 +16,7 @@ class CalendarProvider extends CalendarGateway {
|
||||
@override
|
||||
Future<List<DeviceCalendar>> calendars() async => [
|
||||
const DeviceCalendar('local', '专用测试日历'),
|
||||
const DeviceCalendar('other', '另一测试日历'),
|
||||
];
|
||||
@override
|
||||
Future<List<CalendarRecord>> ownedEvents(String calendarId) async {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:timezone/data/latest.dart' as tzdata;
|
||||
import 'package:hepan_everyday/app/app_controller.dart';
|
||||
import 'package:hepan_everyday/app/hepan_app.dart';
|
||||
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
|
||||
import '../support/fakes.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(tzdata.initializeTimeZones);
|
||||
for (final badBackup in [false, true]) {
|
||||
testWidgets(
|
||||
'corrupt primary with ${badBackup ? "corrupt" : "missing"} backup has confirmed fresh recovery',
|
||||
(tester) async {
|
||||
final store = DamagedStore(badBackup);
|
||||
final controller = AppController(
|
||||
store,
|
||||
FakeNotifications(),
|
||||
FakeCalendar(),
|
||||
DateTime.now,
|
||||
);
|
||||
await controller.initialize();
|
||||
await controller.restoreBackup();
|
||||
await tester.pumpWidget(HepanApp(controller: controller));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.storageReady, isFalse);
|
||||
expect(find.text('重新建立课表'), findsOneWidget);
|
||||
await tester.tap(find.text('重新建立课表'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('取消'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.storageReady, isFalse);
|
||||
expect(store.freshStarts, 0);
|
||||
await tester.tap(find.text('重新建立课表'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('保留损坏文件并重新建立'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.storageReady, isTrue);
|
||||
await tester.tap(find.bySemanticsLabel('我的'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('课表设置'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('课表设置'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('单色'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(store.value!.settings.colorMode.name, 'single');
|
||||
expect(store.freshStarts, 1);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DamagedStore extends MemoryStore {
|
||||
DamagedStore(this.badBackup);
|
||||
final bool badBackup;
|
||||
int freshStarts = 0;
|
||||
@override
|
||||
Future<AppSnapshot?> load() async =>
|
||||
value ?? (throw const FormatException('damaged'));
|
||||
@override
|
||||
Future<AppSnapshot> restoreBackup() async =>
|
||||
throw StateError(badBackup ? '备份损坏' : '没有备份');
|
||||
@override
|
||||
Future<AppSnapshot> startFresh() async {
|
||||
freshStarts++;
|
||||
return value = AppSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,61 @@ import 'package:hepan_everyday/services/calendar_service.dart';
|
||||
import '../support/fakes.dart';
|
||||
|
||||
import 'navigation_test.dart' show uiController;
|
||||
import '../services/calendar_service_test.dart' show CalendarProvider;
|
||||
import '../domain/schedule_engine_test.dart' show course;
|
||||
|
||||
import 'package:hepan_everyday/services/android_calendar_service.dart';
|
||||
import 'package:hepan_everyday/domain/app_snapshot.dart';
|
||||
import 'package:hepan_everyday/domain/app_settings.dart';
|
||||
import 'package:hepan_everyday/domain/semester.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(tzdata.initializeTimeZones);
|
||||
testWidgets(
|
||||
'partial initial calendar sync exposes working cleanup action while switch is off',
|
||||
(tester) async {
|
||||
final provider = CalendarProvider()..failAfterFirst = true;
|
||||
final controller = AppController(
|
||||
MemoryStore(
|
||||
AppSnapshot(
|
||||
courses: [
|
||||
course(id: 'a'),
|
||||
course(id: 'b'),
|
||||
],
|
||||
settings: AppSettings(
|
||||
semester: Semester(
|
||||
startDate: DateTime(2026, 9, 9),
|
||||
totalWeeks: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
FakeNotifications(),
|
||||
AndroidCalendarService(provider),
|
||||
DateTime.now,
|
||||
);
|
||||
await controller.initialize();
|
||||
await controller.enableCalendarFromUserAction('local');
|
||||
expect(controller.snapshot.settings.calendarEnabled, isFalse);
|
||||
expect(provider.records, hasLength(1));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: SettingsPage(controller: controller)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('重试清理课程日程'),
|
||||
250,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.ensureVisible(find.text('重试清理课程日程'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('重试清理课程日程'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(provider.records, isEmpty);
|
||||
expect(controller.snapshot.calendarCleanupPending, isFalse);
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
testWidgets('calendar provider failure is explained and keeps switch off', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user