feat: persist timetable and validate confirmed imports
This commit is contained in:
@@ -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 = '日历同步失败,可重试';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('课表文件格式无效');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('没有可恢复的备份'));
|
||||
}
|
||||
@@ -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('保存文件内容无效,未覆盖已有数据');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('课程字段、节次或周次无效');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user