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
+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('课程字段、节次或周次无效');
}
}
}