feat: persist timetable and validate confirmed imports
This commit is contained in:
@@ -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('课程字段、节次或周次无效');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user