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 = '日历同步失败,可重试';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user