diff --git a/lib/page/classtable/classtable_state.dart b/lib/page/classtable/classtable_state.dart index 68ec599a..7be01c0c 100644 --- a/lib/page/classtable/classtable_state.dart +++ b/lib/page/classtable/classtable_state.dart @@ -6,21 +6,19 @@ import 'dart:math' as math; import 'package:device_calendar/device_calendar.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:signals/signals.dart'; -import 'package:timezone/data/latest.dart' as tz; import 'package:watermeter/controller/classtable_controller.dart'; import 'package:watermeter/controller/exam_controller.dart'; -import 'package:watermeter/controller/physics_experiment_controller.dart'; import 'package:watermeter/controller/other_experiment_controller.dart'; +import 'package:watermeter/controller/physics_experiment_controller.dart'; import 'package:watermeter/controller/week_swift_controller.dart'; -import 'package:watermeter/model/time_list.dart'; import 'package:watermeter/model/xidian_ids/classtable.dart'; import 'package:watermeter/model/xidian_ids/exam.dart'; import 'package:watermeter/model/xidian_ids/experiment.dart'; import 'package:watermeter/page/classtable/class_table_view/class_organized_data.dart'; import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/system_calendar_sync_service.dart'; import 'package:watermeter/themes/color_seed.dart'; /// Use a inheritedWidget to share the ClassTableWidgetState @@ -304,285 +302,35 @@ class ClassTableWidgetState with ChangeNotifier { .deleteUserDefinedClass(timeArrangement) .then((value) => notifyListeners()); - List get events { - List events = []; - - // UTC+8 timezone defination, hard-coded since our school is in here... - tz.initializeTimeZones(); - Location currentLocation = getLocation("Asia/Shanghai"); - - // @hgh: i here means each single course assignment - for (var i in timeArrangement) { - // @hgh: j here means each week for a single course assignment - // @hgh: find the first week that has class - int j = i.weekList.indexWhere((element) => element); - - // @benderblog: basically not happens, but if not arranged, just skip it. - if (j == -1) continue; - - // @benderblog: rewrite, using ai generated algorithm to get the ranges of "true". - List<(int, int)> ranges = []; - int start = -1; - - for (j; j < i.weekList.length; j++) { - if (i.weekList[j] && start == -1) { - start = j; - } else if (!i.weekList[j] && start != -1) { - ranges.add((start, j - 1)); - start = -1; - } - } - - // @ai: Handle the case where the array ends with a sequence of true values. - if (start != -1) { - ranges.add((start, j - 1)); - } - - String title = - "${getClassDetail(timeArrangement.indexOf(i)).name}@${i.classroom ?? "待定"}"; - String description = - "课程名称:${getClassDetail(timeArrangement.indexOf(i)).name} - 老师:${i.teacher ?? "未知"}"; - String? location = i.classroom ?? "待定"; - - List startTime = timeList[(i.start - 1) * 2].split(":"); - List stopTime = timeList[(i.stop - 1) * 2 + 1].split(":"); - - DayOfWeek getDayOfWeek(int day) { - switch (day) { - case 1: - return DayOfWeek.Monday; - case 2: - return DayOfWeek.Tuesday; - case 3: - return DayOfWeek.Wednesday; - case 4: - return DayOfWeek.Thursday; - case 5: - return DayOfWeek.Friday; - case 6: - return DayOfWeek.Saturday; - case 7: - return DayOfWeek.Sunday; - default: - return DayOfWeek.Sunday; - } - } - - // @benderblog: start dealing with - for (var range in ranges) { - // @hgh: initialize the first day(or, first recurrence) of the class - DateTime firstDay = startDay.add( - Duration(days: range.$1 * 7 + i.day - 1), - ); - - DateTime startTimeToUse = firstDay.add( - Duration( - hours: int.parse(startTime[0]), - minutes: int.parse(startTime[1]), - ), - ); - DateTime stopTimeToUse = firstDay.add( - Duration( - hours: int.parse(stopTime[0]), - minutes: int.parse(stopTime[1]), - ), - ); - - RecurrenceRule rrule = RecurrenceRule( - RecurrenceFrequency.Weekly, - daysOfWeek: [getDayOfWeek(i.day)], - endDate: firstDay.add(Duration(days: (range.$2 - range.$1) * 7 + 1)), - ); - - events.add( - Event( - null, - title: title, - description: description, - recurrenceRule: rrule, - start: TZDateTime.from(startTimeToUse, currentLocation), - end: TZDateTime.from(stopTimeToUse, currentLocation), - location: location, - ), - ); - } - } - - // Add exam data and experiment data here. - for (var i in subjects) { - String title = - "${i.subject} ${i.typeStr}@${i.place} ${i.seat != null ? "-${i.seat}" : ""}"; - String description = "考试信息:${i.subject} - ${i.typeStr}"; - String location = "${i.place} ${i.seat != null ? "-${i.seat}" : ""}"; - - events.add( - Event( - null, - title: title, - description: description, - start: TZDateTime.from(i.startTime!, currentLocation), - end: TZDateTime.from(i.stopTime!, currentLocation), - location: location, - ), - ); - } - - for (var experiment in experiments) { - for (var j in experiment.timeRanges) { - events.add( - Event( - null, - title: "${experiment.name}@${experiment.classroom}", - description: "实验名称:${experiment.name} - 老师:${experiment.teacher}", - start: TZDateTime.from(j.$1, currentLocation), - end: TZDateTime.from(j.$2, currentLocation), - location: experiment.classroom, - ), - ); - } - } - return events; - } + List get events => buildCalendarEvents( + classTableData: classTableController.classTableComputedSignal.value, + subjects: subjects, + experiments: experiments, + ); /// Generate icalendar file string. - String get iCalenderStr { - String toReturn = '''BEGIN:VCALENDAR -CALSCALE:GREGORIAN -BEGIN:VTIMEZONE -TZID:Asia/Shanghai -X-LIC-LOCATION:Asia/Shanghai -BEGIN:STANDARD -TZOFFSETFROM:+0800 -TZOFFSETTO:+0800 -TZNAME:CST -DTSTART:19700101T000000 -END:STANDARD -END:VTIMEZONE -'''; - - for (var i in events) { - String vevent = "BEGIN:VEVENT\n"; - - vevent += - "DTSTAMP:" - "${DateFormat('yyyyMMddTHHmmssZ').format(DateTime.now())}\n"; - vevent += "SUMMARY:${i.title ?? "待定"}\n"; - vevent += "DESCRIPTION:${i.description ?? "待定"}\n"; - - /// Minus 8 hours to match the "UTC time" - vevent += - "DTSTART;TZID=Asia/Shanghai:" - "${DateFormat('yyyyMMddTHHmmss').format(DateTime.fromMicrosecondsSinceEpoch(i.start!.microsecondsSinceEpoch))}\n"; - vevent += - "DTEND;TZID=Asia/Shanghai:" - "${DateFormat('yyyyMMddTHHmmss').format(DateTime.fromMicrosecondsSinceEpoch(i.end!.microsecondsSinceEpoch))}\n"; - if (i.location != null) { - vevent += "LOCATION:${i.location}\n"; - } - - if (i.recurrenceRule != null) { - String getWeekStr(DayOfWeek day) { - switch (day) { - case DayOfWeek.Monday: - return "MO"; - case DayOfWeek.Tuesday: - return "TU"; - case DayOfWeek.Wednesday: - return "WE"; - case DayOfWeek.Thursday: - return "TH"; - case DayOfWeek.Friday: - return "FR"; - case DayOfWeek.Saturday: - return "SA"; - case DayOfWeek.Sunday: - return "SU"; - } - } - - vevent += - "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=${getWeekStr(i.recurrenceRule!.daysOfWeek!.first)};" - "UNTIL=${DateFormat('yyyyMMdd').format(i.recurrenceRule!.endDate!)}\n"; - } - toReturn += "${vevent}END:VEVENT\n"; - } - - return "${toReturn}END:VCALENDAR"; - } + String get iCalenderStr => buildICalendarString(events); /// Output to System Calendar Future outputToCalendar(Future Function() showDialog) async { - // Fetch calendar permission - final DeviceCalendarPlugin deviceCalendarPlugin = DeviceCalendarPlugin(); - Result hasPermitted = await deviceCalendarPlugin.hasPermissions(); - if (hasPermitted.data != true) { - await showDialog(); - hasPermitted = await deviceCalendarPlugin.requestPermissions(); - if (hasPermitted.data != true) { - log.info( - "[Classtable][outputToCalendar] " - "Gain permission failed: " - "${hasPermitted.errors.map((e) => e.errorMessage).join(",")}", - ); - return false; - } - } - - // Generate a new calendar - (bool, String) calendarIdData = await DeviceCalendarPlugin() - .createCalendar( - "PDA Class Arrangement $semesterCode " - "created at ${DateTime.now().millisecondsSinceEpoch}", - ) - .then((data) { - if (!data.isSuccess) { - log.info( - "[Classtable][outputToCalendar] " - "Generate new calendar failed: " - "${hasPermitted.errors.map((e) => e.errorMessage).join(",")}", - ); - return (false, ""); - } else { - return (true, data.data!); - } - }); - - if (!calendarIdData.$1) { - return false; - } - String calendarId = calendarIdData.$2; - - for (var i in events) { - var toPush = i..calendarId = calendarId; - Result? addEventResult = await deviceCalendarPlugin - .createOrUpdateEvent(toPush); - // If got error, return with false. - if (addEventResult == null || - addEventResult.data == null || - addEventResult.data!.isEmpty) { - log.info( - "[Classtable][outputToCalendar] " - "Add failed: " - "${hasPermitted.errors.map((e) => e.errorMessage).join(",")}", - ); - return false; - } - } - - return true; + return await SystemCalendarSyncService().syncSystemCalendar( + requestPermissionsIfNeeded: true, + onlyIfCalendarExists: false, + showDialog: showDialog, + ); } /// Update classtable infos Future updateClasstable(BuildContext context) async { log.info("Updating time arrangement data..."); - return await Future.wait([ + await Future.wait([ classTableController.reloadClassTable(), examController.reloadExamInfo(), physicsExperimentController.reloadPhysicsExperiment(), otherExperimentController.reloadOtherExperiment(), - ]).then((value) { - notifyListeners(); - }); + ]); + await maybeAutoSyncSystemCalendar(); + notifyListeners(); } ClassTableWidgetState({required this.currentWeek}) { diff --git a/lib/page/homepage/refresh.dart b/lib/page/homepage/refresh.dart index d5a41b33..79d82102 100644 --- a/lib/page/homepage/refresh.dart +++ b/lib/page/homepage/refresh.dart @@ -15,6 +15,7 @@ import 'package:watermeter/controller/schoolnet_controller.dart'; import 'package:watermeter/controller/semester_controller.dart'; import 'package:watermeter/repository/logger.dart'; import 'package:watermeter/repository/notification/course_reminder_service.dart'; +import 'package:watermeter/repository/system_calendar_sync_service.dart'; import 'package:watermeter/repository/xidian_ids/ids_session.dart'; Future _comboLogin({ @@ -81,9 +82,13 @@ Future update({ ), _safeReload("Library", LibraryController.i.reloadBorrowList), _safeReload("SchoolCard", SchoolCardController.i.reloadOverview), - _safeReload("Electricity", ElectricityController.i.refreshElectricityInfo), + _safeReload( + "Electricity", + ElectricityController.i.refreshElectricityInfo, + ), _safeReload("Schoolnet", SchoolnetController.i.reloadSchoolnetInfo), ]); + await maybeAutoSyncSystemCalendar(); if (CourseReminderService().isInitialized) { CourseReminderService().validateAndUpdateNotifications(); diff --git a/lib/page/setting/setting.dart b/lib/page/setting/setting.dart index c523b6cf..6a5dc350 100644 --- a/lib/page/setting/setting.dart +++ b/lib/page/setting/setting.dart @@ -4,6 +4,7 @@ // Setting window. +import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; @@ -40,6 +41,7 @@ import 'package:watermeter/page/setting/about_page/about_page.dart'; import 'package:watermeter/page/setting/dialogs/experiment_password_dialog.dart'; import 'package:watermeter/repository/pick_file.dart'; import 'package:watermeter/repository/preference.dart' as preference; +import 'package:watermeter/repository/system_calendar_sync_service.dart'; import 'package:watermeter/page/setting/dialogs/electricity_password_dialog.dart'; import 'package:watermeter/page/setting/dialogs/sport_password_dialog.dart'; import 'package:watermeter/page/setting/dialogs/change_swift_dialog.dart'; @@ -84,6 +86,22 @@ class _SettingWindowState extends State { } } + bool get _isSemesterAwareControllerLoading => + ClassTableController.i.schoolClassTableStateSignal.value.isLoading || + ExamController.i.examInfoStateSignal.value.isLoading || + PhysicsExperimentController.i.physicsExperimentStateSignal.value + .isLoading || + OtherExperimentController.i.otherExperimentStateSignal.value.isLoading; + + Future _waitForSemesterAwareReloads() async { + await Future.delayed(const Duration(milliseconds: 100)); + final stopwatch = Stopwatch()..start(); + while (_isSemesterAwareControllerLoading && + stopwatch.elapsed < const Duration(seconds: 30)) { + await Future.delayed(const Duration(milliseconds: 100)); + } + } + @override Widget build(BuildContext context) { List demoBlueModeName = [ @@ -254,7 +272,8 @@ class _SettingWindowState extends State { subtitle: Text( FlutterI18n.translate( context, - "setting.change_color_dialog.${ColorSeed.values[preference.getInt(preference.Preference.color)].label}", + "setting.change_color_dialog." + "${ColorSeed.values[preference.getInt(preference.Preference.color)].label}", ), ), trailing: const Icon(Icons.navigate_next), @@ -737,6 +756,7 @@ class _SettingWindowState extends State { OtherExperimentController.i .reloadOtherExperiment(), ]); + await maybeAutoSyncSystemCalendar(); if (mounted) { setState(() {}); } @@ -801,9 +821,17 @@ class _SettingWindowState extends State { barrierDismissible: false, context: context, builder: (context) => SemesterSwitchDialog(), - ).then((value) { + ).then((value) async { if (value == true) { setState(() {}); + if (context.mounted) { + showToast(context: context, msg: "Updating data"); + } + await _waitForSemesterAwareReloads(); + await maybeAutoSyncSystemCalendar(); + if (mounted) { + setState(() {}); + } } }); }, diff --git a/lib/repository/preference.dart b/lib/repository/preference.dart index 59cdf4dd..4a6a951c 100644 --- a/lib/repository/preference.dart +++ b/lib/repository/preference.dart @@ -89,7 +89,16 @@ enum Preference { ), // 上次通知使用的语言 dormWaterToken(key: "dorm_water_token", type: "String"), // 宿舍水机登录 token dormWaterUid(key: "dorm_water_uid", type: "String"), // 宿舍水机用户 uid - dormWaterEid(key: "dorm_water_eid", type: "String"); // 宿舍水机用户 eid + dormWaterEid(key: "dorm_water_eid", type: "String"), // 宿舍水机用户 eid + systemCalendarId(key: "system_calendar_id", type: "String"), // 导出的系统日历 id + systemCalendarSemesterCode( + key: "system_calendar_semester_code", + type: "String", + ), // 导出的系统日历绑定学期 + systemCalendarSnapshot( + key: "system_calendar_snapshot", + type: "String", + ); // 上次同步到系统日历的数据快照 const Preference({required this.key, this.type = "String"}); diff --git a/lib/repository/system_calendar_sync_service.dart b/lib/repository/system_calendar_sync_service.dart new file mode 100644 index 00000000..38252c8e --- /dev/null +++ b/lib/repository/system_calendar_sync_service.dart @@ -0,0 +1,553 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 OR Apache-2.0 + +import 'dart:convert'; + +import 'package:device_calendar/device_calendar.dart'; +import 'package:intl/intl.dart'; +import 'package:timezone/data/latest.dart' as tz; +import 'package:watermeter/controller/classtable_controller.dart'; +import 'package:watermeter/controller/exam_controller.dart'; +import 'package:watermeter/controller/other_experiment_controller.dart'; +import 'package:watermeter/controller/physics_experiment_controller.dart'; +import 'package:watermeter/model/time_list.dart'; +import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/model/xidian_ids/exam.dart'; +import 'package:watermeter/model/xidian_ids/experiment.dart'; +import 'package:watermeter/repository/logger.dart'; +import 'package:watermeter/repository/preference.dart' as preference; + +const String exportedClassTableCalendarPrefix = 'PDA Class Arrangement'; + +/// Build a stable calendar name for exported classtable events. +String buildExportedClassTableCalendarName(String semesterCode) => + semesterCode.isEmpty + ? exportedClassTableCalendarPrefix + : '$exportedClassTableCalendarPrefix $semesterCode'; + +/// Build a stable snapshot used to decide whether auto sync is needed. +String buildSystemCalendarSnapshot({ + required ClassTableData classTableData, + required List subjects, + required List experiments, +}) => jsonEncode({ + 'classTableData': classTableData.toJson(), + 'subjects': subjects.map((item) => item.toJson()).toList(), + 'experiments': experiments.map((item) => item.toJson()).toList(), +}); + +List buildCalendarEvents({ + required ClassTableData classTableData, + required List subjects, + required List experiments, +}) { + List events = []; + + tz.initializeTimeZones(); + Location currentLocation = getLocation('Asia/Shanghai'); + + if (classTableData.termStartDay.isNotEmpty) { + DateTime startDay = DateTime.parse(classTableData.termStartDay); + + for (var i in classTableData.timeArrangement) { + int j = i.weekList.indexWhere((element) => element); + + if (j == -1) continue; + + List<(int, int)> ranges = []; + int start = -1; + + for (j; j < i.weekList.length; j++) { + if (i.weekList[j] && start == -1) { + start = j; + } else if (!i.weekList[j] && start != -1) { + ranges.add((start, j - 1)); + start = -1; + } + } + + if (start != -1) { + ranges.add((start, j - 1)); + } + + String title = + '${classTableData.getClassDetail(i).name}@${i.classroom ?? "待定"}'; + String description = + '课程名称:${classTableData.getClassDetail(i).name} - 老师:${i.teacher ?? "未知"}'; + String? location = i.classroom ?? '待定'; + + List startTime = timeList[(i.start - 1) * 2].split(':'); + List stopTime = timeList[(i.stop - 1) * 2 + 1].split(':'); + + DayOfWeek getDayOfWeek(int day) { + switch (day) { + case 1: + return DayOfWeek.Monday; + case 2: + return DayOfWeek.Tuesday; + case 3: + return DayOfWeek.Wednesday; + case 4: + return DayOfWeek.Thursday; + case 5: + return DayOfWeek.Friday; + case 6: + return DayOfWeek.Saturday; + case 7: + return DayOfWeek.Sunday; + default: + return DayOfWeek.Sunday; + } + } + + for (var range in ranges) { + DateTime firstDay = startDay.add( + Duration(days: range.$1 * 7 + i.day - 1), + ); + + DateTime startTimeToUse = firstDay.add( + Duration( + hours: int.parse(startTime[0]), + minutes: int.parse(startTime[1]), + ), + ); + DateTime stopTimeToUse = firstDay.add( + Duration( + hours: int.parse(stopTime[0]), + minutes: int.parse(stopTime[1]), + ), + ); + + RecurrenceRule rrule = RecurrenceRule( + RecurrenceFrequency.Weekly, + daysOfWeek: [getDayOfWeek(i.day)], + endDate: firstDay.add(Duration(days: (range.$2 - range.$1) * 7 + 1)), + ); + + events.add( + Event( + null, + title: title, + description: description, + recurrenceRule: rrule, + start: TZDateTime.from(startTimeToUse, currentLocation), + end: TZDateTime.from(stopTimeToUse, currentLocation), + location: location, + ), + ); + } + } + } + + for (var i in subjects) { + String title = + '${i.subject} ${i.typeStr}@${i.place} ${i.seat != null ? "-${i.seat}" : ""}'; + String description = '考试信息:${i.subject} - ${i.typeStr}'; + String location = '${i.place} ${i.seat != null ? "-${i.seat}" : ""}'; + + events.add( + Event( + null, + title: title, + description: description, + start: TZDateTime.from(i.startTime!, currentLocation), + end: TZDateTime.from(i.stopTime!, currentLocation), + location: location, + ), + ); + } + + for (var experiment in experiments) { + for (var j in experiment.timeRanges) { + events.add( + Event( + null, + title: '${experiment.name}@${experiment.classroom}', + description: '实验名称:${experiment.name} - 老师:${experiment.teacher}', + start: TZDateTime.from(j.$1, currentLocation), + end: TZDateTime.from(j.$2, currentLocation), + location: experiment.classroom, + ), + ); + } + } + + return events; +} + +String buildICalendarString(List events) { + String toReturn = '''BEGIN:VCALENDAR +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Asia/Shanghai +X-LIC-LOCATION:Asia/Shanghai +BEGIN:STANDARD +TZOFFSETFROM:+0800 +TZOFFSETTO:+0800 +TZNAME:CST +DTSTART:19700101T000000 +END:STANDARD +END:VTIMEZONE +'''; + + for (var i in events) { + String vevent = 'BEGIN:VEVENT\n'; + + vevent += + 'DTSTAMP:${DateFormat('yyyyMMddTHHmmssZ').format(DateTime.now())}\n'; + vevent += 'SUMMARY:${i.title ?? "待定"}\n'; + vevent += 'DESCRIPTION:${i.description ?? "待定"}\n'; + + vevent += + 'DTSTART;TZID=Asia/Shanghai:' + '${DateFormat('yyyyMMddTHHmmss').format( + DateTime.fromMicrosecondsSinceEpoch(i.start!.microsecondsSinceEpoch), + )}\n'; + vevent += + 'DTEND;TZID=Asia/Shanghai:' + '${DateFormat('yyyyMMddTHHmmss').format( + DateTime.fromMicrosecondsSinceEpoch(i.end!.microsecondsSinceEpoch), + )}\n'; + if (i.location != null) { + vevent += 'LOCATION:${i.location}\n'; + } + + if (i.recurrenceRule != null) { + String getWeekStr(DayOfWeek day) { + switch (day) { + case DayOfWeek.Monday: + return 'MO'; + case DayOfWeek.Tuesday: + return 'TU'; + case DayOfWeek.Wednesday: + return 'WE'; + case DayOfWeek.Thursday: + return 'TH'; + case DayOfWeek.Friday: + return 'FR'; + case DayOfWeek.Saturday: + return 'SA'; + case DayOfWeek.Sunday: + return 'SU'; + } + } + + vevent += + 'RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=${getWeekStr(i.recurrenceRule!.daysOfWeek!.first)};' + 'UNTIL=${DateFormat('yyyyMMdd').format(i.recurrenceRule!.endDate!)}\n'; + } + toReturn += '${vevent}END:VEVENT\n'; + } + + return '${toReturn}END:VCALENDAR'; +} + +class SystemCalendarSyncService { + final DeviceCalendarPlugin deviceCalendarPlugin = DeviceCalendarPlugin(); + + ClassTableController get classTableController => ClassTableController.i; + ExamController get examController => ExamController.i; + PhysicsExperimentController get physicsExperimentController => + PhysicsExperimentController.i; + OtherExperimentController get otherExperimentController => + OtherExperimentController.i; + + String get currentSemesterCode => + classTableController.classTableComputedSignal.value.semesterCode; + + String get savedCalendarId => + preference.getString(preference.Preference.systemCalendarId); + + String get savedCalendarSemesterCode => + preference.getString(preference.Preference.systemCalendarSemesterCode); + + String get calendarName => buildExportedClassTableCalendarName( + currentSemesterCode, + ); + + List get experiments => [ + ...physicsExperimentController.physicsExperiments.value, + ...otherExperimentController.otherExperiments.value, + ]; + + List get events => buildCalendarEvents( + classTableData: classTableController.classTableComputedSignal.value, + subjects: examController.subjects.value, + experiments: experiments, + ); + + String get snapshot => buildSystemCalendarSnapshot( + classTableData: classTableController.classTableComputedSignal.value, + subjects: examController.subjects.value, + experiments: experiments, + ); + + bool get hasCalendarBinding => savedCalendarId.isNotEmpty; + + bool get isBoundCalendarForCurrentSemester => + savedCalendarSemesterCode.isNotEmpty && + savedCalendarSemesterCode == currentSemesterCode; + + bool get didSemesterChangeSinceLastBinding => + savedCalendarSemesterCode.isNotEmpty && + currentSemesterCode.isNotEmpty && + savedCalendarSemesterCode != currentSemesterCode; + + bool get canAutoSync => + hasCalendarBinding && + (didSemesterChangeSinceLastBinding || + preference.getString(preference.Preference.systemCalendarSnapshot) != + snapshot) && + classTableController.hasValidClassInfo.value; + + Future _ensureCalendarPermission({ + required bool requestPermissionsIfNeeded, + Future Function()? showDialog, + }) async { + Result hasPermitted = await deviceCalendarPlugin.hasPermissions(); + if (hasPermitted.data == true) { + return true; + } + + if (!requestPermissionsIfNeeded) { + return false; + } + + if (showDialog != null) { + await showDialog(); + } + hasPermitted = await deviceCalendarPlugin.requestPermissions(); + return hasPermitted.data == true; + } + + Future _saveCalendarBinding(String calendarId) async { + await preference.setString( + preference.Preference.systemCalendarId, + calendarId, + ); + await preference.setString( + preference.Preference.systemCalendarSemesterCode, + currentSemesterCode, + ); + } + + Future _saveCalendarSyncState(String calendarId) async { + await _saveCalendarBinding(calendarId); + await preference.setString( + preference.Preference.systemCalendarSnapshot, + snapshot, + ); + } + + Future _clearCalendarBinding() async { + await preference.remove(preference.Preference.systemCalendarId); + await preference.remove(preference.Preference.systemCalendarSemesterCode); + } + + Future?> _retrieveWritableCalendars() async { + final calendarResult = await deviceCalendarPlugin.retrieveCalendars(); + if (!calendarResult.isSuccess || calendarResult.data == null) { + log.info( + '[SystemCalendarSyncService][_retrieveWritableCalendars] ' + 'Retrieve calendars failed.', + ); + return null; + } + + return calendarResult.data! + .where((calendar) => calendar.isReadOnly != true) + .toList(); + } + + Future _findBoundCalendar(List calendars) async { + if (!hasCalendarBinding) { + return null; + } + + for (var calendar in calendars) { + if (calendar.id != savedCalendarId) { + continue; + } + + if (isBoundCalendarForCurrentSemester) { + return calendar; + } + + // Migrate older bindings that do not persist the semester code yet. + if (savedCalendarSemesterCode.isEmpty && + calendar.name == calendarName && + calendar.id != null && + calendar.id!.isNotEmpty) { + await _saveCalendarBinding(calendar.id!); + return calendar; + } + + log.info( + '[SystemCalendarSyncService][_findBoundCalendar] ' + 'Skip maintaining bound calendar from semester ' + '$savedCalendarSemesterCode while current semester is ' + '$currentSemesterCode.', + ); + return null; + } + + await _clearCalendarBinding(); + return null; + } + + /// Find the exported calendar by saved id or the current fixed name. + Future _findExportedCalendar() async { + final calendars = await _retrieveWritableCalendars(); + if (calendars == null) { + return null; + } + + final hadCalendarBinding = hasCalendarBinding; + final boundCalendar = await _findBoundCalendar(calendars); + if (boundCalendar != null) { + return boundCalendar; + } + // Treat a missing bound calendar as an explicit user opt-out, so auto sync + // does not resurrect the export by falling back to name lookup. + if (hadCalendarBinding && !hasCalendarBinding) { + return null; + } + + for (var calendar in calendars) { + if (calendar.name == calendarName && + calendar.id != null && + calendar.id!.isNotEmpty) { + await _saveCalendarBinding(calendar.id!); + return calendar; + } + } + + return null; + } + + Future _createExportedCalendar() async { + Result calendarIdData = await deviceCalendarPlugin.createCalendar( + calendarName, + ); + if (!calendarIdData.isSuccess || + calendarIdData.data == null || + calendarIdData.data!.isEmpty) { + log.info( + '[SystemCalendarSyncService][_createExportedCalendar] ' + 'Create calendar failed.', + ); + return null; + } + + return calendarIdData.data!; + } + + Future _prepareCalendar({required bool onlyIfCalendarExists}) async { + Calendar? exportedCalendar = await _findExportedCalendar(); + bool shouldCreateCurrentSemesterCalendar = + !onlyIfCalendarExists || didSemesterChangeSinceLastBinding; + + if (exportedCalendar == null) { + if (!shouldCreateCurrentSemesterCalendar) { + return null; + } + return await _createExportedCalendar(); + } + + if (exportedCalendar.id == null || exportedCalendar.id!.isEmpty) { + log.info( + '[SystemCalendarSyncService][_prepareCalendar] ' + 'Exported calendar has empty id.', + ); + return null; + } + + Result deleteResult = await deviceCalendarPlugin.deleteCalendar( + exportedCalendar.id!, + ); + if (deleteResult.data != true) { + log.info( + '[SystemCalendarSyncService][_prepareCalendar] ' + 'Delete old exported calendar failed.', + ); + return null; + } + + await _clearCalendarBinding(); + return await _createExportedCalendar(); + } + + Future _writeEventsToCalendar(String calendarId) async { + for (var i in events) { + final toPush = i..calendarId = calendarId; + Result? addEventResult = await deviceCalendarPlugin + .createOrUpdateEvent(toPush); + if (addEventResult == null || + addEventResult.data == null || + addEventResult.data!.isEmpty) { + log.info( + '[SystemCalendarSyncService][_writeEventsToCalendar] ' + 'Write event failed.', + ); + return false; + } + } + + return true; + } + + Future syncSystemCalendar({ + required bool requestPermissionsIfNeeded, + required bool onlyIfCalendarExists, + Future Function()? showDialog, + }) async { + try { + bool hasPermitted = await _ensureCalendarPermission( + requestPermissionsIfNeeded: requestPermissionsIfNeeded, + showDialog: showDialog, + ); + if (!hasPermitted) { + log.info( + '[SystemCalendarSyncService][syncSystemCalendar] ' + 'Calendar permission denied.', + ); + return false; + } + + String? calendarId = await _prepareCalendar( + onlyIfCalendarExists: onlyIfCalendarExists, + ); + if (calendarId == null || calendarId.isEmpty) { + return false; + } + + bool didWrite = await _writeEventsToCalendar(calendarId); + if (didWrite) { + await _saveCalendarSyncState(calendarId); + } + return didWrite; + } catch (e, s) { + log.handle(e, s); + return false; + } + } +} + +/// Auto sync only when the exported payload changed and the bound calendar +/// still exists. +Future maybeAutoSyncSystemCalendar() async { + try { + final service = SystemCalendarSyncService(); + if (!service.canAutoSync) { + return; + } + + await service.syncSystemCalendar( + requestPermissionsIfNeeded: false, + onlyIfCalendarExists: true, + ); + } catch (e, s) { + log.handle(e, s); + } +}