feat: persist timetable and validate confirmed imports

This commit is contained in:
Codex
2026-10-12 23:18:51 +08:00
parent ae8e347e49
commit 808af007a2
13 changed files with 852 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
import 'package:flutter/foundation.dart';
import '../domain/app_snapshot.dart';
import '../domain/app_settings.dart';
import '../domain/course.dart';
import '../data/snapshot_store.dart';
import '../data/json_schedule_import.dart';
import '../services/notification_service.dart';
import '../services/calendar_service.dart';
import '../domain/schedule_engine.dart';
import '../data/demo_schedule.dart';
class AppController extends ChangeNotifier {
AppController(this.store, this.notifications, this.calendar, this.clock);
final SnapshotStore store;
final NotificationService notifications;
final CalendarService calendar;
final DateTime Function() clock;
AppSnapshot snapshot = AppSnapshot();
bool isDemo = false;
bool storageReady = true;
List<Course> _demo = [];
AppSettings? _demoSettings;
Future<void> _queue = Future.value();
String? lastError, calendarSyncError;
ReminderResult reminderResult = const ReminderResult(ReminderStatus.off);
List<Course> get visibleCourses => isDemo ? _demo : snapshot.courses;
AppSettings get visibleSettings =>
isDemo ? _demoSettings! : snapshot.settings;
Future<void> _run(Future<void> Function() operation) {
final next = _queue.then((_) async {
try {
await operation();
} catch (error) {
lastError = error.toString();
}
notifyListeners();
});
_queue = next;
return next;
}
void _checkStorage() {
if (!storageReady) throw StateError('请先恢复备份,避免覆盖损坏的保存文件');
}
Future<void> _save(AppSnapshot next) async {
_checkStorage();
await store.save(next);
snapshot = next;
lastError = null;
}
Future<void> initialize() => _run(() async {
try {
snapshot = await store.load() ?? AppSnapshot();
storageReady = true;
} catch (_) {
storageReady = false;
throw const FormatException('保存文件读取失败,可恢复最近备份');
}
await _reconcile();
});
Future<void> restoreBackup() => _run(() async {
snapshot = await store.restoreBackup();
storageReady = true;
lastError = null;
await _reconcile();
});
Future<void> showDemo({List<Course>? courses}) => _run(() async {
_demo = courses ?? demoCourses();
isDemo = true;
final today = ScheduleEngine.schoolDate(clock());
_demoSettings = snapshot.settings.copyWith(
semester: Semester(
startDate: today.subtract(Duration(days: today.weekday - 1)),
totalWeeks: 20,
),
remindersEnabled: false,
calendarEnabled: false,
);
});
Future<void> leaveDemo() => _run(() async {
isDemo = false;
});
Future<void> updateSettings(AppSettings value) => _run(() async {
if (![5, 10, 15, 30, 60].contains(value.reminderMinutes))
throw ArgumentError('提醒时间无效');
await _save(snapshot.copyWith(settings: value));
if (isDemo)
_demoSettings = _demoSettings!.copyWith(
colorMode: value.colorMode,
singleColorArgb: value.singleColorArgb,
);
await _reconcile();
});
Future<void> applyImport(
ImportDraft draft, {
required bool confirmedEmpty,
}) => _run(() async {
if (draft.courses.isEmpty && (!draft.explicitlyEmpty || !confirmedEmpty)) {
throw const FormatException('未检测到课程,已有课表保持不变');
}
if (draft.courses.map((c) => c.id).toSet().length != draft.courses.length)
throw const FormatException('课程标识重复');
if (draft.source == CourseSource.demo) {
_demo = draft.courses;
isDemo = true;
final today = ScheduleEngine.schoolDate(clock());
_demoSettings = snapshot.settings.copyWith(
semester: Semester(
startDate: today.subtract(Duration(days: today.weekday - 1)),
totalWeeks: 20,
),
remindersEnabled: false,
calendarEnabled: false,
);
return;
}
if (draft.courses.any((c) => c.source != draft.source))
throw const FormatException('课程来源不一致');
await _save(
snapshot.copyWith(
courses: draft.courses,
importMetadata: ImportMetadata(
source: draft.source,
importedAt: clock(),
parserVersion: draft.parserVersion,
),
),
);
isDemo = false;
await _reconcile();
});
Future<void> clearCourses() => _run(() async {
// Preserve ownership mapping until cleanup has actually succeeded.
await _save(snapshot.copyWith(courses: [], clearMetadata: true));
isDemo = false;
await notifications.cancelOwned();
await calendar.deleteOwned(snapshot.calendarEventIds);
await _save(snapshot.copyWith(calendarEventIds: {}));
});
Future<void> disableCalendar({required bool deleteEvents}) => _run(() async {
await _save(
snapshot.copyWith(
settings: snapshot.settings.copyWith(calendarEnabled: false),
),
);
if (deleteEvents) {
await calendar.deleteOwned(snapshot.calendarEventIds);
await _save(snapshot.copyWith(calendarEventIds: {}));
}
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;
}
final calendars = await calendar.listWritableCalendars();
if (!calendars.any((c) => c.id == calendarId)) {
calendarSyncError = '请选择可写入的日历';
return;
}
final settings = snapshot.settings.copyWith(
calendarEnabled: true,
calendarId: calendarId,
);
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> enableRemindersFromUserAction() => _run(() async {
if (isDemo) throw StateError('示例课表不会生成提醒');
await notifications.requestPermissions();
await _save(
snapshot.copyWith(
settings: snapshot.settings.copyWith(remindersEnabled: true),
),
);
await _reconcile();
});
Future<void> refreshServices() => _run(_reconcile);
Future<void> _reconcile() async {
final settings = snapshot.settings;
final occurrences = settings.semester == null
? <CourseOccurrence>[]
: ScheduleEngine.occurrences(snapshot.courses, settings.semester!);
try {
reminderResult = await notifications.reconcile(
occurrences,
settings,
clock(),
);
} catch (_) {
reminderResult = const ReminderResult(
ReminderStatus.error,
message: '提醒设置未完成,可重试',
);
}
if (settings.calendarEnabled && settings.calendarId != null) {
try {
final ids = await calendar.sync(
occurrences,
settings.calendarId!,
snapshot.calendarEventIds,
);
await _save(snapshot.copyWith(calendarEventIds: ids));
calendarSyncError = null;
} catch (_) {
calendarSyncError = '日历同步失败,可重试';
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
import '../domain/course.dart';
List<Course> demoCourses() => [
_demo('english', '学术英语', '示例 · 张老师', '示例 · 综合楼 A301', 1, 1, 2),
_demo('matrix', '矩阵论', '示例 · 李老师', '示例 · 品学楼 B205', 1, 5, 6),
_demo('research', '研究方法', '示例 · 陈老师', '示例 · 立人楼 A208', 2, 3, 4),
_demo('signal', '现代信号处理', '示例 · 王老师', '示例 · 品学楼 C302', 3, 1, 2),
_demo('seminar', '学术研讨', '示例 · 刘老师', '示例 · 实验室 501', 3, 9, 11),
_demo('science', '科技论文写作', '示例 · 周老师', '示例 · 综合楼 B102', 4, 5, 6),
_demo('experiment', '实验与实践', '示例 · 赵老师', '示例 · 实验楼 302', 5, 3, 4),
_demo('weekend', '专题报告', '示例 · 导师组', '示例 · 学术报告厅', 7, 9, 11),
];
Course _demo(
String id,
String name,
String teacher,
String room,
int day,
int first,
int last,
) => Course(
id: id,
name: name,
teacher: teacher,
room: room,
weekday: day,
startPeriod: first,
endPeriod: last,
weeks: Set.from(List.generate(20, (i) => i + 1)),
source: CourseSource.demo,
);
+49
View File
@@ -0,0 +1,49 @@
import 'dart:io';
import 'dart:convert';
import 'snapshot_store.dart';
import '../domain/app_snapshot.dart';
class FileSnapshotStore extends SnapshotStore {
FileSnapshotStore(this.directory);
final Directory directory;
File get _file => File('${directory.path}/hepan_snapshot.json');
File get _backup => File('${directory.path}/hepan_snapshot.bak');
Future<AppSnapshot> _read(File file) async {
final value = jsonDecode(await file.readAsString());
if (value is! Map<String, dynamic>) throw const FormatException('保存文件损坏');
return AppSnapshot.fromJson(value);
}
@override
Future<AppSnapshot?> load() async =>
await _file.exists() ? await _read(_file) : null;
@override
Future<void> save(AppSnapshot snapshot) async {
await directory.create(recursive: true);
final text = jsonEncode(snapshot.toJson());
// Validate before touching the last successfully saved version.
AppSnapshot.fromJson(jsonDecode(text) as Map<String, dynamic>);
if (await _file.exists()) {
try {
await _read(_file);
await _file.copy(_backup.path);
} on FormatException {
/* Keep an existing valid backup. */
}
}
final temporary = File('${_file.path}.tmp');
await temporary.writeAsString(text, flush: true);
await temporary.rename(_file.path);
}
@override
Future<AppSnapshot> restoreBackup() async {
if (!await _backup.exists()) throw StateError('没有可恢复的备份');
final previous = await _read(_backup);
final temporary = File('${_file.path}.tmp');
await temporary.writeAsString(jsonEncode(previous.toJson()), flush: true);
await temporary.rename(_file.path);
return previous;
}
}
+57
View File
@@ -0,0 +1,57 @@
import '../domain/course.dart';
import 'dart:convert';
class ImportDraft {
const ImportDraft({
required this.courses,
required this.explicitlyEmpty,
required this.source,
required this.parserVersion,
});
final List<Course> courses;
final bool explicitlyEmpty;
final CourseSource source;
final String parserVersion;
}
class JsonScheduleImport {
static ImportDraft parse(String text) {
try {
if (utf8.encode(text).length > 2 * 1024 * 1024)
throw const FormatException('文件不能超过 2 MB');
final json = jsonDecode(text);
if (json is! Map ||
json['schemaVersion'] != 1 ||
json['courses'] is! List ||
(json['isDemo'] != null && json['isDemo'] is! bool) ||
(json['explicitlyEmpty'] != null &&
json['explicitlyEmpty'] is! bool)) {
throw const FormatException('请选择版本 1 的课表 JSON 文件');
}
final source = json['isDemo'] == true
? CourseSource.demo
: CourseSource.file;
final courses = (json['courses'] as List)
.map(
(c) => Course.fromJson(
Map<String, dynamic>.from(c as Map),
source: source,
),
)
.toList();
if (courses.map((c) => c.id).toSet().length != courses.length)
throw const FormatException('课程标识重复');
return ImportDraft(
courses: List.unmodifiable(courses),
explicitlyEmpty: json['explicitlyEmpty'] == true,
source: source,
parserVersion: 'json-v1',
);
} on FormatException {
rethrow;
} catch (_) {
throw const FormatException('课表文件格式无效');
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import '../domain/app_snapshot.dart';
abstract class SnapshotStore {
Future<AppSnapshot?> load();
Future<void> save(AppSnapshot snapshot);
Future<AppSnapshot> restoreBackup() => Future.error(StateError('没有可恢复的备份'));
}
+122
View File
@@ -0,0 +1,122 @@
import 'app_settings.dart';
import 'course.dart';
import 'semester.dart';
class ImportMetadata {
const ImportMetadata({
required this.source,
required this.importedAt,
required this.parserVersion,
});
final CourseSource source;
final DateTime importedAt;
final String parserVersion;
}
class AppSnapshot {
AppSnapshot({
this.settings = const AppSettings(),
List<Course> courses = const [],
this.importMetadata,
Map<String, String> calendarEventIds = const {},
}) : courses = List.unmodifiable(courses),
calendarEventIds = Map.unmodifiable(calendarEventIds);
final AppSettings settings;
final List<Course> courses;
final ImportMetadata? importMetadata;
final Map<String, String> calendarEventIds;
AppSnapshot copyWith({
AppSettings? settings,
List<Course>? courses,
ImportMetadata? importMetadata,
Map<String, String>? calendarEventIds,
bool clearMetadata = false,
}) => AppSnapshot(
settings: settings ?? this.settings,
courses: courses ?? this.courses,
importMetadata: clearMetadata
? null
: importMetadata ?? this.importMetadata,
calendarEventIds: calendarEventIds ?? this.calendarEventIds,
);
Map<String, dynamic> toJson() => {
'schemaVersion': 1,
'courses': courses.map((c) => c.toJson()).toList(),
'settings': {
'semester': settings.semester == null
? null
: {
'startDate': settings.semester!.startDate
.toIso8601String()
.substring(0, 10),
'totalWeeks': settings.semester!.totalWeeks,
},
'remindersEnabled': settings.remindersEnabled,
'reminderMinutes': settings.reminderMinutes,
'colorMode': settings.colorMode.name,
'singleColorArgb': settings.singleColorArgb,
'calendarEnabled': settings.calendarEnabled,
'calendarId': settings.calendarId,
},
'importMetadata': importMetadata == null
? null
: {
'source': importMetadata!.source.name,
'importedAt': importMetadata!.importedAt.toUtc().toIso8601String(),
'parserVersion': importMetadata!.parserVersion,
},
'calendarEventIds': calendarEventIds,
};
static AppSnapshot fromJson(Map<String, dynamic> json) {
try {
if (json['schemaVersion'] != 1) throw const FormatException('不支持的保存版本');
final values = (json['courses'] as List)
.map((c) => Course.fromJson(Map<String, dynamic>.from(c as Map)))
.toList();
if (values.map((c) => c.id).toSet().length != values.length ||
values.any((c) => c.source == CourseSource.demo)) {
throw const FormatException('保存课表无效');
}
final s = Map<String, dynamic>.from(json['settings'] as Map);
final minutes = s['reminderMinutes'] as int;
if (![5, 10, 15, 30, 60].contains(minutes))
throw const FormatException('提醒时间无效');
final color = s['singleColorArgb'] as int;
if (color < 0 || color > 0xFFFFFFFF) throw const FormatException('颜色无效');
final sem = s['semester'];
final term = sem == null
? null
: Semester(
startDate: DateTime.parse(sem['startDate'] as String),
totalWeeks: sem['totalWeeks'] as int,
);
final metadata = json['importMetadata'];
return AppSnapshot(
courses: values,
settings: AppSettings(
semester: term,
remindersEnabled: s['remindersEnabled'] as bool,
reminderMinutes: minutes,
colorMode: ColorMode.values.byName(s['colorMode'] as String),
singleColorArgb: color,
calendarEnabled: s['calendarEnabled'] as bool,
calendarId: s['calendarId'] as String?,
),
importMetadata: metadata == null
? null
: ImportMetadata(
source: CourseSource.values.byName(
metadata['source'] as String,
),
importedAt: DateTime.parse(metadata['importedAt'] as String),
parserVersion: metadata['parserVersion'] as String,
),
calendarEventIds: (json['calendarEventIds'] as Map).map(
(k, v) => MapEntry(k as String, v as String),
),
);
} catch (_) {
throw const FormatException('保存文件内容无效,未覆盖已有数据');
}
}
}
+39
View File
@@ -27,4 +27,43 @@ class Course {
final int weekday, startPeriod, endPeriod;
final Set<int> weeks;
final CourseSource source;
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'teacher': teacher,
'room': room,
'weekday': weekday,
'startPeriod': startPeriod,
'endPeriod': endPeriod,
'weeks': weeks.toList()..sort(),
'source': source.name,
};
static Course fromJson(Map<String, dynamic> json, {CourseSource? source}) {
try {
String text(String key) {
final v = json[key];
if (v is! String) throw const FormatException('课程字段缺失');
return v.trim();
}
final rawWeeks = json['weeks'];
if (rawWeeks is! List || rawWeeks.any((w) => w is! int))
throw const FormatException('周次必须是整数数组');
return Course(
id: text('id'),
name: text('name'),
teacher: text('teacher'),
room: text('room'),
weekday: json['weekday'] as int,
startPeriod: json['startPeriod'] as int,
endPeriod: json['endPeriod'] as int,
weeks: rawWeeks.cast<int>().toSet(),
source: source ?? CourseSource.values.byName(json['source'] as String),
);
} catch (_) {
throw const FormatException('课程字段、节次或周次无效');
}
}
}
+17
View File
@@ -0,0 +1,17 @@
import '../domain/course_occurrence.dart';
class DeviceCalendar {
const DeviceCalendar(this.id, this.name);
final String id, name;
}
abstract class CalendarService {
Future<bool> requestAccess();
Future<List<DeviceCalendar>> listWritableCalendars();
Future<Map<String, String>> sync(
List<CourseOccurrence> occurrences,
String calendarId,
Map<String, String> knownEventIds,
);
Future<void> deleteOwned(Map<String, String> eventIds);
}
@@ -0,0 +1,25 @@
import '../domain/app_settings.dart';
import '../domain/course_occurrence.dart';
enum ReminderStatus { off, permissionDenied, exact, inexact, error }
class ReminderResult {
const ReminderResult(
this.status, {
this.scheduledCount = 0,
this.message = '',
});
final ReminderStatus status;
final int scheduledCount;
final String message;
}
abstract class NotificationService {
Future<ReminderResult> reconcile(
List<CourseOccurrence> occurrences,
AppSettings settings,
DateTime now,
);
Future<void> requestPermissions();
Future<void> cancelOwned();
}
+92
View File
@@ -0,0 +1,92 @@
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/domain/app_snapshot.dart';
import 'package:hepan_everyday/domain/app_settings.dart';
import 'package:hepan_everyday/domain/semester.dart';
import 'package:hepan_everyday/domain/course.dart';
import 'package:hepan_everyday/data/json_schedule_import.dart';
import '../support/fakes.dart';
import '../domain/schedule_engine_test.dart' show course;
void main() {
setUpAll(tzdata.initializeTimeZones);
late MemoryStore store;
late FakeNotifications notifications;
late FakeCalendar calendar;
late AppController controller;
setUp(() {
store = MemoryStore(
AppSnapshot(
courses: [course()],
settings: AppSettings(
semester: Semester(startDate: DateTime(2026, 9, 9), totalWeeks: 5),
),
),
);
notifications = FakeNotifications();
calendar = FakeCalendar();
controller = AppController(
store,
notifications,
calendar,
() => DateTime.utc(2026, 9, 8),
);
});
tearDown(() => controller.dispose());
test('failed saving does not change currently visible settings', () async {
await controller.initialize();
store.failSave = true;
await controller.updateSettings(
controller.snapshot.settings.copyWith(reminderMinutes: 30),
);
expect(controller.snapshot.settings.reminderMinutes, 15);
expect(controller.lastError, isNotNull);
});
test('concurrent settings changes persist in user action order', () async {
await controller.initialize();
await Future.wait([
controller.updateSettings(
controller.snapshot.settings.copyWith(reminderMinutes: 30),
),
controller.updateSettings(
controller.snapshot.settings.copyWith(reminderMinutes: 60),
),
]);
expect(store.value!.settings.reminderMinutes, 60);
});
test('demo cannot replace real courses or create real reminders', () async {
await controller.initialize();
final previous = notifications.reconciliations;
await controller.showDemo();
expect(controller.isDemo, isTrue);
expect(store.value!.courses.single.id, 'math');
expect(notifications.reconciliations, previous);
expect(calendar.synchronizations, 0);
});
test('unconfirmed empty import preserves saved timetable', () async {
await controller.initialize();
await controller.applyImport(
const ImportDraft(
courses: [],
explicitlyEmpty: false,
source: CourseSource.file,
parserVersion: 'json-v1',
),
confirmedEmpty: false,
);
expect(controller.snapshot.courses.single.id, 'math');
expect(controller.lastError, isNotNull);
});
test(
'clearing courses cancels owned notifications and calendar events',
() async {
await controller.initialize();
await controller.clearCourses();
expect(store.value!.courses, isEmpty);
expect(notifications.cancellations, 1);
expect(calendar.deletions, 1);
},
);
}
@@ -0,0 +1,55 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/data/json_schedule_import.dart';
import 'package:hepan_everyday/domain/course.dart';
Map<String, dynamic> row({String id = 'a', int day = 1}) => {
'id': id,
'name': '矩阵论',
'teacher': '老师',
'room': '教室',
'weekday': day,
'startPeriod': 1,
'endPeriod': 2,
'weeks': [1, 3, 5],
};
String input(List<dynamic> courses, {int version = 1, bool demo = false}) =>
jsonEncode({'schemaVersion': version, 'isDemo': demo, 'courses': courses});
void main() {
test('file import normalizes weeks and cannot claim school provenance', () {
final parsed = JsonScheduleImport.parse(input([row()]));
expect(parsed.courses, hasLength(1));
expect(parsed.courses.single.weeks, {1, 3, 5});
expect(parsed.source, CourseSource.file);
});
test('one invalid course rejects the whole draft', () {
expect(
() => JsonScheduleImport.parse(input([row(), row(id: 'b', day: 8)])),
throwsFormatException,
);
});
test('duplicate identifiers and unsupported schema are rejected', () {
expect(
() => JsonScheduleImport.parse(input([row(), row()])),
throwsFormatException,
);
expect(
() => JsonScheduleImport.parse(input([], version: 2)),
throwsFormatException,
);
});
test('demo import is explicitly separated from real records', () {
expect(
JsonScheduleImport.parse(input([row()], demo: true)).source,
CourseSource.demo,
);
});
test('valid but oversized JSON is rejected before accepting a course', () {
final large = row()..['name'] = List.filled(2100000, 'x').join();
expect(
() => JsonScheduleImport.parse(input([large])),
throwsFormatException,
);
});
}
+57
View File
@@ -0,0 +1,57 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/data/file_snapshot_store.dart';
import 'package:hepan_everyday/domain/app_snapshot.dart';
import 'package:hepan_everyday/domain/app_settings.dart';
import '../domain/schedule_engine_test.dart' show course;
void main() {
late Directory directory;
setUp(() async {
directory = await Directory.systemTemp.createTemp('hepan-test-');
});
tearDown(() async {
await directory.delete(recursive: true);
});
test(
'reopening restores courses, settings and calendar identifiers',
() async {
final original = AppSnapshot(
settings: const AppSettings(reminderMinutes: 30),
courses: [course()],
calendarEventIds: {'math/2026-09-09': '17'},
);
await FileSnapshotStore(directory).save(original);
final loaded = await FileSnapshotStore(directory).load();
expect(loaded, isNotNull);
expect(loaded!.courses.single.name, '矩阵论');
expect(loaded.settings.reminderMinutes, 30);
expect(loaded.calendarEventIds, {'math/2026-09-09': '17'});
},
);
test(
'damaged snapshot reports failure instead of returning empty courses',
() async {
await File('${directory.path}/hepan_snapshot.json')
.writeAsString('damaged');
await expectLater(
FileSnapshotStore(directory).load(),
throwsFormatException,
);
},
);
test('valid previous snapshot is available for explicit recovery', () async {
final store = FileSnapshotStore(directory);
await store.save(
AppSnapshot(settings: const AppSettings(reminderMinutes: 15)),
);
await store.save(
AppSnapshot(settings: const AppSettings(reminderMinutes: 30)),
);
await File('${directory.path}/hepan_snapshot.json')
.writeAsString('damaged');
expect((await store.restoreBackup()).settings.reminderMinutes, 15);
});
}
+73
View File
@@ -0,0 +1,73 @@
import 'package:hepan_everyday/data/snapshot_store.dart';
import 'package:hepan_everyday/domain/app_snapshot.dart';
import 'package:hepan_everyday/domain/app_settings.dart';
import 'package:hepan_everyday/domain/course_occurrence.dart';
import 'package:hepan_everyday/services/notification_service.dart';
import 'package:hepan_everyday/services/calendar_service.dart';
class MemoryStore extends SnapshotStore {
MemoryStore([this.value]);
AppSnapshot? value;
bool failSave = false;
@override
Future<AppSnapshot?> load() async => value;
@override
Future<void> save(AppSnapshot snapshot) async {
if (failSave) throw StateError('磁盘写入失败');
value = snapshot;
}
}
class FakeNotifications extends NotificationService {
int reconciliations = 0, cancellations = 0;
bool fail = false;
@override
Future<ReminderResult> reconcile(
List<CourseOccurrence> occurrences,
AppSettings settings,
DateTime now,
) async {
reconciliations++;
if (fail) throw StateError('系统拒绝');
return ReminderResult(
settings.remindersEnabled ? ReminderStatus.exact : ReminderStatus.off,
scheduledCount: occurrences.length,
);
}
@override
Future<void> cancelOwned() async {
cancellations++;
}
@override
Future<void> requestPermissions() async {}
}
class FakeCalendar extends CalendarService {
bool access = true;
int synchronizations = 0, deletions = 0;
@override
Future<bool> requestAccess() async => access;
@override
Future<List<DeviceCalendar>> listWritableCalendars() async => [
const DeviceCalendar('local', '测试日历'),
];
@override
Future<Map<String, String>> sync(
List<CourseOccurrence> occurrences,
String calendarId,
Map<String, String> knownEventIds,
) async {
synchronizations++;
return {
for (final e in occurrences)
e.key: knownEventIds[e.key] ?? 'event-${e.key}',
};
}
@override
Future<void> deleteOwned(Map<String, String> eventIds) async {
deletions++;
}
}