fix: preserve queued settings and recover interrupted or corrupt state
This commit is contained in:
@@ -54,3 +54,4 @@ flutter run -d emulator-5554
|
|||||||
构建成功才复制新 APK 到 `dist/`。工具版本与设备环境见 [Android 环境](docs/android-setup-status.md)。其他电脑可使用 `scripts/setup-android.ps1 -WithEmulator` 安装环境,并将构建脚本 ToolchainRoot 指向对应 `.tools` 目录。
|
构建成功才复制新 APK 到 `dist/`。工具版本与设备环境见 [Android 环境](docs/android-setup-status.md)。其他电脑可使用 `scripts/setup-android.ps1 -WithEmulator` 安装环境,并将构建脚本 ToolchainRoot 指向对应 `.tools` 目录。
|
||||||
|
|
||||||
设备集成检查见 [验证记录](docs/android-mvp-validation.md)。系统日历测试仅运行于专用模拟器的 `Hepan_Integration_Test` 测试日历,需提前允许测试 APP 读取和写入日历。`mobile/tool/reminder_probe.dart` 是短时系统提醒测试入口,交付 APK 始终使用 `lib/main.dart`。
|
设备集成检查见 [验证记录](docs/android-mvp-validation.md)。系统日历测试仅运行于专用模拟器的 `Hepan_Integration_Test` 测试日历,需提前允许测试 APP 读取和写入日历。`mobile/tool/reminder_probe.dart` 是短时系统提醒测试入口,交付 APK 始终使用 `lib/main.dart`。
|
||||||
|
损坏文件无法从备份恢复时,可在提示区选择“重新建立课表”,确认后保留损坏文件副本并恢复保存功能。手机日历中的历史日程需按提示处理。
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 153 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 153 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
@@ -5,7 +5,7 @@
|
|||||||
## 已验证
|
## 已验证
|
||||||
|
|
||||||
- `flutter analyze`:No issues found。
|
- `flutter analyze`:No issues found。
|
||||||
- `flutter test`:51 项通过,包括 11 节时间、上海时区和非周一开始日、不连续周次、导入拒绝保留原课表、存储损坏恢复、示例隔离、通知去重和权限降级、日历中断恢复与归属检查、200% 字体和重叠课程、通知点击详情。
|
- `flutter test`:58 项通过,包括 11 节时间、上海时区和非周一开始日、不连续周次、导入拒绝保留原课表、存储损坏恢复、示例隔离、通知去重和权限降级、日历中断恢复与归属检查、200% 字体和重叠课程、通知点击详情。
|
||||||
- `integration_test/app_flow_test.dart` 在 Android 模拟器通过:两页导航、单色设置、实际日期选择器和学期周数保存、导入预览取消与确认、示例不改真实保存文件、错误导入保持数据、重开恢复。
|
- `integration_test/app_flow_test.dart` 在 Android 模拟器通过:两页导航、单色设置、实际日期选择器和学期周数保存、导入预览取消与确认、示例不改真实保存文件、错误导入保持数据、重开恢复。
|
||||||
- `integration_test/calendar_bridge_test.dart` 在专用 Hepan_Integration_Test 日历通过:创建、再次同步更新同一 ID、映射丢失后找回、错误 key 无法删除、删除自有事件。设备实际查询表明 Unrelated_fixture 事件保留,CalendarContract.Reminders 无记录。
|
- `integration_test/calendar_bridge_test.dart` 在专用 Hepan_Integration_Test 日历通过:创建、再次同步更新同一 ID、映射丢失后找回、错误 key 无法删除、删除自有事件。设备实际查询表明 Unrelated_fixture 事件保留,CalendarContract.Reminders 无记录。
|
||||||
- 首次真实 APK 构建、安装、启动成功。初次编译下载依赖用时约 695 秒,后续构建约 40 秒。
|
- 首次真实 APK 构建、安装、启动成功。初次编译下载依赖用时约 695 秒,后续构建约 40 秒。
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
- 模拟器重启后:未重新打开 APP,系统仍送出已安排的测试通知;启动正式 APP 后,空课表协调取消残留自有通知。
|
- 模拟器重启后:未重新打开 APP,系统仍送出已安排的测试通知;启动正式 APP 后,空课表协调取消残留自有通知。
|
||||||
- 已重建并安装 lib/main.dart 正式入口 APK,核实包名、0.1.0+1、API 24/36 和三种 Android 架构。示例课表、我的、周日、末节、设置和 200% 字体已有设备截图;验证 APK 不包含测试入口。
|
- 已重建并安装 lib/main.dart 正式入口 APK,核实包名、0.1.0+1、API 24/36 和三种 Android 架构。示例课表、我的、周日、末节、设置和 200% 字体已有设备截图;验证 APK 不包含测试入口。
|
||||||
- 独立代码审查进行中,结论另记。
|
- 已完成一次独立代码审查:无 Critical/Minor,三项 Important 均用回归测试修复。58 项测试全通过,未进行重复审查。
|
||||||
|
|
||||||
## 仍需本人手机验证
|
## 仍需本人手机验证
|
||||||
|
|
||||||
@@ -30,3 +30,6 @@
|
|||||||
|
|
||||||
通知初始化暂时失败后可重试,日历服务异常在设置中解释并保持未启用。首次同步中断、尚无保存 ID 时,通过选定日历的专属标记恢复清理;清理失败持久保存待重试状态。
|
通知初始化暂时失败后可重试,日历服务异常在设置中解释并保持未启用。首次同步中断、尚无保存 ID 时,通过选定日历的专属标记恢复清理;清理失败持久保存待重试状态。
|
||||||
|
|
||||||
|
补充:系统文件选择器已从 Downloads 选择随附 JSON,正常显示 3 条课程预览和示例来源;飞行模式下正式 APP 重开后页面正常,示例体验未写成真实课程。
|
||||||
|
|
||||||
|
审查修复:设置改为按字段在最新状态上修改;首次日历写入前保存未完成状态,清理成功才允许更换目标;没有可用备份时提供确认恢复入口,真实文件测试验证损坏主文件和备份均先归档。
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# SDD ledger — plan: docs/superpowers/plans/2026-10-12-hepan-everyday-android.md
|
||||||
|
Execution: inline, approved by user. Base: 36d18f0.
|
||||||
|
Pre-flight: Tasks 1→2→3 share domain, snapshot and controller; names consistent.
|
||||||
|
Pre-flight: Tasks 2→4 import drafts; unknown school pages fail closed, demo stays isolated.
|
||||||
|
Pre-flight: Tasks 2→5 NotificationService; permission request separate from reconciliation.
|
||||||
|
Pre-flight: Tasks 2→6 CalendarService; ownership and retry mapping required.
|
||||||
|
Pre-flight: Tasks 3→7 UI/integration; file fixture uses explicit demo flag.
|
||||||
|
Ruling: PowerShell ledger and extracted briefs replace Bash helper scripts — only WSL shim exists and native Windows execution is available — tracking remains equivalent; cost if wrong: missing process record, not product behavior.
|
||||||
|
Task 1: in_progress (base 36d18f0).
|
||||||
|
Task 1: complete (base 36d18f0; RED 7 expected failures; GREEN flutter test 8/8; analyze clean).
|
||||||
|
Task 2: in_progress (base ae8e347).
|
||||||
|
Task 2: complete (RED 13 expected failures; GREEN flutter test 21/21; analyze clean).
|
||||||
|
Task 3: in_progress (base 808af00).
|
||||||
|
Correction: Task 2 analyze had 9 style infos; dart fix applied and Task 3 verifies current analyzer.
|
||||||
|
Ruling: Task 3 emulator visual review is grouped with Task 7 after real platform adapters exist — avoids a production placeholder adapter — cost if wrong: visual issues are found later, before delivery.
|
||||||
|
Task 3: complete (RED navigation 4, overlap 1, recovery 2; GREEN full suite 28/28; device visual deferred to Task 7 by ruling).
|
||||||
|
Task 4: in_progress (base 8a98d49).
|
||||||
|
Task 4: complete (RED unsafe domain/layout 3, concurrent read 1, edge recovery/frame 2; GREEN full suite 34/34; analyze clean. Real authenticated timetable parser remains explicitly pending per spec.)
|
||||||
|
Task 5: in_progress (base ffd3920).
|
||||||
|
Task 5/6: Ruling: native notification and calendar checks consolidated in Task 7 after real startup assembly — avoids temporary production adapters — cost if wrong: native failures found later.
|
||||||
|
Task 6: Ruling: per-event save/delete gateway replaces native syncEvents batch — keeps reconciliation and recovery testable in Dart, native checks ownership for every mutation — cost if wrong: extra platform calls for large terms.
|
||||||
|
Task 5: complete (local notification tests 6 passed, combined suite 48 passed; analyzer clean; native checks deferred by ruling).
|
||||||
|
Task 6: complete (calendar and controller RED witnessed; combined suite 48 passed; analyzer clean; native verification remains Task 7).
|
||||||
|
Correction: previous commit attempts failed due to missing local author identity. Tasks 5 and 6 committed together using existing Codex <codex@local> author; analyzer confirmed No issues found after completion labels.
|
||||||
|
Task 7: in_progress; real startup assembled, startup notification-tap RED→GREEN, initialization retry and calendar UI error RED→GREEN. First actual APK built and installed successfully; native device verification underway.
|
||||||
|
Task 7: complete (51 tests pass; analyze clean; Android UI flow and native calendar integration pass; background notification, tap details, reboot restoration and startup cleanup observed; formal main APK rebuilt and installed, badging verified; review follows).
|
||||||
|
Final: Ruling: updateSettings queues field transformations instead of complete settings values — fixes stale queued values undoing previous choices — cost if wrong: consumers must use the new callback signature.
|
||||||
|
Final: Ruling: real school parser remains conditional on unavailable authenticated fixture — approved spec protects old timetable and explicitly marks pending — cost if wrong: automatic school import is unavailable in this MVP.
|
||||||
|
Final: Ruling: iOS packaging remains deferred — user selected Android first — cost if wrong: cannot install on iPhone yet.
|
||||||
|
Final: Ruling: physical-device power management and cloud calendar marker behavior remain device-validation work — emulator cannot establish those results — cost if wrong: notifications or calendar synchronization may vary on specific phones/accounts.
|
||||||
|
Final review: fresh gpt-6-astra reviewer; no Critical or Minor; three Important findings regraded as Important and fixed in one pass. No re-review required; regression tests govern fix completion.
|
||||||
|
Final: fixed queued whole settings undoing reminder disable — queued distinct-field regression RED→GREEN, suite 58/58.
|
||||||
|
Final: fixed inaccessible partial calendar recovery and destination loss — partial sync/reopen/new calendar regression RED→GREEN; working cleanup button tested, suite 58/58.
|
||||||
|
Final: fixed no-backup corrupt storage lockout — missing/corrupt backup confirmation UI RED→GREEN; real filesystem archive/save tests pass, suite 58/58.
|
||||||
|
Final verification: affected Android app_flow integration rerun passes after review fixes; full suite 58/58 and analyze No issues found.
|
||||||
@@ -85,7 +85,10 @@ class AppController extends ChangeNotifier {
|
|||||||
Future<void> leaveDemo() => _run(() async {
|
Future<void> leaveDemo() => _run(() async {
|
||||||
isDemo = false;
|
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)) {
|
if (![5, 10, 15, 30, 60].contains(value.reminderMinutes)) {
|
||||||
throw ArgumentError('提醒时间无效');
|
throw ArgumentError('提醒时间无效');
|
||||||
}
|
}
|
||||||
@@ -170,43 +173,54 @@ class AppController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
calendarSyncError = null;
|
calendarSyncError = null;
|
||||||
});
|
});
|
||||||
Future<void> enableCalendarFromUserAction(
|
Future<void> enableCalendarFromUserAction(String calendarId) =>
|
||||||
String calendarId,
|
_run(() async {
|
||||||
) => _run(() async {
|
if (isDemo) throw StateError('示例课表不会导入日历');
|
||||||
if (isDemo) throw StateError('示例课表不会导入日历');
|
if (snapshot.settings.semester == null) throw StateError('请先设置学期');
|
||||||
if (snapshot.settings.semester == null) throw StateError('请先设置学期');
|
if (!await calendar.requestAccess()) {
|
||||||
if (!await calendar.requestAccess()) {
|
calendarSyncError = '日历权限未获允许';
|
||||||
calendarSyncError = '日历权限未获允许';
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (snapshot.calendarCleanupPending) {
|
||||||
if (snapshot.calendarCleanupPending) {
|
await _cleanupCalendar();
|
||||||
await _cleanupCalendar();
|
}
|
||||||
}
|
final calendars = await calendar.listWritableCalendars();
|
||||||
final calendars = await calendar.listWritableCalendars();
|
if (!calendars.any((c) => c.id == calendarId)) {
|
||||||
if (!calendars.any((c) => c.id == calendarId)) {
|
calendarSyncError = '请选择可写入的日历';
|
||||||
calendarSyncError = '请选择可写入的日历';
|
return;
|
||||||
return;
|
}
|
||||||
}
|
final settings = snapshot.settings.copyWith(
|
||||||
final settings = snapshot.settings.copyWith(
|
calendarEnabled: true,
|
||||||
calendarEnabled: true,
|
calendarId: calendarId,
|
||||||
calendarId: calendarId,
|
);
|
||||||
);
|
// Keep the chosen calendar for recovery if the first native sync fails midway.
|
||||||
// Keep the chosen calendar for recovery if the first native sync fails midway.
|
await _save(
|
||||||
await _save(
|
snapshot.copyWith(
|
||||||
snapshot.copyWith(settings: settings.copyWith(calendarEnabled: false)),
|
settings: settings.copyWith(calendarEnabled: false),
|
||||||
);
|
calendarCleanupPending: true,
|
||||||
try {
|
calendarEventIds: snapshot.settings.calendarId == calendarId
|
||||||
final ids = await calendar.sync(
|
? snapshot.calendarEventIds
|
||||||
ScheduleEngine.occurrences(snapshot.courses, settings.semester!),
|
: {},
|
||||||
calendarId,
|
),
|
||||||
snapshot.calendarEventIds,
|
);
|
||||||
);
|
try {
|
||||||
await _save(snapshot.copyWith(settings: settings, calendarEventIds: ids));
|
final ids = await calendar.sync(
|
||||||
calendarSyncError = null;
|
ScheduleEngine.occurrences(snapshot.courses, settings.semester!),
|
||||||
} catch (_) {
|
calendarId,
|
||||||
calendarSyncError = '日历同步失败,可重试';
|
snapshot.calendarEventIds,
|
||||||
}
|
);
|
||||||
});
|
await _save(
|
||||||
|
snapshot.copyWith(
|
||||||
|
settings: settings,
|
||||||
|
calendarEventIds: ids,
|
||||||
|
calendarCleanupPending: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
calendarSyncError = null;
|
||||||
|
} catch (_) {
|
||||||
|
calendarSyncError = '日历同步失败,可重试';
|
||||||
|
}
|
||||||
|
});
|
||||||
Future<void> enableRemindersFromUserAction() => _run(() async {
|
Future<void> enableRemindersFromUserAction() => _run(() async {
|
||||||
if (isDemo) throw StateError('示例课表不会生成提醒');
|
if (isDemo) throw StateError('示例课表不会生成提醒');
|
||||||
await notifications.requestPermissions();
|
await notifications.requestPermissions();
|
||||||
@@ -218,6 +232,16 @@ class AppController extends ChangeNotifier {
|
|||||||
await _reconcile();
|
await _reconcile();
|
||||||
});
|
});
|
||||||
Future<void> refreshServices() => _run(_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 {
|
Future<void> retryCalendarCleanup() => _run(() async {
|
||||||
if (await calendar.requestAccess()) {
|
if (await calendar.requestAccess()) {
|
||||||
await _cleanupCalendar();
|
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
|
@override
|
||||||
Widget build(BuildContext context) => AnimatedBuilder(
|
Widget build(BuildContext context) => AnimatedBuilder(
|
||||||
animation: Listenable.merge([
|
animation: Listenable.merge([
|
||||||
@@ -198,6 +223,11 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
|||||||
onPressed: widget.controller.restoreBackup,
|
onPressed: widget.controller.restoreBackup,
|
||||||
child: const Text('恢复最近备份'),
|
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);
|
await temporary.rename(_file.path);
|
||||||
return previous;
|
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<AppSnapshot?> load();
|
||||||
Future<void> save(AppSnapshot snapshot);
|
Future<void> save(AppSnapshot snapshot);
|
||||||
Future<AppSnapshot> restoreBackup() => Future.error(StateError('没有可恢复的备份'));
|
Future<AppSnapshot> restoreBackup() => Future.error(StateError('没有可恢复的备份'));
|
||||||
|
Future<AppSnapshot> startFresh() => Future.error(StateError('无法重新建立保存文件'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,14 +138,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
label: const Text('多彩'),
|
label: const Text('多彩'),
|
||||||
selected: s.colorMode == ColorMode.multicolor,
|
selected: s.colorMode == ColorMode.multicolor,
|
||||||
onSelected: (_) => c.updateSettings(
|
onSelected: (_) => c.updateSettings(
|
||||||
s.copyWith(colorMode: ColorMode.multicolor),
|
(current) =>
|
||||||
|
current.copyWith(colorMode: ColorMode.multicolor),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
label: const Text('单色'),
|
label: const Text('单色'),
|
||||||
selected: s.colorMode == ColorMode.single,
|
selected: s.colorMode == ColorMode.single,
|
||||||
onSelected: (_) => c.updateSettings(
|
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,
|
button: true,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => c.updateSettings(
|
onTap: () => c.updateSettings(
|
||||||
s.copyWith(
|
(current) => current.copyWith(
|
||||||
singleColorArgb: color,
|
singleColorArgb: color,
|
||||||
colorMode: ColorMode.single,
|
colorMode: ColorMode.single,
|
||||||
),
|
),
|
||||||
@@ -217,7 +219,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
await c.enableRemindersFromUserAction();
|
await c.enableRemindersFromUserAction();
|
||||||
} else {
|
} else {
|
||||||
await c.updateSettings(
|
await c.updateSettings(
|
||||||
s.copyWith(remindersEnabled: false),
|
(current) =>
|
||||||
|
current.copyWith(remindersEnabled: false),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -234,7 +237,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
onSelected: c.isDemo
|
onSelected: c.isDemo
|
||||||
? null
|
? null
|
||||||
: (_) => c.updateSettings(
|
: (_) => 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;
|
if (_weeksError != null || _dateError != null) return;
|
||||||
await c.updateSettings(
|
await c.updateSettings(
|
||||||
s.copyWith(
|
(current) => current.copyWith(
|
||||||
semester: Semester(
|
semester: Semester(
|
||||||
startDate: _start!,
|
startDate: _start!,
|
||||||
totalWeeks: weeks!,
|
totalWeeks: weeks!,
|
||||||
|
|||||||
@@ -38,6 +38,60 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
tearDown(() => controller.dispose());
|
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 {
|
test('turning off after interrupted first sync cleans events even without saved IDs', () async {
|
||||||
store.value = store.value!.copyWith(
|
store.value = store.value!.copyWith(
|
||||||
courses: [
|
courses: [
|
||||||
@@ -122,7 +176,7 @@ void main() {
|
|||||||
await controller.initialize();
|
await controller.initialize();
|
||||||
store.failSave = true;
|
store.failSave = true;
|
||||||
await controller.updateSettings(
|
await controller.updateSettings(
|
||||||
controller.snapshot.settings.copyWith(reminderMinutes: 30),
|
(current) => current.copyWith(reminderMinutes: 30),
|
||||||
);
|
);
|
||||||
expect(controller.lastError, isNotNull);
|
expect(controller.lastError, isNotNull);
|
||||||
await controller.showDemo();
|
await controller.showDemo();
|
||||||
@@ -148,7 +202,7 @@ void main() {
|
|||||||
await controller.initialize();
|
await controller.initialize();
|
||||||
store.failSave = true;
|
store.failSave = true;
|
||||||
await controller.updateSettings(
|
await controller.updateSettings(
|
||||||
controller.snapshot.settings.copyWith(reminderMinutes: 30),
|
(current) => current.copyWith(reminderMinutes: 30),
|
||||||
);
|
);
|
||||||
expect(controller.snapshot.settings.reminderMinutes, 15);
|
expect(controller.snapshot.settings.reminderMinutes, 15);
|
||||||
expect(controller.lastError, isNotNull);
|
expect(controller.lastError, isNotNull);
|
||||||
@@ -157,10 +211,10 @@ void main() {
|
|||||||
await controller.initialize();
|
await controller.initialize();
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
controller.updateSettings(
|
controller.updateSettings(
|
||||||
controller.snapshot.settings.copyWith(reminderMinutes: 30),
|
(current) => current.copyWith(reminderMinutes: 30),
|
||||||
),
|
),
|
||||||
controller.updateSettings(
|
controller.updateSettings(
|
||||||
controller.snapshot.settings.copyWith(reminderMinutes: 60),
|
(current) => current.copyWith(reminderMinutes: 60),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
expect(store.value!.settings.reminderMinutes, 60);
|
expect(store.value!.settings.reminderMinutes, 60);
|
||||||
|
|||||||
@@ -15,6 +15,37 @@ void main() {
|
|||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
await directory.delete(recursive: true);
|
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(
|
test(
|
||||||
'reopening restores courses, settings and calendar identifiers',
|
'reopening restores courses, settings and calendar identifiers',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class CalendarProvider extends CalendarGateway {
|
|||||||
@override
|
@override
|
||||||
Future<List<DeviceCalendar>> calendars() async => [
|
Future<List<DeviceCalendar>> calendars() async => [
|
||||||
const DeviceCalendar('local', '专用测试日历'),
|
const DeviceCalendar('local', '专用测试日历'),
|
||||||
|
const DeviceCalendar('other', '另一测试日历'),
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
Future<List<CalendarRecord>> ownedEvents(String calendarId) async {
|
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 '../support/fakes.dart';
|
||||||
|
|
||||||
import 'navigation_test.dart' show uiController;
|
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() {
|
void main() {
|
||||||
setUpAll(tzdata.initializeTimeZones);
|
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', (
|
testWidgets('calendar provider failure is explained and keeps switch off', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user