419 lines
14 KiB
Dart
419 lines
14 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../domain/app_snapshot.dart';
|
|
import '../domain/app_settings.dart';
|
|
import '../data/snapshot_store.dart';
|
|
import '../data/json_schedule_import.dart';
|
|
import '../services/notification_service.dart';
|
|
import '../services/diagnostic_log.dart';
|
|
import '../services/calendar_service.dart';
|
|
import '../domain/schedule_engine.dart';
|
|
import '../data/demo_schedule.dart';
|
|
import '../domain/exam_schedule.dart';
|
|
import '../domain/school_term.dart';
|
|
import '../domain/school_identity.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;
|
|
String? reminderErrorCode;
|
|
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 {
|
|
if (storageReady) {
|
|
lastError = null;
|
|
}
|
|
try {
|
|
await operation();
|
|
} catch (error) {
|
|
lastError = error.toString();
|
|
DiagnosticLog.instance.add(
|
|
'operation_failed',
|
|
fields: {'code': error.runtimeType.toString()},
|
|
);
|
|
}
|
|
notifyListeners();
|
|
});
|
|
_queue = next;
|
|
return next;
|
|
}
|
|
|
|
void _checkStorage() {
|
|
if (!storageReady) throw StateError('请先恢复备份,避免覆盖损坏的保存文件');
|
|
}
|
|
|
|
Future<void> _save(AppSnapshot next) async {
|
|
_checkStorage();
|
|
AppSnapshot.validateCourseSource(next.courses, next.schoolTerm);
|
|
await store.save(next);
|
|
snapshot = next;
|
|
lastError = null;
|
|
DiagnosticLog.instance.add(
|
|
'snapshot_saved',
|
|
fields: {'count': next.courses.length, 'term': next.schoolTerm?.id},
|
|
);
|
|
}
|
|
|
|
Future<void> initialize({bool reconcileServices = true}) => _run(() async {
|
|
try {
|
|
snapshot = await store.load() ?? AppSnapshot();
|
|
storageReady = true;
|
|
} catch (_) {
|
|
storageReady = false;
|
|
throw const FormatException('保存文件读取失败,可恢复最近备份');
|
|
}
|
|
if (reconcileServices) 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<bool> selectSchool(SupportedSchool school) async {
|
|
var saved = false;
|
|
await _run(() async {
|
|
await _save(snapshot.copyWith(selectedSchool: school));
|
|
saved = true;
|
|
});
|
|
return saved;
|
|
}
|
|
|
|
Future<void> updateSettings(
|
|
AppSettings Function(AppSettings current) change,
|
|
) => _run(() async {
|
|
final previous = snapshot.settings;
|
|
final value = change(previous);
|
|
if (value.reminderOffsets.isEmpty ||
|
|
value.reminderOffsets.any(
|
|
(v) => !AppSettings.allowedReminderMinutes.contains(v),
|
|
)) {
|
|
throw ArgumentError('提醒时间无效');
|
|
}
|
|
if (!value.courseHeightScale.isFinite ||
|
|
value.courseHeightScale < AppSettings.minCourseHeightScale ||
|
|
value.courseHeightScale > AppSettings.maxCourseHeightScale) {
|
|
throw ArgumentError('课程条长度无效');
|
|
}
|
|
await _save(snapshot.copyWith(settings: value));
|
|
if (isDemo) {
|
|
_demoSettings = _demoSettings!.copyWith(
|
|
colorMode: value.colorMode,
|
|
singleColorArgb: value.singleColorArgb,
|
|
courseHeightScale: value.courseHeightScale,
|
|
);
|
|
}
|
|
final scheduleChanged =
|
|
previous.semester != value.semester ||
|
|
previous.remindersEnabled != value.remindersEnabled ||
|
|
!listEquals(previous.reminderOffsets, value.reminderOffsets) ||
|
|
previous.calendarEnabled != value.calendarEnabled ||
|
|
previous.calendarId != value.calendarId;
|
|
if (scheduleChanged) await _reconcile();
|
|
});
|
|
Future<void> applyImport(
|
|
ImportDraft draft, {
|
|
required bool confirmedEmpty,
|
|
bool Function()? canApply,
|
|
}) => _run(() async {
|
|
if (canApply != null && !canApply()) throw StateError('学校页面已变化,请重新导入。');
|
|
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('课程来源不一致');
|
|
}
|
|
if (draft.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
|
|
draft.courses.any((c) => c.endPeriod > 11)) {
|
|
throw const FormatException('第12节需要本科课表来源');
|
|
}
|
|
await _save(
|
|
snapshot.copyWith(
|
|
courses: draft.courses,
|
|
schoolTerm: draft.schoolTerm,
|
|
clearExams:
|
|
draft.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
|
|
snapshot.examSchedule?.school !=
|
|
SupportedSchool.uestcUndergraduate &&
|
|
_differentTerm(draft.schoolTerm),
|
|
settings: _differentTerm(draft.schoolTerm)
|
|
? snapshot.settings.copyWith(clearSemester: true)
|
|
: snapshot.settings,
|
|
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,
|
|
calendarCleanupPending: true,
|
|
),
|
|
);
|
|
isDemo = false;
|
|
try {
|
|
await notifications.cancelOwned();
|
|
} catch (_) {
|
|
reminderResult = const ReminderResult(
|
|
ReminderStatus.error,
|
|
message: '提醒清理失败,可重试',
|
|
);
|
|
}
|
|
await _cleanupCalendar();
|
|
});
|
|
Future<void> applyExams(ExamSchedule value, {bool Function()? canApply}) =>
|
|
_run(() async {
|
|
if (canApply != null && !canApply()) throw StateError('学校查询已取消,请重新读取。');
|
|
final validated = ExamSchedule.fromJson(value.toJson());
|
|
if (validated.school == SupportedSchool.uestcUndergraduate) {
|
|
await _save(snapshot.copyWith(examSchedule: validated));
|
|
return;
|
|
}
|
|
final term = SchoolTerm(int.parse(validated.termId));
|
|
final changed = _differentTerm(term);
|
|
await _save(
|
|
snapshot.copyWith(
|
|
examSchedule: validated,
|
|
schoolTerm: term,
|
|
courses: changed ? [] : null,
|
|
clearMetadata: changed,
|
|
settings: changed
|
|
? snapshot.settings.copyWith(clearSemester: true)
|
|
: null,
|
|
),
|
|
);
|
|
if (changed) {
|
|
isDemo = false;
|
|
await _reconcile();
|
|
}
|
|
});
|
|
bool _differentTerm(SchoolTerm? next) =>
|
|
next != null &&
|
|
((snapshot.schoolTerm != null && snapshot.schoolTerm != next) ||
|
|
(snapshot.schoolTerm == null &&
|
|
(snapshot.courses.isNotEmpty || snapshot.examSchedule != null)));
|
|
Future<void> clearExams() => _run(() async {
|
|
await _save(snapshot.copyWith(clearExams: true));
|
|
});
|
|
Future<void> disableCalendar({required bool deleteEvents}) => _run(() async {
|
|
await _save(
|
|
snapshot.copyWith(
|
|
settings: snapshot.settings.copyWith(calendarEnabled: false),
|
|
calendarCleanupPending: deleteEvents || snapshot.calendarCleanupPending,
|
|
),
|
|
);
|
|
if (deleteEvents) {
|
|
await _cleanupCalendar();
|
|
}
|
|
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;
|
|
}
|
|
if (snapshot.calendarCleanupPending) {
|
|
await _cleanupCalendar();
|
|
}
|
|
final calendars = await calendar.listWritableCalendars();
|
|
if (!calendars.any((c) => c.id == calendarId)) {
|
|
calendarSyncError = '请选择可写入的日历';
|
|
return;
|
|
}
|
|
final settings = snapshot.settings.copyWith(
|
|
calendarEnabled: true,
|
|
calendarId: calendarId,
|
|
);
|
|
// Keep the chosen calendar for recovery if the first native sync fails midway.
|
|
await _save(
|
|
snapshot.copyWith(
|
|
settings: settings.copyWith(calendarEnabled: false),
|
|
calendarCleanupPending: true,
|
|
calendarEventIds: snapshot.settings.calendarId == calendarId
|
|
? snapshot.calendarEventIds
|
|
: {},
|
|
),
|
|
);
|
|
try {
|
|
final ids = await calendar.sync(
|
|
ScheduleEngine.occurrences(snapshot.courses, settings.semester!),
|
|
calendarId,
|
|
snapshot.calendarEventIds,
|
|
);
|
|
await _save(
|
|
snapshot.copyWith(
|
|
settings: settings,
|
|
calendarEventIds: ids,
|
|
calendarCleanupPending: false,
|
|
),
|
|
);
|
|
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> startFreshData() => _run(() async {
|
|
if (storageReady) {
|
|
throw StateError('保存文件正常,无需重新建立');
|
|
}
|
|
snapshot = await store.startFresh();
|
|
storageReady = true;
|
|
isDemo = false;
|
|
lastError = null;
|
|
await _reconcile();
|
|
});
|
|
Future<void> retryCalendarCleanup() => _run(() async {
|
|
if (await calendar.requestAccess()) {
|
|
await _cleanupCalendar();
|
|
} else {
|
|
calendarSyncError = '日历权限未获允许,课程日程尚未清理';
|
|
}
|
|
});
|
|
Future<void> _cleanupCalendar() async {
|
|
try {
|
|
await calendar.deleteOwned(snapshot.calendarEventIds);
|
|
final calendarId = snapshot.settings.calendarId;
|
|
if (calendarId != null) {
|
|
await calendar.deleteOwned(await calendar.ownedEventIds(calendarId));
|
|
}
|
|
await _save(
|
|
snapshot.copyWith(calendarEventIds: {}, calendarCleanupPending: false),
|
|
);
|
|
calendarSyncError = null;
|
|
} catch (_) {
|
|
calendarSyncError = '课程日程尚未清理,请允许日历访问后重试';
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> _reconcile() async {
|
|
if (!storageReady) {
|
|
return;
|
|
}
|
|
final settings = snapshot.settings;
|
|
final occurrences = settings.semester == null
|
|
? <CourseOccurrence>[]
|
|
: ScheduleEngine.occurrences(snapshot.courses, settings.semester!);
|
|
try {
|
|
reminderResult = await notifications.reconcile(
|
|
occurrences,
|
|
settings,
|
|
clock(),
|
|
);
|
|
reminderErrorCode = null;
|
|
DiagnosticLog.instance.add(
|
|
'reminders_reconciled',
|
|
fields: {
|
|
'count': occurrences.length,
|
|
'status': reminderResult.status.name,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
reminderErrorCode = error is ReminderSetupException
|
|
? error.code
|
|
: error is PlatformException
|
|
? error.code
|
|
: error.runtimeType.toString();
|
|
DiagnosticLog.instance.add(
|
|
'reminders_failed',
|
|
fields: {'code': error.runtimeType.toString()},
|
|
);
|
|
reminderResult = ReminderResult(
|
|
ReminderStatus.error,
|
|
message: error is ReminderSetupException
|
|
? error.message
|
|
: '提醒设置未完成,请点击“检查提醒”后重试。',
|
|
);
|
|
}
|
|
if (snapshot.calendarCleanupPending) {
|
|
try {
|
|
await _cleanupCalendar();
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
}
|
|
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 = '日历同步失败,可重试';
|
|
}
|
|
}
|
|
}
|
|
}
|