From f4e544c654388f37f287cc79a948a0c9a007a30b Mon Sep 17 00:00:00 2001 From: CopperKoi Date: Wed, 1 Apr 2026 19:25:14 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E7=B3=BB=E7=BB=9F=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E5=AF=BC=E5=87=BA=E6=94=AF=E6=8C=81=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/controller/classtable_controller.dart | 20 +- lib/page/classtable/classtable_state.dart | 279 +---------- lib/page/homepage/refresh.dart | 6 +- lib/page/setting/setting.dart | 18 +- lib/repository/preference.dart | 3 +- .../system_calendar_sync_service.dart | 442 ++++++++++++++++++ 6 files changed, 494 insertions(+), 274 deletions(-) create mode 100644 lib/repository/system_calendar_sync_service.dart diff --git a/lib/controller/classtable_controller.dart b/lib/controller/classtable_controller.dart index 62753b60..8a43bef9 100644 --- a/lib/controller/classtable_controller.dart +++ b/lib/controller/classtable_controller.dart @@ -29,6 +29,7 @@ class ClassTableController extends GetxController { late File userDefinedFile; late ClassTableData classTableData; late UserDefinedClassData userDefinedClassData; + bool _isClassTableChangedForSystemCalendarSync = false; // Get ClassDetail name info ClassDetail getClassDetail(TimeArrangement timeArrangementIndex) => @@ -213,6 +214,12 @@ class ClassTableController extends GetxController { classTableData.termStartDay, ).add(Duration(days: 7 * preference.getInt(preference.Preference.swift))); + bool consumeClassTableChangeForSystemCalendarSync() { + bool toReturn = _isClassTableChangedForSystemCalendarSync; + _isClassTableChangedForSystemCalendarSync = false; + return toReturn; + } + Future updateClassTable({ bool isForce = false, bool isUserDefinedChanged = false, @@ -226,7 +233,9 @@ class ClassTableController extends GetxController { ); refreshUserDefinedClass(); + bool isClassTableChangedForSystemCalendarSync = false; bool classTableFileIsExist = classTableFile.existsSync(); + String? originalClassTableStr; bool isNotNeedRefreshCache = classTableFileIsExist && !isForce && @@ -234,8 +243,9 @@ class ClassTableController extends GetxController { 2; bool isEmptyCache = false; if (classTableFileIsExist) { + originalClassTableStr = classTableFile.readAsStringSync(); classTableData = ClassTableData.fromJson( - jsonDecode(classTableFile.readAsStringSync()), + jsonDecode(originalClassTableStr), ); classTableData.userDefinedDetail = userDefinedClassData.userDefinedDetail; @@ -261,7 +271,10 @@ class ClassTableController extends GetxController { var toUse = isPostGraduate ? await ClassTableFile().getYjspt() : await ClassTableFile().getEhall(); - classTableFile.writeAsStringSync(jsonEncode(toUse.toJson())); + String newClassTableStr = jsonEncode(toUse.toJson()); + isClassTableChangedForSystemCalendarSync = + originalClassTableStr != newClassTableStr; + classTableFile.writeAsStringSync(newClassTableStr); toUse.userDefinedDetail = userDefinedClassData.userDefinedDetail; toUse.timeArrangement.addAll(userDefinedClassData.timeArrangement); classTableData = toUse; @@ -324,6 +337,9 @@ class ClassTableController extends GetxController { ); } + _isClassTableChangedForSystemCalendarSync = + _isClassTableChangedForSystemCalendarSync || + isClassTableChangedForSystemCalendarSync; state = ClassTableState.fetched; error = null; update(); diff --git a/lib/page/classtable/classtable_state.dart b/lib/page/classtable/classtable_state.dart index 4002ebe3..fd4c6a08 100644 --- a/lib/page/classtable/classtable_state.dart +++ b/lib/page/classtable/classtable_state.dart @@ -7,19 +7,17 @@ import 'dart:math' as math; import 'package:device_calendar/device_calendar.dart'; import 'package:flutter/material.dart'; import 'package:get/get.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/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/page/classtable/class_table_view/class_organized_data.dart'; import 'package:watermeter/repository/logger.dart'; import 'package:watermeter/repository/preference.dart' as preference; +import 'package:watermeter/repository/system_calendar_sync_service.dart'; import 'package:watermeter/themes/color_seed.dart'; /// Use a inheritedWidget to share the ClassTableWidgetState @@ -160,272 +158,22 @@ 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.classTableData, + 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 @@ -435,7 +183,8 @@ END:VTIMEZONE classTableController.updateClassTable(isForce: true), examController.get(), experimentController.get(), - ]).then((value) { + ]).then((value) async { + await maybeAutoSyncSystemCalendar(); notifyListeners(); }); } diff --git a/lib/page/homepage/refresh.dart b/lib/page/homepage/refresh.dart index ad97074d..e2001035 100644 --- a/lib/page/homepage/refresh.dart +++ b/lib/page/homepage/refresh.dart @@ -11,6 +11,7 @@ import 'package:get/get.dart'; import 'package:watermeter/controller/classtable_controller.dart'; import 'package:watermeter/controller/exam_controller.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/school_card_session.dart' as school_card_session; //import 'package:watermeter/repository/pda_service_session.dart' as message; @@ -98,7 +99,10 @@ Future update({ Future(() => school_card_session.SchoolCardSession().initSession()), Future(() => electricity.update()), Future(() => school_net.update()), - ]).then((value) => updateCurrentData()).onError((error, stackTrace) { + ]).then((value) async { + await maybeAutoSyncSystemCalendar(); + updateCurrentData(); + }).onError((error, stackTrace) { log.info( "[homepage Update]" "Update failed with following exception: $error\n" diff --git a/lib/page/setting/setting.dart b/lib/page/setting/setting.dart index 5ae83b4b..093f8a62 100644 --- a/lib/page/setting/setting.dart +++ b/lib/page/setting/setting.dart @@ -38,6 +38,7 @@ import 'package:watermeter/page/setting/dialogs/experiment_password_dialog.dart' import 'package:watermeter/repository/pda_service_session.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'; @@ -718,7 +719,9 @@ class _SettingWindowState extends State { ).updateClassTable(isForce: true), Get.put(ExamController()).get(), Get.put(ExperimentController()).get(), - ]).then((value) => value.first); + ]).then((_) async { + await maybeAutoSyncSystemCalendar(); + }); Navigator.pop(context); }, child: Text( @@ -786,10 +789,15 @@ class _SettingWindowState extends State { if (context.mounted) { showToast(context: context, msg: "Updating data"); } - Get.put( - ClassTableController(), - ).updateClassTable(isForce: true); - Get.put(ExamController()).get(); + Future.wait([ + Get.put( + ClassTableController(), + ).updateClassTable(isForce: true), + Get.put(ExamController()).get(), + Get.put(ExperimentController()).get(), + ]).then((_) async { + await maybeAutoSyncSystemCalendar(); + }); } }); }, diff --git a/lib/repository/preference.dart b/lib/repository/preference.dart index 59cdf4dd..4bee20bf 100644 --- a/lib/repository/preference.dart +++ b/lib/repository/preference.dart @@ -89,7 +89,8 @@ 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 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..a76daf09 --- /dev/null +++ b/lib/repository/system_calendar_sync_service.dart @@ -0,0 +1,442 @@ +// Copyright 2023-2025 BenderBlog Rodriguez and contributors +// Copyright 2025 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 OR Apache-2.0 + +import 'package:device_calendar/device_calendar.dart'; +import 'package:get/get.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/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'; + +String buildExportedClassTableCalendarName(String semesterCode) => + semesterCode.isEmpty + ? exportedClassTableCalendarPrefix + : '$exportedClassTableCalendarPrefix $semesterCode'; + +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 => Get.find(); + ExamController get examController => Get.find(); + ExperimentController get experimentController => Get.find(); + + String get calendarName => buildExportedClassTableCalendarName( + classTableController.classTableData.semesterCode, + ); + + List get events => buildCalendarEvents( + classTableData: classTableController.classTableData, + subjects: examController.data.subject, + experiments: experimentController.data, + ); + + 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 _saveCalendarId(String calendarId) async { + await preference.setString( + preference.Preference.systemCalendarId, + calendarId, + ); + } + + Future _clearCalendarId() async { + await preference.remove(preference.Preference.systemCalendarId); + } + + bool _isLegacyCalendarName(String name) { + return name.startsWith('$calendarName created at ') || + name.startsWith(exportedClassTableCalendarPrefix); + } + + Future _findExportedCalendar() async { + final calendarResult = await deviceCalendarPlugin.retrieveCalendars(); + if (!calendarResult.isSuccess || calendarResult.data == null) { + log.info( + '[SystemCalendarSyncService][_findExportedCalendar] ' + 'Retrieve calendars failed.', + ); + return null; + } + + String savedCalendarId = preference.getString( + preference.Preference.systemCalendarId, + ); + + if (savedCalendarId.isNotEmpty) { + for (var i in calendarResult.data!) { + if (i.id == savedCalendarId && i.isReadOnly != true) { + return i; + } + } + await _clearCalendarId(); + } + + for (var i in calendarResult.data!) { + if (i.name == calendarName && i.isReadOnly != true) { + if (i.id != null && i.id!.isNotEmpty) { + await _saveCalendarId(i.id!); + } + return i; + } + } + + for (var i in calendarResult.data!) { + if (_isLegacyCalendarName(i.name ?? '') && i.isReadOnly != true) { + if (i.id != null && i.id!.isNotEmpty) { + await _saveCalendarId(i.id!); + } + return i; + } + } + + 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; + } + + await _saveCalendarId(calendarIdData.data!); + return calendarIdData.data!; + } + + Future _prepareCalendar({required bool onlyIfCalendarExists}) async { + Calendar? exportedCalendar = await _findExportedCalendar(); + + if (exportedCalendar == null) { + if (onlyIfCalendarExists) { + 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 _clearCalendarId(); + 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; + } + + return await _writeEventsToCalendar(calendarId); + } catch (e, s) { + log.handle(e, s); + return false; + } + } +} + +Future maybeAutoSyncSystemCalendar() async { + try { + final classTableController = Get.find(); + if (!classTableController.consumeClassTableChangeForSystemCalendarSync()) { + return; + } + + await SystemCalendarSyncService().syncSystemCalendar( + requestPermissionsIfNeeded: false, + onlyIfCalendarExists: true, + ); + } catch (e, s) { + log.handle(e, s); + } +} From 7bd73a16469346c550ce3e482cd350d5581439b6 Mon Sep 17 00:00:00 2001 From: CopperKoi Date: Thu, 2 Apr 2026 22:00:06 +0800 Subject: [PATCH 2/7] chore: add comments for system calendar sync --- lib/controller/classtable_controller.dart | 2 ++ lib/repository/system_calendar_sync_service.dart | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/lib/controller/classtable_controller.dart b/lib/controller/classtable_controller.dart index 8a43bef9..00451d73 100644 --- a/lib/controller/classtable_controller.dart +++ b/lib/controller/classtable_controller.dart @@ -29,6 +29,7 @@ class ClassTableController extends GetxController { late File userDefinedFile; late ClassTableData classTableData; late UserDefinedClassData userDefinedClassData; + /// Mark whether the classtable cache changed for system calendar sync. bool _isClassTableChangedForSystemCalendarSync = false; // Get ClassDetail name info @@ -214,6 +215,7 @@ class ClassTableController extends GetxController { classTableData.termStartDay, ).add(Duration(days: 7 * preference.getInt(preference.Preference.swift))); + /// Read and clear the pending change mark for system calendar sync. bool consumeClassTableChangeForSystemCalendarSync() { bool toReturn = _isClassTableChangedForSystemCalendarSync; _isClassTableChangedForSystemCalendarSync = false; diff --git a/lib/repository/system_calendar_sync_service.dart b/lib/repository/system_calendar_sync_service.dart index a76daf09..6340f2f7 100644 --- a/lib/repository/system_calendar_sync_service.dart +++ b/lib/repository/system_calendar_sync_service.dart @@ -18,6 +18,7 @@ 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 @@ -278,6 +279,7 @@ class SystemCalendarSyncService { name.startsWith(exportedClassTableCalendarPrefix); } + /// Find the exported calendar by saved id, fixed name or legacy name. Future _findExportedCalendar() async { final calendarResult = await deviceCalendarPlugin.retrieveCalendars(); if (!calendarResult.isSuccess || calendarResult.data == null) { @@ -358,6 +360,7 @@ class SystemCalendarSyncService { return null; } + // Recreate the exported calendar to replace the previous exported events. Result deleteResult = await deviceCalendarPlugin.deleteCalendar( exportedCalendar.id!, ); @@ -425,6 +428,7 @@ class SystemCalendarSyncService { } } +/// Auto sync only when classtable changed and exported calendar exists. Future maybeAutoSyncSystemCalendar() async { try { final classTableController = Get.find(); From 794e3bc9f3c8eefb199de1659013e325dd704148 Mon Sep 17 00:00:00 2001 From: CopperKoi Date: Thu, 16 Apr 2026 23:40:17 +0800 Subject: [PATCH 3/7] chore: tidy semester switch dialog formatting --- lib/page/setting/setting.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/page/setting/setting.dart b/lib/page/setting/setting.dart index aa318b56..97926424 100644 --- a/lib/page/setting/setting.dart +++ b/lib/page/setting/setting.dart @@ -818,8 +818,8 @@ class _SettingWindowState extends State { onTap: () { showDialog( barrierDismissible: false, - context: context, - builder: (context) => SemesterSwitchDialog(), + context: context, + builder: (context) => SemesterSwitchDialog(), ).then((value) async { if (value == true) { setState(() {}); From 45f4af6ca103b74f775936167417185d6a5e7bba Mon Sep 17 00:00:00 2001 From: CopperKoi Date: Thu, 16 Apr 2026 23:44:14 +0800 Subject: [PATCH 4/7] style: tidy merge follow-up formatting --- lib/page/homepage/refresh.dart | 5 ++++- lib/page/setting/setting.dart | 3 ++- lib/repository/system_calendar_sync_service.dart | 8 ++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/page/homepage/refresh.dart b/lib/page/homepage/refresh.dart index 3784a99f..79d82102 100644 --- a/lib/page/homepage/refresh.dart +++ b/lib/page/homepage/refresh.dart @@ -82,7 +82,10 @@ 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(); diff --git a/lib/page/setting/setting.dart b/lib/page/setting/setting.dart index 97926424..6a5dc350 100644 --- a/lib/page/setting/setting.dart +++ b/lib/page/setting/setting.dart @@ -272,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), diff --git a/lib/repository/system_calendar_sync_service.dart b/lib/repository/system_calendar_sync_service.dart index 560b7dc4..6fbc714f 100644 --- a/lib/repository/system_calendar_sync_service.dart +++ b/lib/repository/system_calendar_sync_service.dart @@ -201,10 +201,14 @@ END:VTIMEZONE vevent += 'DTSTART;TZID=Asia/Shanghai:' - '${DateFormat('yyyyMMddTHHmmss').format(DateTime.fromMicrosecondsSinceEpoch(i.start!.microsecondsSinceEpoch))}\n'; + '${DateFormat('yyyyMMddTHHmmss').format( + DateTime.fromMicrosecondsSinceEpoch(i.start!.microsecondsSinceEpoch), + )}\n'; vevent += 'DTEND;TZID=Asia/Shanghai:' - '${DateFormat('yyyyMMddTHHmmss').format(DateTime.fromMicrosecondsSinceEpoch(i.end!.microsecondsSinceEpoch))}\n'; + '${DateFormat('yyyyMMddTHHmmss').format( + DateTime.fromMicrosecondsSinceEpoch(i.end!.microsecondsSinceEpoch), + )}\n'; if (i.location != null) { vevent += 'LOCATION:${i.location}\n'; } From 12cc84c79393314f3f287b637973257513b931f9 Mon Sep 17 00:00:00 2001 From: CopperKoi Date: Thu, 16 Apr 2026 23:55:00 +0800 Subject: [PATCH 5/7] refactor: remove legacy system calendar migration --- .../system_calendar_sync_service.dart | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/lib/repository/system_calendar_sync_service.dart b/lib/repository/system_calendar_sync_service.dart index 6fbc714f..d0d2472d 100644 --- a/lib/repository/system_calendar_sync_service.dart +++ b/lib/repository/system_calendar_sync_service.dart @@ -319,11 +319,6 @@ class SystemCalendarSyncService { await preference.remove(preference.Preference.systemCalendarId); } - bool _isLegacyCalendarName(String name) { - return name.startsWith('$calendarName created at ') || - name.startsWith(exportedClassTableCalendarPrefix); - } - Future?> _retrieveWritableCalendars() async { final calendarResult = await deviceCalendarPlugin.retrieveCalendars(); if (!calendarResult.isSuccess || calendarResult.data == null) { @@ -357,17 +352,15 @@ class SystemCalendarSyncService { return null; } - /// Find the exported calendar by saved id or compatible old naming rules. - Future _findExportedCalendar({ - required bool allowLegacyLookup, - }) async { + /// 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 boundCalendar = await _findBoundCalendar(calendars); - if (boundCalendar != null || !allowLegacyLookup) { + if (boundCalendar != null) { return boundCalendar; } @@ -380,15 +373,6 @@ class SystemCalendarSyncService { } } - for (var calendar in calendars) { - if (_isLegacyCalendarName(calendar.name ?? '') && - calendar.id != null && - calendar.id!.isNotEmpty) { - await _saveCalendarBinding(calendar.id!); - return calendar; - } - } - return null; } @@ -410,9 +394,7 @@ class SystemCalendarSyncService { } Future _prepareCalendar({required bool onlyIfCalendarExists}) async { - Calendar? exportedCalendar = await _findExportedCalendar( - allowLegacyLookup: !onlyIfCalendarExists, - ); + Calendar? exportedCalendar = await _findExportedCalendar(); if (exportedCalendar == null) { if (onlyIfCalendarExists) { From 4f80f8717b9cec1bb1ac986fd56be2b33adbc6e1 Mon Sep 17 00:00:00 2001 From: CopperKoi Date: Fri, 17 Apr 2026 00:11:16 +0800 Subject: [PATCH 6/7] feat: create a new system calendar after semester change --- lib/repository/preference.dart | 4 ++ .../system_calendar_sync_service.dart | 70 ++++++++++++++++--- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/lib/repository/preference.dart b/lib/repository/preference.dart index d4383c95..4a6a951c 100644 --- a/lib/repository/preference.dart +++ b/lib/repository/preference.dart @@ -91,6 +91,10 @@ enum Preference { dormWaterUid(key: "dorm_water_uid", type: "String"), // 宿舍水机用户 uid 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", diff --git a/lib/repository/system_calendar_sync_service.dart b/lib/repository/system_calendar_sync_service.dart index d0d2472d..8fab4b05 100644 --- a/lib/repository/system_calendar_sync_service.dart +++ b/lib/repository/system_calendar_sync_service.dart @@ -253,8 +253,17 @@ class SystemCalendarSyncService { 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( - classTableController.classTableComputedSignal.value.semesterCode, + currentSemesterCode, ); List get experiments => [ @@ -274,10 +283,22 @@ class SystemCalendarSyncService { 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 => - preference.getString(preference.Preference.systemCalendarId).isNotEmpty && - preference.getString(preference.Preference.systemCalendarSnapshot) != - snapshot && + hasCalendarBinding && + (didSemesterChangeSinceLastBinding || + preference.getString(preference.Preference.systemCalendarSnapshot) != + snapshot) && classTableController.hasValidClassInfo.value; Future _ensureCalendarPermission({ @@ -305,6 +326,10 @@ class SystemCalendarSyncService { preference.Preference.systemCalendarId, calendarId, ); + await preference.setString( + preference.Preference.systemCalendarSemesterCode, + currentSemesterCode, + ); } Future _saveCalendarSyncState(String calendarId) async { @@ -317,6 +342,7 @@ class SystemCalendarSyncService { Future _clearCalendarBinding() async { await preference.remove(preference.Preference.systemCalendarId); + await preference.remove(preference.Preference.systemCalendarSemesterCode); } Future?> _retrieveWritableCalendars() async { @@ -335,20 +361,40 @@ class SystemCalendarSyncService { } Future _findBoundCalendar(List calendars) async { - String savedCalendarId = preference.getString( - preference.Preference.systemCalendarId, - ); - if (savedCalendarId.isEmpty) { + if (!hasCalendarBinding) { return null; } for (var calendar in calendars) { - if (calendar.id == savedCalendarId) { + 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(); + if (isBoundCalendarForCurrentSemester) { + await _clearCalendarBinding(); + } return null; } @@ -395,9 +441,11 @@ class SystemCalendarSyncService { Future _prepareCalendar({required bool onlyIfCalendarExists}) async { Calendar? exportedCalendar = await _findExportedCalendar(); + bool shouldCreateCurrentSemesterCalendar = + !onlyIfCalendarExists || didSemesterChangeSinceLastBinding; if (exportedCalendar == null) { - if (onlyIfCalendarExists) { + if (!shouldCreateCurrentSemesterCalendar) { return null; } return await _createExportedCalendar(); From c1c4d7d6a7edb6f53849a0edd80b18b3925fe549 Mon Sep 17 00:00:00 2001 From: CopperKoi Date: Fri, 17 Apr 2026 12:37:45 +0800 Subject: [PATCH 7/7] fix: stop auto restoring deleted system calendars --- lib/repository/system_calendar_sync_service.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/repository/system_calendar_sync_service.dart b/lib/repository/system_calendar_sync_service.dart index 8fab4b05..38252c8e 100644 --- a/lib/repository/system_calendar_sync_service.dart +++ b/lib/repository/system_calendar_sync_service.dart @@ -392,9 +392,7 @@ class SystemCalendarSyncService { return null; } - if (isBoundCalendarForCurrentSemester) { - await _clearCalendarBinding(); - } + await _clearCalendarBinding(); return null; } @@ -405,10 +403,16 @@ class SystemCalendarSyncService { 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 &&