diff --git a/assets/flutter_i18n/en_US.yaml b/assets/flutter_i18n/en_US.yaml index 22121a17..a9e4dc2d 100644 --- a/assets/flutter_i18n/en_US.yaml +++ b/assets/flutter_i18n/en_US.yaml @@ -67,6 +67,21 @@ weekday: saturday: "Sat." sunday: "Sun." +# Months +month: + january: "Jan." + february: "Feb." + march: "Mar." + april: "Apr." + may: "May" + june: "Jun." + july: "Jul." + august: "Aug." + september: "Sept." + october: "Oct." + november: "Nov." + december: "Dec." + # 考勤查询 class_attendance: title: "Attendance Query" @@ -233,6 +248,16 @@ classtable: input_start_time_hint: "Time start" input_end_time_hint: "Time end" wheel_choose_hint: "Period {index}" + choose_at_least_one: "Please choose at least one time for class" + repeat_weekly: "Repeat Weekly" + free_time: "Free Time" + date_selector_free: + rule: "Time must be between 8:30 and 21:25." + rule_2: "The end time must be later than the start time." + class_start_time: "Start time" + class_end_time: "End time" + edit_class_time: "Edit the class time" + choose_class_time: "Choose a class time" course_detail_card: class_number_string: "Class {number}" unknown_teacher: "Unknown teacher" @@ -240,8 +265,11 @@ classtable: class_period: "period {start} to {stop}" edit: "Edit" delete: "Delete" + delete_single: "Delete this one" + delete_all: "Delete all" delete_title: "Are you sure to delete this class information?" delete_content: "Everything will be excuted." + delete_content_single: "Only the information within this time range of the class will be removed." output_to_system: success: Successfully output to the system calendar. failure: Problem occurred while outputing to the system calendar. diff --git a/assets/flutter_i18n/zh_CN.yaml b/assets/flutter_i18n/zh_CN.yaml index 65170e1c..e70591e5 100644 --- a/assets/flutter_i18n/zh_CN.yaml +++ b/assets/flutter_i18n/zh_CN.yaml @@ -62,6 +62,21 @@ weekday: saturday: "周六" sunday: "周日" +# 月份 +month: + january: "一月" + february: "二月" + march: "三月" + april: "四月" + may: "五月" + june: "六月" + july: "七月" + august: "八月" + september: "九月" + october: "十月" + november: "十一月" + december: "十二月" + # 考勤查询 class_attendance: title: "考勤查询" @@ -224,6 +239,16 @@ classtable: input_start_time_hint: "上课时间" input_end_time_hint: "下课时间" wheel_choose_hint: "第 {index} 节" + choose_at_least_one: "请至少选择一个上课日期和时间" + repeat_weekly: "按周重复" + free_time: "自定义日期" + date_selector_free: + rule: "时间必须在 08:30-21:25 之间" + rule_2: "下课时间必须晚于上课时间" + class_start_time: "上课时间" + class_end_time: "下课时间" + edit_class_time: "编辑课程时间" + choose_class_time: "选择课程时间" course_detail_card: class_number_string: "{number} 班" unknown_teacher: "老师未定" @@ -232,7 +257,10 @@ classtable: edit: "编辑" delete: "删除" delete_title: "是否删除课程信息?" + delete_single: "删除本次" + delete_all: "删除全部" delete_content: "所有关于这个课的信息都会被删除,课表上关于这门课的信息将不复存在!" + delete_content_single: "关于这个课的信息只有这个时间段都会被删除,其他的时间段会被保留。" output_to_system: success: "成功导出到系统日历" failure: "导出到系统日历过程中发生了问题:P" diff --git a/assets/flutter_i18n/zh_TW.yaml b/assets/flutter_i18n/zh_TW.yaml index b51901b1..5de8d6b0 100644 --- a/assets/flutter_i18n/zh_TW.yaml +++ b/assets/flutter_i18n/zh_TW.yaml @@ -41,6 +41,19 @@ weekday: friday: 週五 saturday: 週六 sunday: 週日 +month: + january: 一月 + february: 二月 + march: 三月 + april: 四月 + may: 五月 + june: 六月 + july: 七月 + august: 八月 + september: 九月 + october: 十月 + november: 十一月 + december: 十二月 class_attendance: title: 考勤查詢 detail_title: 簽到信息 - {courseName} @@ -186,6 +199,16 @@ classtable: input_start_time_hint: 上課時間 input_end_time_hint: 下課時間 wheel_choose_hint: 第 {index} 節 + choose_at_least_one: 請至少選擇一個上課日期和時間 + repeat_weekly: 按周重複 + free_time: 自定義日期 + date_selector_free: + rule: 時間必須在 08:30-21:25 之間 + rule_2: 下課時間必須晚於上課時間 + class_start_time: 上課時間 + class_end_time: 下課時間 + edit_class_time: 編輯課程時間 + choose_class_time: 選擇課程時間 course_detail_card: class_number_string: '{number} 班' unknown_teacher: 老師未定 @@ -194,7 +217,10 @@ classtable: edit: 編輯 delete: 刪除 delete_title: 是否刪除課程信息? + delete_single: 刪除本次 + delete_all: 刪除全部 delete_content: 所有關於這個課的信息都會被刪除,課表上關於這門課的信息將不復存在! + delete_content_single: 關於這個課的信息只有這個時間段都會被刪除,其他的時間段會被保留。 output_to_system: success: 成功導出到系統日曆 failure: 導出到系統日曆過程中發生了問題:P diff --git a/lib/controller/custom_class_controller.dart b/lib/controller/custom_class_controller.dart new file mode 100644 index 00000000..e82dca7f --- /dev/null +++ b/lib/controller/custom_class_controller.dart @@ -0,0 +1,198 @@ +// Copyright 2026 Hazuki Keatsu. +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'dart:convert'; +import 'dart:io'; + +import 'package:signals/signals.dart'; +import 'package:watermeter/model/pda_service/custom_class.dart'; +import 'package:watermeter/repository/network_session.dart'; + +enum CustomClassState { fetching, fetched, error, none } + +class CustomClassOccurrence { + final CustomClass customClass; + final CustomClassTimeRange timeRange; + + const CustomClassOccurrence({ + required this.customClass, + required this.timeRange, + }); +} + +class CustomClassController { + static final CustomClassController i = CustomClassController._(); + + static const String _customClassFileName = 'CustomClassesV2.json'; + static const String _customClassIdPrefix = 'cc'; + static const String _timeRangeIdPrefix = 'tr'; + + late File customClassFile; + + CustomClassController._() { + customClassFile = File('${supportPath.path}/$_customClassFileName'); + _load(); + } + + int _idSequence = 0; + + String _nextId(String prefix) { + final int now = DateTime.now().microsecondsSinceEpoch; + _idSequence = (_idSequence + 1) & 0xFFFFF; + return '$prefix-${now.toRadixString(36)}-${_idSequence.toRadixString(36)}'; + } + + String generateCustomClassId() => _nextId(_customClassIdPrefix); + + String generateTimeRangeId() => _nextId(_timeRangeIdPrefix); + + DateTime _dateOnly(DateTime value) => + DateTime(value.year, value.month, value.day); + + final errorSignal = signal(null); + final stateSignal = signal(CustomClassState.none); + final customClassesSignal = signal>([]); + + String? get error => errorSignal.value; + + CustomClassState get state => stateSignal.value; + + List get customClasses => customClassesSignal.value; + + void _load() { + stateSignal.value = CustomClassState.fetching; + errorSignal.value = null; + try { + if (!customClassFile.existsSync()) { + customClassFile.writeAsStringSync('[]'); + } + final dynamic decoded = jsonDecode(customClassFile.readAsStringSync()); + customClassesSignal.value = (decoded as List) + .map((e) => CustomClass.fromJson(e as Map)) + .toList(); + stateSignal.value = CustomClassState.fetched; + } catch (e) { + stateSignal.value = CustomClassState.error; + errorSignal.value = e.toString(); + customClassesSignal.value = []; + } + } + + bool _save(List nextClasses) { + try { + customClassFile.writeAsStringSync( + jsonEncode(nextClasses.map((e) => e.toJson()).toList()), + ); + customClassesSignal.value = nextClasses; + errorSignal.value = null; + stateSignal.value = CustomClassState.fetched; + return true; + } catch (e) { + stateSignal.value = CustomClassState.error; + errorSignal.value = 'Failed to save custom classes: $e'; + return false; + } + } + + int _indexOfCustomClassById(String customClassId) => customClasses.indexWhere( + (customClass) => customClass.id == customClassId, + ); + + /// 添加新的自定义课程 + Future addCustomClass(CustomClass customClass) async { + final List updated = List.from(customClasses) + ..add(customClass); + _save(updated); + } + + /// 编辑已有的自定义课程 + Future editCustomClassById( + String customClassId, + CustomClass customClass, + ) async { + final int index = _indexOfCustomClassById(customClassId); + if (index < 0) return; + final List updated = List.from(customClasses); + updated[index] = customClass; + _save(updated); + } + + /// 删除已有的自定义课程 + Future deleteCustomClassById(String customClassId) async { + final int index = _indexOfCustomClassById(customClassId); + if (index < 0) return; + final List updated = List.from(customClasses) + ..removeAt(index); + _save(updated); + } + + /// 从已有的自定义课程中一处某个时间段 + Future deleteCustomClassTimeRange({ + required String customClassId, + required String timeRangeId, + }) async { + final int customIndex = _indexOfCustomClassById(customClassId); + if (customIndex < 0) return; + + final CustomClass customClass = customClasses[customIndex]; + final int timeRangeIndex = customClass.timeRanges.indexWhere( + (timeRange) => timeRange.id == timeRangeId, + ); + if (timeRangeIndex < 0) return; + + final List updatedRanges = + List.from(customClass.timeRanges) + ..removeAt(timeRangeIndex); + + if (updatedRanges.isEmpty) { + final List updated = List.from(customClasses) + ..removeAt(customIndex); + _save(updated); + return; + } + + final CustomClass updatedClass = CustomClass( + id: customClass.id, + name: customClass.name, + teacher: customClass.teacher, + classroom: customClass.classroom, + timeRanges: updatedRanges, + ); + final List updated = List.from(customClasses); + updated[customIndex] = updatedClass; + _save(updated); + } + + /// 通过周索引、日索引和学期开始日期来找到有日程的那天 + List getOccurrenceOfDay({ + required int weekIndex, + required int dayIndex, + required DateTime semesterStartDay, + }) { + final List occurrences = []; + + for (final customClass in customClasses) { + for (final timeRange in customClass.timeRanges) { + final int diffDays = _dateOnly( + timeRange.startTime, + ).difference(_dateOnly(semesterStartDay)).inDays; + if (diffDays < 0) continue; + + final int targetWeek = diffDays ~/ 7; + final int targetDay = diffDays % 7 + 1; + + if (targetWeek == weekIndex && targetDay == dayIndex) { + occurrences.add( + CustomClassOccurrence( + customClass: customClass, + timeRange: timeRange, + ), + ); + } + } + } + + return occurrences; + } +} diff --git a/lib/main.dart b/lib/main.dart index a300b8aa..5003d364 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -73,18 +73,20 @@ void main() async { ); // Initialize notification services - try { - await NotificationServiceRegistrar().initializeAllServices(); + if (Platform.isAndroid || Platform.isIOS) { + try { + await NotificationServiceRegistrar().initializeAllServices(); - // Handle app launch from notification - WidgetsBinding.instance.addPostFrameCallback((_) async { - final services = NotificationServiceRegistrar().getAllServices(); - await Future.wait( - services.map((service) => service.handleAppLaunchFromNotification()), - ); - }); - } catch (e) { - log.error('Failed to initialize notification services', e); + // Handle app launch from notification + WidgetsBinding.instance.addPostFrameCallback((_) async { + final services = NotificationServiceRegistrar().getAllServices(); + await Future.wait( + services.map((service) => service.handleAppLaunchFromNotification()), + ); + }); + } catch (e) { + log.error('Failed to initialize notification services', e); + } } } @@ -117,7 +119,8 @@ class _MyAppState extends State { WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; - final screenWidth = PlatformDispatcher.instance.views.first.physicalSize.width / + final screenWidth = + PlatformDispatcher.instance.views.first.physicalSize.width / PlatformDispatcher.instance.views.first.devicePixelRatio; log.info("Screen width: $screenWidth."); if (screenWidth < 480) { diff --git a/lib/model/pda_service/custom_class.dart b/lib/model/pda_service/custom_class.dart new file mode 100644 index 00000000..853399bb --- /dev/null +++ b/lib/model/pda_service/custom_class.dart @@ -0,0 +1,81 @@ +// Copyright 2026 Hazuki Keatsu. +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 + +import 'package:json_annotation/json_annotation.dart'; + +part 'custom_class.g.dart'; + +/// 自定义课程的数据类 +@JsonSerializable(explicitToJson: true) +class CustomClassTimeRange { + final String id; + @JsonKey(name: 'start_time') + final DateTime startTime; + @JsonKey(name: 'end_time') + final DateTime endTime; + + static const int _earliestInMinutes = 8 * 60 + 30; + static const int _latestInMinutes = 21 * 60 + 25; + + CustomClassTimeRange({ + required this.id, + required this.startTime, + required this.endTime, + }) { + // 这里从数据模型层面封堵了非法时间的可能性,虽然说在UI层面也有封堵。。 + if (!isWithinAllowedTime(startTime) || !isWithinAllowedTime(endTime)) { + throw ArgumentError('Class time must be in 08:30-21:25.'); + } + if (!isSameDay(startTime, endTime)) { + throw ArgumentError('Start and end time must be on the same day.'); + } + if (!startTime.isBefore(endTime)) { + throw ArgumentError('Start time must be earlier than end time.'); + } + } + + static bool isWithinAllowedTime(DateTime dateTime) { + final int minutes = dateTime.hour * 60 + dateTime.minute; + return minutes >= _earliestInMinutes && minutes <= _latestInMinutes; + } + + static bool isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + factory CustomClassTimeRange.fromJson(Map json) => + _$CustomClassTimeRangeFromJson(json); + + Map toJson() => _$CustomClassTimeRangeToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class CustomClass { + final String id; + final String name; + final String? teacher; + final String? classroom; + @JsonKey(name: 'time_ranges') + final List timeRanges; + + CustomClass({ + required this.id, + required this.name, + this.teacher, + this.classroom, + required List timeRanges, + }) : timeRanges = List.from(timeRanges) { + if (name.trim().isEmpty) { + throw ArgumentError('Class name is required.'); + } + if (this.timeRanges.isEmpty) { + throw ArgumentError('At least one time range is required.'); + } + } + + factory CustomClass.fromJson(Map json) => + _$CustomClassFromJson(json); + + Map toJson() => _$CustomClassToJson(this); +} diff --git a/lib/model/pda_service/custom_class.g.dart b/lib/model/pda_service/custom_class.g.dart new file mode 100644 index 00000000..8adefab6 --- /dev/null +++ b/lib/model/pda_service/custom_class.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'custom_class.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CustomClassTimeRange _$CustomClassTimeRangeFromJson( + Map json, +) => CustomClassTimeRange( + id: json['id'] as String, + startTime: DateTime.parse(json['start_time'] as String), + endTime: DateTime.parse(json['end_time'] as String), +); + +Map _$CustomClassTimeRangeToJson( + CustomClassTimeRange instance, +) => { + 'id': instance.id, + 'start_time': instance.startTime.toIso8601String(), + 'end_time': instance.endTime.toIso8601String(), +}; + +CustomClass _$CustomClassFromJson(Map json) => CustomClass( + id: json['id'] as String, + name: json['name'] as String, + teacher: json['teacher'] as String?, + classroom: json['classroom'] as String?, + timeRanges: (json['time_ranges'] as List) + .map((e) => CustomClassTimeRange.fromJson(e as Map)) + .toList(), +); + +Map _$CustomClassToJson(CustomClass instance) => + { + 'id': instance.id, + 'name': instance.name, + 'teacher': instance.teacher, + 'classroom': instance.classroom, + 'time_ranges': instance.timeRanges.map((e) => e.toJson()).toList(), + }; diff --git a/lib/page/classtable/arrangement_detail/arrangement_list.dart b/lib/page/classtable/arrangement_detail/arrangement_list.dart index 8e0722d9..3de3ecbd 100644 --- a/lib/page/classtable/arrangement_detail/arrangement_list.dart +++ b/lib/page/classtable/arrangement_detail/arrangement_list.dart @@ -3,10 +3,12 @@ // SPDX-License-Identifier: MPL-2.0 OR Apache-2.0 import 'package:flutter/material.dart'; +import 'package:watermeter/model/pda_service/custom_class.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/arrangement_detail/course_detail_card.dart'; +import 'package:watermeter/page/classtable/arrangement_detail/custom_class_detail_card.dart'; import 'package:watermeter/page/classtable/arrangement_detail/arrangement_detail_state.dart'; import 'package:watermeter/page/classtable/arrangement_detail/exam_detail_card.dart'; import 'package:watermeter/page/classtable/arrangement_detail/experiment_detail_card.dart'; @@ -18,9 +20,14 @@ class ArrangementList extends StatelessWidget { @override Widget build(BuildContext context) { - ArrangementDetailState classDetailState = ArrangementDetailState.of( + final ArrangementDetailState? classDetailState = ArrangementDetailState.of( context, - )!; + ); + // 空列表收缩 + if (classDetailState == null) { + return const SizedBox.shrink(); + } + return ListView( shrinkWrap: true, children: List.generate(classDetailState.information.length, (i) { @@ -43,6 +50,21 @@ class ArrangementList extends StatelessWidget { experiment: classDetailState.information[i], infoColor: colorList[2 % colorList.length], ); + } else if (classDetailState.information[i] + is (CustomClass, CustomClassTimeRange, MaterialColor)) { + return CustomClassDetailCard( + customClass: classDetailState.information[i].$1, + timeRange: classDetailState.information[i].$2, + infoColor: classDetailState.information[i].$3, + ); + } else if (classDetailState.information[i] + is (CustomClass, CustomClassTimeRange)) { + // 颜色降级 + return CustomClassDetailCard( + customClass: classDetailState.information[i].$1, + timeRange: classDetailState.information[i].$2, + infoColor: colorList[0], + ); } return const Placeholder(); }), diff --git a/lib/page/classtable/arrangement_detail/custom_class_detail_card.dart b/lib/page/classtable/arrangement_detail/custom_class_detail_card.dart new file mode 100644 index 00000000..29bb6cb9 --- /dev/null +++ b/lib/page/classtable/arrangement_detail/custom_class_detail_card.dart @@ -0,0 +1,211 @@ +// Copyright 2026 Hazuki Keatsu. +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 OR Apache-2.0 + +import 'package:flutter/material.dart'; +import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:intl/intl.dart'; +import 'package:watermeter/model/pda_service/custom_class.dart'; +import 'package:watermeter/page/classtable/arrangement_detail/course_detail_card.dart'; + +class CustomClassDetailCard extends StatelessWidget { + final CustomClass customClass; + final CustomClassTimeRange timeRange; + final MaterialColor infoColor; + + const CustomClassDetailCard({ + super.key, + required this.customClass, + required this.timeRange, + required this.infoColor, + }); + + @override + Widget build(BuildContext context) { + final String dateText = DateFormat( + 'yyyy-MM-dd EEE', + ).format(timeRange.startTime); + final String timeText = + '${DateFormat('HH:mm').format(timeRange.startTime)}-${DateFormat('HH:mm').format(timeRange.endTime)}'; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360.0), + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + elevation: 0, + color: infoColor.shade100, + child: Container( + padding: const EdgeInsets.fromLTRB(15, 15, 15, 15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + customClass.name, + style: TextStyle( + color: infoColor.shade900, + fontSize: 18, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 5), + CustomListTile( + icon: Icons.person, + str: + customClass.teacher ?? + FlutterI18n.translate( + context, + "classtable.course_detail_card.unknown_teacher", + ), + infoColor: infoColor, + ), + CustomListTile( + icon: Icons.room, + str: + customClass.classroom ?? + FlutterI18n.translate( + context, + "classtable.course_detail_card.unknown_place", + ), + infoColor: infoColor, + ), + CustomListTile( + icon: Icons.access_time_filled_outlined, + str: '$dateText $timeText', + infoColor: infoColor, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + TextButton( + onPressed: () { + Navigator.of( + context, + ).pop((customClass.id, timeRange.id, 'edit')); + }, + child: Text( + FlutterI18n.translate( + context, + "classtable.course_detail_card.edit", + ), + style: TextStyle(color: infoColor.shade900), + ), + ), + TextButton( + onPressed: () async { + bool? isContinue = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + FlutterI18n.translate( + context, + "classtable.course_detail_card.delete_title", + ), + ), + content: Text( + FlutterI18n.translate( + context, + "classtable.course_detail_card.delete_content_single", + ), + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimary, + ), + onPressed: () => Navigator.pop(context, false), + child: Text( + FlutterI18n.translate(context, "cancel"), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + FlutterI18n.translate(context, "confirm"), + ), + ), + ], + ), + ); + if (context.mounted && isContinue == true) { + Navigator.of( + context, + ).pop((customClass.id, timeRange.id, 'delete_one')); + } + }, + child: Text( + FlutterI18n.translate( + context, + "classtable.course_detail_card.delete_single", + ), + style: TextStyle(color: infoColor.shade900), + ), + ), + TextButton( + onPressed: () async { + bool? isContinue = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + FlutterI18n.translate( + context, + "classtable.course_detail_card.delete_title", + ), + ), + content: Text( + FlutterI18n.translate( + context, + "classtable.course_detail_card.delete_content", + ), + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimary, + ), + onPressed: () => Navigator.pop(context, false), + child: Text( + FlutterI18n.translate(context, 'cancel'), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + FlutterI18n.translate(context, 'confirm'), + ), + ), + ], + ), + ); + if (context.mounted && isContinue == true) { + Navigator.of( + context, + ).pop((customClass.id, null, 'delete_all')); + } + }, + child: Text( + FlutterI18n.translate( + context, + "classtable.course_detail_card.delete_all", + ), + style: TextStyle(color: Colors.red.shade700), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/page/classtable/class_add/class_add_window.dart b/lib/page/classtable/class_add/class_add_window.dart index 3d2fa4c8..857fb85e 100644 --- a/lib/page/classtable/class_add/class_add_window.dart +++ b/lib/page/classtable/class_add/class_add_window.dart @@ -4,18 +4,24 @@ import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:watermeter/controller/classtable_controller.dart'; +import 'package:watermeter/controller/custom_class_controller.dart'; +import 'package:watermeter/model/pda_service/custom_class.dart'; import 'package:watermeter/page/public_widget/toast.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:watermeter/model/xidian_ids/classtable.dart'; +import 'package:watermeter/page/classtable/class_add/date_selector_free.dart'; import 'package:watermeter/page/classtable/class_add/week_selector.dart'; import 'package:watermeter/page/classtable/class_add/time_selector.dart'; class ClassAddWindow extends StatefulWidget { final (ClassDetail, TimeArrangement)? toChange; + final CustomClass? customToChange; final int semesterLength; const ClassAddWindow({ super.key, this.toChange, + this.customToChange, required this.semesterLength, }); @@ -24,7 +30,12 @@ class ClassAddWindow extends StatefulWidget { } class _ClassAddWindowState extends State { + bool useCustomDateFlow = false; + + late final CustomClassController customClassController; + late List chosenWeek; + late List chosenDates; late TextEditingController classNameController; late TextEditingController teacherNameController; late TextEditingController classRoomController; @@ -36,18 +47,43 @@ class _ClassAddWindowState extends State { final double inputFieldVerticalPadding = 4; final double horizontalPadding = 10; - late InputDecoration inputDecoration; - Color get color => Theme.of(context).colorScheme.primary; + Color get deleteColor => Theme.of(context).colorScheme.error; + + DateTime get semesterStartDate { + final String termStartDay = + ClassTableController.i.classTableComputedSignal.value.termStartDay; + return DateTime.tryParse(termStartDay) ?? DateUtils.dateOnly(DateTime.now()); + } @override void initState() { super.initState(); - if (widget.toChange == null) { + customClassController = CustomClassController.i; + if (widget.customToChange != null) { + useCustomDateFlow = true; + classNameController = TextEditingController( + text: widget.customToChange!.name, + ); + teacherNameController = TextEditingController( + text: widget.customToChange!.teacher, + ); + classRoomController = TextEditingController( + text: widget.customToChange!.classroom, + ); + chosenWeek = List.generate(widget.semesterLength, (index) => false); + chosenDates = widget.customToChange!.timeRanges + .map((e) => DateTimeRange(start: e.startTime, end: e.endTime)) + .toList(); + week = 1; + start = 1; + stop = 1; + } else if (widget.toChange == null) { classNameController = TextEditingController(); teacherNameController = TextEditingController(); classRoomController = TextEditingController(); chosenWeek = List.generate(widget.semesterLength, (index) => false); + chosenDates = []; week = 1; start = 1; stop = 1; @@ -62,6 +98,7 @@ class _ClassAddWindowState extends State { text: widget.toChange!.$2.classroom, ); chosenWeek = widget.toChange!.$2.weekList; + chosenDates = []; week = widget.toChange!.$2.day; start = widget.toChange!.$2.start; stop = widget.toChange!.$2.stop; @@ -69,25 +106,148 @@ class _ClassAddWindowState extends State { } @override - void didChangeDependencies() { - super.didChangeDependencies(); - inputDecoration = InputDecoration( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(25), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: Theme.of(context).colorScheme.onPrimary, - contentPadding: const EdgeInsets.symmetric(horizontal: 20), - ); + void dispose() { + classNameController.dispose(); + teacherNameController.dispose(); + classRoomController.dispose(); + super.dispose(); + } + + Future _save() async { + if (classNameController.text.isEmpty) { + showToast( + context: context, + msg: FlutterI18n.translate( + context, + "classtable.class_add.class_name_empty_message", + ), + ); + return; + } + + if (widget.customToChange != null || + (widget.toChange == null && useCustomDateFlow)) { + if (chosenDates.isEmpty) { + showToast( + context: context, + msg: FlutterI18n.translate( + context, + "classtable.class_add.choose_at_least_one", + ), + ); + return; + } + + try { + final Map existingRangeIds = { + for (final range in widget.customToChange?.timeRanges ?? []) + '${range.startTime.microsecondsSinceEpoch}-${range.endTime.microsecondsSinceEpoch}': + range.id, + }; + final customClass = CustomClass( + id: + widget.customToChange?.id ?? + customClassController.generateCustomClassId(), + name: classNameController.text, + teacher: teacherNameController.text.isNotEmpty + ? teacherNameController.text + : null, + classroom: classRoomController.text.isNotEmpty + ? classRoomController.text + : null, + timeRanges: chosenDates.map((e) { + final String key = + '${e.start.microsecondsSinceEpoch}-${e.end.microsecondsSinceEpoch}'; + return CustomClassTimeRange( + id: + existingRangeIds[key] ?? + customClassController.generateTimeRangeId(), + startTime: e.start, + endTime: e.end, + ); + }).toList(), + ); + Navigator.of(context).pop(customClass); + } catch (e) { + showToast(context: context, msg: e.toString()); + } + return; + } + + if (!(week > 0 && week <= 7) || !(start <= stop)) { + showToast( + context: context, + msg: FlutterI18n.translate( + context, + "classtable.class_add.wrong_time_message", + ), + ); + return; + } + + if (widget.toChange == null) { + Navigator.of(context).pop(( + ClassDetail(name: classNameController.text), + TimeArrangement( + source: Source.user, + index: -1, + teacher: teacherNameController.text.isNotEmpty + ? teacherNameController.text + : null, + classroom: classRoomController.text.isNotEmpty + ? classRoomController.text + : null, + weekList: chosenWeek, + day: week, + start: start, + stop: stop, + ), + )); + } else { + Navigator.of(context).pop(( + widget.toChange!.$2, + ClassDetail(name: classNameController.text), + TimeArrangement( + source: Source.user, + index: widget.toChange!.$2.index, + teacher: teacherNameController.text.isNotEmpty + ? teacherNameController.text + : null, + classroom: classRoomController.text.isNotEmpty + ? classRoomController.text + : null, + weekList: chosenWeek, + day: week, + start: start, + stop: stop, + ), + )); + } } @override Widget build(BuildContext context) { + final OutlineInputBorder inputEnabledBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: color.withValues(alpha: 0.25)), + ); + final OutlineInputBorder inputFocusedBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: color, width: 1.2), + ); + final ButtonStyle segmentedButtonStyle = ButtonStyle( + side: WidgetStatePropertyAll( + BorderSide(color: color.withValues(alpha: 0.25)), + ), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ); + return Scaffold( appBar: AppBar( title: Text( - widget.toChange == null + widget.toChange == null && widget.customToChange == null ? FlutterI18n.translate( context, "classtable.class_add.add_class_title", @@ -99,62 +259,7 @@ class _ClassAddWindowState extends State { ), actions: [ TextButton( - onPressed: () async { - if (classNameController.text.isEmpty) { - showToast( - context: context, - msg: FlutterI18n.translate( - context, - "classtable.class_add.class_name_empty_message", - ), - ); - } else if (!(week > 0 && week <= 7) || !(start <= stop)) { - showToast( - context: context, - msg: FlutterI18n.translate( - context, - "classtable.class_add.wrong_time_message", - ), - ); - } else if (widget.toChange == null) { - Navigator.of(context).pop(( - ClassDetail(name: classNameController.text), - TimeArrangement( - source: Source.user, - index: -1, - teacher: teacherNameController.text.isNotEmpty - ? teacherNameController.text - : null, - classroom: classRoomController.text.isNotEmpty - ? classRoomController.text - : null, - weekList: chosenWeek, - day: week, - start: start, - stop: stop, - ), - )); - } else { - Navigator.of(context).pop(( - widget.toChange!.$2, - ClassDetail(name: classNameController.text), - TimeArrangement( - source: Source.user, - index: widget.toChange!.$2.index, - teacher: teacherNameController.text.isNotEmpty - ? teacherNameController.text - : null, - classroom: classRoomController.text.isNotEmpty - ? classRoomController.text - : null, - weekList: chosenWeek, - day: week, - start: start, - stop: stop, - ), - )); - } - }, + onPressed: _save, child: Text( FlutterI18n.translate( context, @@ -164,15 +269,24 @@ class _ClassAddWindowState extends State { ), ], ), - body: ListView( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), - children: [ - Column( + body: Align( + alignment: AlignmentGeometry.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 600), + child: ListView( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + children: [ + Column( children: [ TextField( controller: classNameController, - decoration: inputDecoration.copyWith( - icon: Icon(Icons.calendar_month, color: color), + decoration: InputDecoration( + prefixIcon: Icon(Icons.book, color: color), + enabledBorder: inputEnabledBorder, + focusedBorder: inputFocusedBorder, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + ), hintText: FlutterI18n.translate( context, "classtable.class_add.input_classname_hint", @@ -181,8 +295,13 @@ class _ClassAddWindowState extends State { ).padding(vertical: inputFieldVerticalPadding), TextField( controller: teacherNameController, - decoration: inputDecoration.copyWith( - icon: Icon(Icons.person, color: color), + decoration: InputDecoration( + prefixIcon: Icon(Icons.person, color: color), + enabledBorder: inputEnabledBorder, + focusedBorder: inputFocusedBorder, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + ), hintText: FlutterI18n.translate( context, "classtable.class_add.input_teacher_hint", @@ -191,8 +310,13 @@ class _ClassAddWindowState extends State { ).padding(vertical: inputFieldVerticalPadding), TextField( controller: classRoomController, - decoration: inputDecoration.copyWith( - icon: Icon(Icons.place, color: color), + decoration: InputDecoration( + prefixIcon: Icon(Icons.place, color: color), + enabledBorder: inputEnabledBorder, + focusedBorder: inputFocusedBorder, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + ), hintText: FlutterI18n.translate( context, "classtable.class_add.input_classroom_hint", @@ -200,34 +324,75 @@ class _ClassAddWindowState extends State { ), ).padding(vertical: inputFieldVerticalPadding), ], - ) - .padding(vertical: 8, horizontal: 16) - .card( - margin: const EdgeInsets.symmetric(vertical: 6), - elevation: 0, - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - ), - WeekSelector( - initialWeeks: chosenWeek, - onChanged: (weeks) { - chosenWeek = weeks; - }, - color: color, - ), - TimeSelector( - initialWeek: week, - initialStart: start, - initialStop: stop, - onChanged: (time) { - week = time.$1; - start = time.$2; - stop = time.$3; - }, - color: color, + ).padding(vertical: 8), + if (widget.toChange == null && widget.customToChange == null) + SegmentedButton( + style: segmentedButtonStyle, + showSelectedIcon: false, + segments: [ + ButtonSegment( + value: false, + icon: Icon(Icons.repeat), + label: Text( + FlutterI18n.translate( + context, + "classtable.class_add.repeat_weekly", + ), + ), + ), + ButtonSegment( + value: true, + icon: Icon(Icons.event), + label: Text( + FlutterI18n.translate( + context, + "classtable.class_add.free_time", + ), + ), + ), + ], + selected: {useCustomDateFlow}, + onSelectionChanged: (selection) { + setState(() { + useCustomDateFlow = selection.first; + }); + }, + ).padding(vertical: 6), + if (widget.toChange != null || + (widget.customToChange == null && !useCustomDateFlow)) ...[ + WeekSelector( + initialWeeks: chosenWeek, + onChanged: (weeks) { + chosenWeek = weeks; + }, + color: color, + ), + TimeSelector( + initialWeek: week, + initialStart: start, + initialStop: stop, + onChanged: (time) { + week = time.$1; + start = time.$2; + stop = time.$3; + }, + color: color, + ), + ] else + DateSelectorFree( + initialDates: chosenDates, + semesterStartDay: semesterStartDate, + semesterLength: widget.semesterLength, + onChanged: (dates) { + chosenDates = dates; + }, + color: color, + deleteColor: deleteColor, + enableBorder: true, + ), + ], ), - ], + ), ), ); } diff --git a/lib/page/classtable/class_add/date_selector_free.dart b/lib/page/classtable/class_add/date_selector_free.dart new file mode 100644 index 00000000..1f49cefd --- /dev/null +++ b/lib/page/classtable/class_add/date_selector_free.dart @@ -0,0 +1,542 @@ +// Copyright 2026 Hazuki Keatsu. +// Copyright 2026 Traintime PDA authors. +// SPDX-License-Identifier: MPL-2.0 OR Apache-2.0 + +import 'package:calendar_date_picker2/calendar_date_picker2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_i18n/flutter_i18n.dart'; +import 'package:intl/intl.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:watermeter/page/public_widget/toast.dart'; +import 'package:watermeter/page/public_widget/wheel_choser.dart'; + +class DateSelectorFree extends StatefulWidget { + final List initialDates; + final DateTime semesterStartDay; + final int semesterLength; + final ValueChanged> onChanged; + final Color color; + final Color deleteColor; + final bool enableBorder; + + const DateSelectorFree({ + super.key, + required this.initialDates, + required this.semesterStartDay, + required this.semesterLength, + required this.onChanged, + required this.color, + required this.deleteColor, + this.enableBorder = false, + }); + + @override + State createState() => _DateSelectorFree(); +} + +class _DateSelectorFree extends State { + static const int _earliestInMinutes = 8 * 60 + 30; + static const int _latestInMinutes = 21 * 60 + 25; + + late List chosenDatesRanges; + + DateTime get _semesterFirstDate => + DateUtils.dateOnly(widget.semesterStartDay); + + DateTime get _semesterLastDate => + _semesterFirstDate.add(Duration(days: widget.semesterLength * 7 - 1)); + + bool _isWithinSemester(DateTime date) { + final day = DateUtils.dateOnly(date); + return !day.isBefore(_semesterFirstDate) && !day.isAfter(_semesterLastDate); + } + + @override + void initState() { + super.initState(); + chosenDatesRanges = widget.initialDates + .where((range) => _isWithinSemester(range.start)) + .toList(); + } + + int _minutesOf(TimeOfDay time) => time.hour * 60 + time.minute; + + bool _isInRange(TimeOfDay time) { + final value = _minutesOf(time); + return value >= _earliestInMinutes && value <= _latestInMinutes; + } + + List _allowedMinutes(int hour, {required bool isStart}) { + if (hour == 8) { + return List.generate(30, (index) => index + 30); + } + if (hour == 21) { + final int maxMinute = isStart ? 24 : 25; + return List.generate(maxMinute + 1, (index) => index); + } + return List.generate(60, (index) => index); + } + + TimeOfDay _sanitizeTime(TimeOfDay source, {required bool isStart}) { + int hour = source.hour.clamp(8, 21); + int minute = source.minute; + if (hour == 8 && minute < 30) { + minute = 30; + } + if (hour == 21) { + final int maxMinute = isStart ? 24 : 25; + if (minute > maxMinute) { + minute = maxMinute; + } + } + return TimeOfDay(hour: hour, minute: minute); + } + + Future<(TimeOfDay, TimeOfDay)?> _showRangeTimePicker( + BuildContext context, { + required TimeOfDay initialStart, + required TimeOfDay initialEnd, + required String helpText, + }) async { + TimeOfDay start = _sanitizeTime(initialStart, isStart: true); + TimeOfDay end = _sanitizeTime(initialEnd, isStart: false); + if (_minutesOf(end) <= _minutesOf(start)) { + final int fixedEnd = (_minutesOf(start) + 60).clamp( + _earliestInMinutes + 1, + _latestInMinutes, + ); + end = TimeOfDay(hour: fixedEnd ~/ 60, minute: fixedEnd % 60); + } + + return await showModalBottomSheet<(TimeOfDay, TimeOfDay)>( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (BuildContext builderContext) { + return StatefulBuilder( + builder: (context, setModalState) => SizedBox( + height: 480, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.of(builderContext).pop(), + child: Text( + FlutterI18n.translate(context, 'cancel'), + style: const TextStyle(color: Colors.grey), + ), + ), + Text( + helpText, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + TextButton( + onPressed: () { + if (!_isInRange(start) || !_isInRange(end)) { + showToast( + context: context, + msg: FlutterI18n.translate( + context, + "classtable.class_add.date_selector_free.rule", + ), + ); + return; + } + if (_minutesOf(end) <= _minutesOf(start)) { + showToast( + context: context, + msg: FlutterI18n.translate( + context, + "classtable.class_add.date_selector_free.rule_2", + ), + ); + return; + } + Navigator.of(builderContext).pop((start, end)); + }, + child: Text( + FlutterI18n.translate(context, "confirm"), + style: TextStyle(color: widget.color), + ), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + children: [ + _buildTimeEditor( + keyPrefix: 'start', + isStart: true, + title: FlutterI18n.translate( + context, + "classtable.class_add.date_selector_free.class_start_time", + ), + color: widget.color, + current: start, + onHourChanged: (hour) { + setModalState(() { + final mins = _allowedMinutes(hour, isStart: true); + int minute = start.minute; + if (!mins.contains(minute)) { + minute = mins.first; + } + start = TimeOfDay(hour: hour, minute: minute); + }); + }, + onMinuteChanged: (minute) { + setModalState(() { + start = TimeOfDay(hour: start.hour, minute: minute); + }); + }, + ), + const SizedBox(height: 12), + _buildTimeEditor( + keyPrefix: 'end', + isStart: false, + title: FlutterI18n.translate( + context, + "classtable.class_add.date_selector_free.class_end_time", + ), + color: widget.color, + current: end, + onHourChanged: (hour) { + setModalState(() { + final mins = _allowedMinutes(hour, isStart: false); + int minute = end.minute; + if (!mins.contains(minute)) { + minute = mins.first; + } + end = TimeOfDay(hour: hour, minute: minute); + }); + }, + onMinuteChanged: (minute) { + setModalState(() { + end = TimeOfDay(hour: end.hour, minute: minute); + }); + }, + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildTimeEditor({ + required String keyPrefix, + required bool isStart, + required String title, + required Color color, + required TimeOfDay current, + required ValueChanged onHourChanged, + required ValueChanged onMinuteChanged, + }) { + final allowedHours = List.generate(14, (index) => index + 8); + final allowedMinutes = _allowedMinutes(current.hour, isStart: isStart); + final hourPage = allowedHours.indexOf(current.hour); + final minutePage = allowedMinutes.indexOf(current.minute); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: color.withValues(alpha: 0.08), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(color: color, fontWeight: FontWeight.w600), + ), + SizedBox( + height: 140, + child: Row( + children: [ + Expanded( + child: WheelChoose( + defaultPage: hourPage < 0 ? 0 : hourPage, + changeBookIdCallBack: onHourChanged, + options: allowedHours + .map( + (hour) => WheelChooseOptions( + data: hour, + hint: hour.toString().padLeft(2, '0'), + ), + ) + .toList(), + ), + ), + const Text( + ':', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + Expanded( + child: WheelChoose( + key: ValueKey('$keyPrefix-${current.hour}'), + defaultPage: minutePage < 0 ? 0 : minutePage, + changeBookIdCallBack: onMinuteChanged, + options: allowedMinutes + .map( + (minute) => WheelChooseOptions( + data: minute, + hint: minute.toString().padLeft(2, '0'), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Future _editChosenDate(int index) async { + final old = chosenDatesRanges[index]; + final result = await _showRangeTimePicker( + context, + initialStart: TimeOfDay.fromDateTime(old.start), + initialEnd: TimeOfDay.fromDateTime(old.end), + helpText: FlutterI18n.translate( + context, + "classtable.class_add.date_selector_free.edit_class_time", + ), + ); + + if (!mounted || result == null) return; + final start = DateTime( + old.start.year, + old.start.month, + old.start.day, + result.$1.hour, + result.$1.minute, + ); + final end = DateTime( + old.end.year, + old.end.month, + old.end.day, + result.$2.hour, + result.$2.minute, + ); + setState(() { + chosenDatesRanges[index] = DateTimeRange(start: start, end: end); + }); + widget.onChanged(chosenDatesRanges); + } + + void _deleteChosenDate(int index) { + setState(() { + chosenDatesRanges.removeAt(index); + }); + widget.onChanged(chosenDatesRanges); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + children: [ + Icon(Icons.calendar_month, color: widget.color, size: 16), + Text( + FlutterI18n.translate( + context, + "classtable.class_add.input_week_hint", + ), + ).textStyle(TextStyle(color: widget.color)).padding(left: 4), + ], + ), + const SizedBox(height: 8), + CalendarDatePicker2( + config: CalendarDatePicker2Config( + calendarType: CalendarDatePicker2Type.multi, + hideMonthPickerDividers: true, + firstDate: _semesterFirstDate, + lastDate: _semesterLastDate, + firstDayOfWeek: MaterialLocalizations.of( + context, + ).firstDayOfWeekIndex, + selectedDayHighlightColor: widget.color, + disableModePicker: true, + disableMonthPicker: true, + selectableYearPredicate: (_) => false, + weekdayLabels: [ + FlutterI18n.translate(context, 'weekday.sunday'), + FlutterI18n.translate(context, 'weekday.monday'), + FlutterI18n.translate(context, 'weekday.tuesday'), + FlutterI18n.translate(context, 'weekday.wednesday'), + FlutterI18n.translate(context, 'weekday.thursday'), + FlutterI18n.translate(context, 'weekday.friday'), + FlutterI18n.translate(context, 'weekday.saturday'), + ], + modePickerTextHandler: + ({required DateTime monthDate, bool? isMonthPicker}) { + final monthKey = + 'month.${DateFormat('MMMM', "en_US").format(monthDate).toLowerCase()}'; + final monthName = FlutterI18n.translate( + context, + monthKey, + ); + final year = monthDate.year.toString(); + final yearName = FlutterI18n.translate( + context, + "classtable.semester_switcher.year", + translationParams: {"year": year}, + ); + return "$yearName $monthName"; + }, + ), + value: chosenDatesRanges.map((e) => e.start).toList(), + onValueChanged: (dates) async { + final newBaseDates = dates.whereType().toList(); + final oldBaseDates = chosenDatesRanges + .map((d) => DateUtils.dateOnly(d.start)) + .toList(); + + DateTime? addedDate; + for (var d in newBaseDates) { + if (!oldBaseDates.contains(DateUtils.dateOnly(d))) { + addedDate = d; + break; + } + } + + if (addedDate != null) { + final pickedRange = await _showRangeTimePicker( + context, + initialStart: const TimeOfDay(hour: 8, minute: 30), + initialEnd: const TimeOfDay(hour: 9, minute: 15), + helpText: FlutterI18n.translate( + context, + "classtable.class_add.date_selector_free.choose_class_time", + ), + ); + + if (pickedRange != null) { + final finalStartDate = DateTime( + addedDate.year, + addedDate.month, + addedDate.day, + pickedRange.$1.hour, + pickedRange.$1.minute, + ); + final finalEndDate = DateTime( + addedDate.year, + addedDate.month, + addedDate.day, + pickedRange.$2.hour, + pickedRange.$2.minute, + ); + + setState(() { + chosenDatesRanges.add( + DateTimeRange(start: finalStartDate, end: finalEndDate), + ); + }); + widget.onChanged(chosenDatesRanges); + } else { + setState(() {}); + } + } else { + setState(() { + chosenDatesRanges.removeWhere( + (d) => !newBaseDates + .map((nd) => DateUtils.dateOnly(nd)) + .contains(DateUtils.dateOnly(d.start)), + ); + }); + widget.onChanged(chosenDatesRanges); + } + }, + ), + if (chosenDatesRanges.isNotEmpty) + ...List.generate(chosenDatesRanges.length, (index) { + final d = chosenDatesRanges[index]; + return Padding( + padding: EdgeInsetsGeometry.only(top: 8), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _editChosenDate(index), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: widget.color.withValues(alpha: 0.08), + border: Border.all( + color: widget.color.withValues(alpha: 0.25), + ), + ), + child: Row( + children: [ + Icon(Icons.schedule, color: widget.color, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + '${DateFormat('yyyy-MM-dd').format(d.start)} ' + '${DateFormat('HH:mm').format(d.start)}-${DateFormat('HH:mm').format(d.end)}', + style: TextStyle( + color: widget.color, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: Icon( + Icons.delete_outline, + color: widget.deleteColor, + size: 18, + ), + onPressed: () => _deleteChosenDate(index), + ), + ], + ), + ), + ), + ), + ); + }), + ], + ) + .padding(all: 12) + .decorated( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: widget.color.withValues(alpha: 0.25), + width: widget.enableBorder ? 1.0 : 0.0, + ), + ) + .padding(vertical: 6); + } +} diff --git a/lib/page/classtable/class_add/time_selector.dart b/lib/page/classtable/class_add/time_selector.dart index 36da1118..60e32509 100644 --- a/lib/page/classtable/class_add/time_selector.dart +++ b/lib/page/classtable/class_add/time_selector.dart @@ -47,120 +47,123 @@ class _TimeSelectorState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - Row( - children: [ - Icon(Icons.schedule, color: widget.color, size: 16), - Text( - FlutterI18n.translate( - context, - "classtable.class_add.input_time_hint", + return Container( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Column( + children: [ + Row( + children: [ + Icon(Icons.schedule, color: widget.color, size: 16), + Text( + FlutterI18n.translate( + context, + "classtable.class_add.input_time_hint", + ), + ).textStyle(TextStyle(color: widget.color)).padding(left: 4), + ], + ), + const SizedBox(height: 8), + Column( + children: [ + Row( + children: [ + Text( + FlutterI18n.translate( + context, + "classtable.class_add.input_time_weekday_hint", + ), + ) + .textStyle(TextStyle(color: widget.color)) + .center() + .flexible(), + Text( + FlutterI18n.translate( + context, + "classtable.class_add.input_start_time_hint", + ), + ) + .textStyle(TextStyle(color: widget.color)) + .center() + .flexible(), + Text( + FlutterI18n.translate( + context, + "classtable.class_add.input_end_time_hint", + ), + ) + .textStyle(TextStyle(color: widget.color)) + .center() + .flexible(), + ], ), - ).textStyle(TextStyle(color: widget.color)).padding(left: 4), - ], - ), - const SizedBox(height: 8), - Column( - children: [ - Row( - children: [ - Text( - FlutterI18n.translate( - context, - "classtable.class_add.input_time_weekday_hint", + Row( + children: [ + WheelChoose( + changeBookIdCallBack: (choiceWeek) { + setState(() => week = choiceWeek + 1); + _notifyChange(); + }, + defaultPage: week - 1, + options: List.generate( + 7, + (index) => WheelChooseOptions( + data: index, + hint: getWeekString(context, index), ), - ) - .textStyle(TextStyle(color: widget.color)) - .center() - .flexible(), - Text( - FlutterI18n.translate( - context, - "classtable.class_add.input_start_time_hint", - ), - ) - .textStyle(TextStyle(color: widget.color)) - .center() - .flexible(), - Text( - FlutterI18n.translate( - context, - "classtable.class_add.input_end_time_hint", - ), - ) - .textStyle(TextStyle(color: widget.color)) - .center() - .flexible(), - ], - ), - Row( - children: [ - WheelChoose( - changeBookIdCallBack: (choiceWeek) { - setState(() => week = choiceWeek + 1); - _notifyChange(); - }, - defaultPage: week - 1, - options: List.generate( - 7, - (index) => WheelChooseOptions( - data: index, - hint: getWeekString(context, index), ), - ), - ).flexible(), - WheelChoose( - changeBookIdCallBack: (choiceStart) { - setState(() => start = choiceStart); - _notifyChange(); - }, - defaultPage: start - 1, - options: List.generate( - 11, - (index) => WheelChooseOptions( - data: index + 1, - hint: FlutterI18n.translate( - context, - "classtable.class_add.wheel_choose_hint", - translationParams: { - "index": (index + 1).toString(), - }, + ).flexible(), + WheelChoose( + changeBookIdCallBack: (choiceStart) { + setState(() => start = choiceStart); + _notifyChange(); + }, + defaultPage: start - 1, + options: List.generate( + 11, + (index) => WheelChooseOptions( + data: index + 1, + hint: FlutterI18n.translate( + context, + "classtable.class_add.wheel_choose_hint", + translationParams: { + "index": (index + 1).toString(), + }, + ), ), ), - ), - ).flexible(), - WheelChoose( - changeBookIdCallBack: (choiceStop) { - setState(() => stop = choiceStop); - _notifyChange(); - }, - defaultPage: stop - 1, - options: List.generate( - 11, - (index) => WheelChooseOptions( - data: index + 1, - hint: FlutterI18n.translate( - context, - "classtable.class_add.wheel_choose_hint", - translationParams: { - "index": (index + 1).toString(), - }, + ).flexible(), + WheelChoose( + changeBookIdCallBack: (choiceStop) { + setState(() => stop = choiceStop); + _notifyChange(); + }, + defaultPage: stop - 1, + options: List.generate( + 11, + (index) => WheelChooseOptions( + data: index + 1, + hint: FlutterI18n.translate( + context, + "classtable.class_add.wheel_choose_hint", + translationParams: { + "index": (index + 1).toString(), + }, + ), ), ), - ), - ).flexible(), - ], - ), - ], - ), - ], - ) - .padding(all: 12) - .card( - margin: const EdgeInsets.symmetric(vertical: 6), - elevation: 0, - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - ); + ).flexible(), + ], + ), + ], + ), + ], + ) + .padding(all: 12) + .decorated( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: widget.color.withValues(alpha: 0.25)), + ), + ); } } diff --git a/lib/page/classtable/class_add/week_selector.dart b/lib/page/classtable/class_add/week_selector.dart index ca4a1665..1a53e18d 100644 --- a/lib/page/classtable/class_add/week_selector.dart +++ b/lib/page/classtable/class_add/week_selector.dart @@ -50,39 +50,47 @@ class _WeekSelectorState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - Row( - children: [ - Icon(Icons.calendar_month, color: widget.color, size: 16), - Text( - FlutterI18n.translate( - context, - "classtable.class_add.input_week_hint", + return Container( + margin: EdgeInsets.fromLTRB(0, 6, 0, 6), + child: + Column( + children: [ + Row( + children: [ + Icon(Icons.calendar_month, color: widget.color, size: 16), + Text( + FlutterI18n.translate( + context, + "classtable.class_add.input_week_hint", + ), + ) + .textStyle(TextStyle(color: widget.color)) + .padding(left: 4), + ], ), - ).textStyle(TextStyle(color: widget.color)).padding(left: 4), - ], - ), - const SizedBox(height: 8), - GridView.extent( - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - mainAxisSpacing: 4, - crossAxisSpacing: 4, - maxCrossAxisExtent: 30, - children: List.generate( - chosenWeek.length, - (index) => weekDoc(index: index), + const SizedBox(height: 8), + GridView.extent( + padding: EdgeInsets.zero, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + maxCrossAxisExtent: 30, + children: List.generate( + chosenWeek.length, + (index) => weekDoc(index: index), + ), + ), + ], + ) + .padding(all: 12) + .decorated( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: widget.color.withValues(alpha: 0.25)), ), - ), - ], - ) - .padding(all: 12) - .card( - margin: const EdgeInsets.symmetric(vertical: 6), - elevation: 0, - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), - ); + ); } } diff --git a/lib/page/classtable/class_page/content_classtable_page.dart b/lib/page/classtable/class_page/content_classtable_page.dart index f23feb5b..5f8aaca2 100644 --- a/lib/page/classtable/class_page/content_classtable_page.dart +++ b/lib/page/classtable/class_page/content_classtable_page.dart @@ -11,6 +11,7 @@ import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:watermeter/model/pda_service/custom_class.dart'; import 'package:watermeter/model/xidian_ids/classtable.dart'; import 'package:watermeter/page/classtable/class_add/class_add_window.dart'; import 'package:watermeter/page/classtable/class_page/class_change_list.dart'; @@ -359,22 +360,23 @@ class _ContentClassTablePageState extends State { int semesterLength = ClassTableState.of( context, )!.controllers.semesterLength; - (ClassDetail, TimeArrangement)? data = - await Navigator.of( - context, - ).push<(ClassDetail, TimeArrangement)>( - MaterialPageRoute( - builder: (BuildContext context) { - return ClassAddWindow( - semesterLength: semesterLength, - ); - }, - ), - ); + dynamic data = await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return ClassAddWindow(semesterLength: semesterLength); + }, + ), + ); if (context.mounted && data != null) { - await ClassTableState.of( - context, - )!.controllers.addUserDefinedClass(data.$1, data.$2); + if (data is (ClassDetail, TimeArrangement)) { + await ClassTableState.of( + context, + )!.controllers.addUserDefinedClass(data.$1, data.$2); + } else if (data is CustomClass) { + await ClassTableState.of( + context, + )!.controllers.addCustomClass(data); + } } break; case 'D': diff --git a/lib/page/classtable/class_table_view/class_card.dart b/lib/page/classtable/class_table_view/class_card.dart index 59dbe0a7..129c3d83 100644 --- a/lib/page/classtable/class_table_view/class_card.dart +++ b/lib/page/classtable/class_table_view/class_card.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_i18n/flutter_i18n.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:watermeter/model/pda_service/custom_class.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'; @@ -58,43 +59,59 @@ class ClassCard extends StatelessWidget { /// The way to show the class info of the period. /// The last one indicate whether to delete this stuff. - (ClassDetail, TimeArrangement, bool)? toUse = - await BothSideSheet.show( - title: FlutterI18n.translate( - context, - "classtable.class_card.title", - ), - child: ArrangementDetail( - information: List.generate(data.length, (index) { - if (data.elementAt(index) is Subject || - data.elementAt(index) is ExperimentData) { - return data.elementAt(index); - } else { - return ( - classTableState.getClassDetail( - classTableState.timeArrangement.indexOf( - data.elementAt(index), - ), - ), - data.elementAt(index), - ); - } - }), - currentWeek: classTableState.currentWeek, - ), - context: context, - ); - if (context.mounted && toUse != null) { - if (toUse.$3) { + final action = await BothSideSheet.show( + title: FlutterI18n.translate( + context, + "classtable.class_card.title", + ), + child: ArrangementDetail( + information: List.generate(data.length, (index) { + if (data.elementAt(index) is Subject || + data.elementAt(index) is ExperimentData) { + return data.elementAt(index); + } else if (data.elementAt(index) + is ( + CustomClass, + CustomClassTimeRange, + MaterialColor, + )) { + return data.elementAt(index); + } else if (data.elementAt(index) + is (CustomClass, CustomClassTimeRange)) { + return data.elementAt(index); + } else if (data.elementAt(index) + is TimeArrangement) { + final TimeArrangement arrangement = data + .elementAt(index); + return ( + classTableState.getClassDetail( + classTableState.timeArrangement.indexOf( + arrangement, + ), + ), + arrangement, + ); + } else { + return data.elementAt(index); + } + }), + currentWeek: classTableState.currentWeek, + ), + context: context, + ); + if (!context.mounted || action == null) return; + + if (action is (ClassDetail, TimeArrangement, bool)) { + if (action.$3) { await ClassTableState.of( context, - )!.controllers.deleteUserDefinedClass(toUse.$2); + )!.controllers.deleteUserDefinedClass(action.$2); } else { await Navigator.of(context) .push( MaterialPageRoute( builder: (context) => ClassAddWindow( - toChange: (toUse.$1, toUse.$2), + toChange: (action.$1, action.$2), semesterLength: controller.semesterLength, ), ), @@ -108,6 +125,41 @@ class ClassCard extends StatelessWidget { ); }); } + } else if (action is (String, String?, String)) { + final int customIndex = controller.customClasses + .indexWhere((custom) => custom.id == action.$1); + if (customIndex < 0) return; + + if (action.$3 == 'delete_all') { + await controller.deleteCustomClassById(action.$1); + } else if (action.$3 == 'delete_one') { + final String? timeRangeId = action.$2; + if (timeRangeId == null) return; + await controller.deleteCustomClassTimeRange( + customClassId: action.$1, + timeRangeId: timeRangeId, + ); + } else if (action.$3 == 'edit') { + final CustomClass customClass = + controller.customClasses[customIndex]; + await Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => ClassAddWindow( + customToChange: customClass, + semesterLength: controller.semesterLength, + ), + ), + ) + .then((value) async { + if (value is CustomClass) { + await controller.editCustomClassById( + action.$1, + value, + ); + } + }); + } } }, child: diff --git a/lib/page/classtable/class_table_view/class_organized_data.dart b/lib/page/classtable/class_table_view/class_organized_data.dart index a7fc8054..ae35b5db 100644 --- a/lib/page/classtable/class_table_view/class_organized_data.dart +++ b/lib/page/classtable/class_table_view/class_organized_data.dart @@ -6,6 +6,7 @@ // Removed left/right, only use stack. import 'package:flutter/material.dart'; +import 'package:watermeter/model/pda_service/custom_class.dart'; import 'package:watermeter/model/xidian_ids/exam.dart'; import 'package:watermeter/model/xidian_ids/classtable.dart'; import 'package:watermeter/model/xidian_ids/experiment.dart'; @@ -112,6 +113,19 @@ class ClassOrgainzedData { place: exp.classroom, ); + factory ClassOrgainzedData.fromCustomClass( + MaterialColor color, + CustomClass customClass, + CustomClassTimeRange timeRange, + ) => ClassOrgainzedData._( + data: [(customClass, timeRange, color)], + start: timeRange.startTime, + stop: timeRange.endTime, + color: color, + name: customClass.name, + place: customClass.classroom, + ); + ClassOrgainzedData({ required this.data, required this.start, diff --git a/lib/page/classtable/classtable_state.dart b/lib/page/classtable/classtable_state.dart index 68ec599a..09fa0aeb 100644 --- a/lib/page/classtable/classtable_state.dart +++ b/lib/page/classtable/classtable_state.dart @@ -11,10 +11,12 @@ import 'package:signals/signals.dart'; import 'package:timezone/data/latest.dart' as tz; import 'package:watermeter/controller/classtable_controller.dart'; +import 'package:watermeter/controller/custom_class_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/week_swift_controller.dart'; +import 'package:watermeter/model/pda_service/custom_class.dart'; import 'package:watermeter/model/time_list.dart'; import 'package:watermeter/model/xidian_ids/classtable.dart'; import 'package:watermeter/model/xidian_ids/exam.dart'; @@ -85,6 +87,7 @@ class ClassTableWidgetState with ChangeNotifier { /// The controller... final ClassTableController classTableController = ClassTableController.i; + final CustomClassController customClassController = CustomClassController.i; final ExamController examController = ExamController.i; final PhysicsExperimentController physicsExperimentController = PhysicsExperimentController.i; @@ -99,6 +102,7 @@ class ClassTableWidgetState with ChangeNotifier { classTableController.classTableComputedSignal.value; classTableController.isClassTableFromCacheComputedSignal.value; classTableController.classTableCacheHintKeyComputedSignal.value; + customClassController.customClassesSignal.value; examController.examInfoStateSignal.value; examController.subjects.value; examController.isExamFromCache.value; @@ -276,6 +280,9 @@ class ClassTableWidgetState with ChangeNotifier { ...otherExperimentController.otherExperiments.value, ]; + /// The custom class list. + List get customClasses => customClassController.customClasses; + /// Get class detail by prividing index of timearrangement ClassDetail getClassDetail(int index) => classTableController .classTableComputedSignal @@ -304,6 +311,37 @@ class ClassTableWidgetState with ChangeNotifier { .deleteUserDefinedClass(timeArrangement) .then((value) => notifyListeners()); + Future addCustomClass(CustomClass customClass) => + customClassController.addCustomClass(customClass).then((_) { + notifyListeners(); + }); + + Future editCustomClassById( + String customClassId, + CustomClass customClass, + ) => customClassController + .editCustomClassById(customClassId, customClass) + .then((_) { + notifyListeners(); + }); + + Future deleteCustomClassById(String customClassId) => + customClassController.deleteCustomClassById(customClassId).then((_) { + notifyListeners(); + }); + + Future deleteCustomClassTimeRange({ + required String customClassId, + required String timeRangeId, + }) => customClassController + .deleteCustomClassTimeRange( + customClassId: customClassId, + timeRangeId: timeRangeId, + ) + .then((_) { + notifyListeners(); + }); + List get events { List events = []; @@ -441,6 +479,22 @@ class ClassTableWidgetState with ChangeNotifier { ); } } + + for (final customClass in customClasses) { + for (final timeRange in customClass.timeRanges) { + events.add( + Event( + null, + title: '${customClass.name}@${customClass.classroom ?? "待定"}', + description: + '自定义课程:${customClass.name} - 老师:${customClass.teacher ?? "未知"}', + start: TZDateTime.from(timeRange.startTime, currentLocation), + end: TZDateTime.from(timeRange.endTime, currentLocation), + location: customClass.classroom, + ), + ); + } + } return events; } @@ -668,6 +722,24 @@ END:VTIMEZONE } } + final customOccurrences = customClassController.getOccurrenceOfDay( + weekIndex: weekIndex, + dayIndex: dayIndex, + semesterStartDay: startDay, + ); + for (final occurrence in customOccurrences) { + final int colorIndex = customClasses.indexWhere( + (item) => item.id == occurrence.customClass.id, + ); + events.add( + ClassOrgainzedData.fromCustomClass( + colorList[(colorIndex >= 0 ? colorIndex : 0) % colorList.length], + occurrence.customClass, + occurrence.timeRange, + ), + ); + } + /// Sort it with the ascending order of start time. events.sort((a, b) => a.start.compareTo(b.start)); diff --git a/lib/page/setting/setting.dart b/lib/page/setting/setting.dart index c523b6cf..13eee685 100644 --- a/lib/page/setting/setting.dart +++ b/lib/page/setting/setting.dart @@ -826,7 +826,7 @@ class _SettingWindowState extends State { onTap: () => context.push(TalkerScreen(talker: log)), ), const Divider(), - if (Platform.isAndroid || Platform.isIOS) + if (Platform.isAndroid || Platform.isIOS) ...[ ListTile( title: Text( FlutterI18n.translate( @@ -837,7 +837,8 @@ class _SettingWindowState extends State { trailing: const Icon(Icons.navigate_next), onTap: () => context.push(NotificationDebugPage()), ), - const Divider(), + const Divider(), + ], ListTile( title: Text( FlutterI18n.translate(context, "setting.clear_and_restart"), diff --git a/lib/repository/notification/course_reminder_service.dart b/lib/repository/notification/course_reminder_service.dart index dc81782b..13f003d3 100644 --- a/lib/repository/notification/course_reminder_service.dart +++ b/lib/repository/notification/course_reminder_service.dart @@ -5,11 +5,11 @@ // Course reminder notification service implementation import 'dart:convert'; -import 'dart:math'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:watermeter/controller/classtable_controller.dart'; +import 'package:watermeter/controller/custom_class_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'; @@ -56,9 +56,6 @@ class CourseReminderService extends NotificationService static const int _notificationIdPrefix = 10; static const int _notificationIdBase = 10000000; - static const int _notificationRandomMin = 10; - static const int _notificationRandomRange = 90; - final Random _random = Random(); // Configuration getters - encapsulate preference access bool get isEnabled => @@ -267,19 +264,23 @@ class CourseReminderService extends NotificationService } } - /// Generate the notification ID for course - int _generateNotificationId(int weekday, int startClass, int weekIndex) { - // return _notificationIdPrefix * 10000 + - // weekday * 10000 + - // startClass * 100 + - // weekIndex; - final randomSuffix = - _random.nextInt(_notificationRandomRange) + _notificationRandomMin; - return _notificationIdPrefix * _notificationIdBase + - weekIndex * 100000 + - weekday * 10000 + - startClass * 100 + - randomSuffix; + /// Generate a stable notification ID from a unique key. + /// + /// IDs stay in the same numeric bucket so `isCourseReminderNotificationId` + /// continues to work, while removing random collisions from parallel + /// scheduling. + int _generateNotificationId(String uniqueKey) { + const int fnvOffsetBasis = 0x811C9DC5; + const int fnvPrime = 0x01000193; + + int hash = fnvOffsetBasis; + for (final codeUnit in uniqueKey.codeUnits) { + hash ^= codeUnit; + hash = (hash * fnvPrime) & 0x7fffffff; + } + + final int minId = _notificationIdPrefix * _notificationIdBase; + return minId + (hash % _notificationIdBase); } static bool isCourseReminderNotificationId(int id) { @@ -312,31 +313,6 @@ class CourseReminderService extends NotificationService ); } - /// Calculate which class period the time corresponds to - /// Returns the class index (1-based), or 1 if no match found - int _calculateClassPeriodFromTime(DateTime time) { - final timeInMinutes = time.hour * 60 + time.minute; - - // Find the closest matching class start time - int closestClass = 1; - int minDifference = 999999; - - for (int i = 0; i < timeList.length; i += 2) { - final classIndex = (i ~/ 2) + 1; // Convert to 1-based class number - final timeStr = timeList[i]; - final parts = timeStr.split(':'); - final classStartMinutes = int.parse(parts[0]) * 60 + int.parse(parts[1]); - - final difference = (timeInMinutes - classStartMinutes).abs(); - if (difference < minDifference) { - minDifference = difference; - closestClass = classIndex; - } - } - - return closestClass; - } - String _getCurrentLocale() { // Get current locale from preference String locale = preference.getString(preference.Preference.localization); @@ -371,6 +347,8 @@ class CourseReminderService extends NotificationService final classTableData = ClassTableController.i.classTableComputedSignal.value; final hasClassTableData = classTableData.termStartDay.isNotEmpty; + final hasCustomClassData = + CustomClassController.i.customClassesSignal.value.isNotEmpty; final hasExperimentData = PhysicsExperimentController.i.physicsExperiments.value.isNotEmpty || @@ -380,7 +358,116 @@ class CourseReminderService extends NotificationService (subject) => subject.startTime != null, ); - return hasClassTableData || hasExperimentData || hasExamData; + return hasClassTableData || + hasCustomClassData || + hasExperimentData || + hasExamData; + } + + Future _scheduleNotificationFromCustomCourseData({ + int daysToSchedule = 7, + int minutesBefore = 5, + }) async { + log.info( + '[CourseReminderService] [scheduleNotificationsFromCustomCourseData] Starting to schedule notifications (daysToSchedule: $daysToSchedule, minutesBefore: $minutesBefore)...', + ); + try { + final controller = CustomClassController.i; + final classTableController = ClassTableController.i; + + final now = DateTime.now(); + final endDate = now.add(Duration(days: daysToSchedule)); + + final data = controller.customClasses; + + if (data.isEmpty) { + log.warning( + '[CourseReminderService] [scheduleNotificationsFromCustomCourseData] CustomClass data is empty.', + ); + return; + } + + final String locale = _getCurrentLocale(); + int scheduledCount = 0; + + for (final customClass in data) { + for (final timeRange in customClass.timeRanges) { + final DateTime classStartTime = timeRange.startTime; + + if (classStartTime.isBefore(now) || classStartTime.isAfter(endDate)) { + continue; + } + + final DateTime notificationTime = classStartTime.subtract( + Duration(minutes: minutesBefore), + ); + + if (notificationTime.isBefore(now)) { + continue; + } + + int weekIndex = 0; + weekIndex = classTableController.getCurrentWeek(classStartTime); + if (weekIndex < 0) { + weekIndex = 0; + } + + final int notificationId = _generateNotificationId( + 'custom|${customClass.id}|${timeRange.id}|' + '${classStartTime.toIso8601String()}|$minutesBefore|$weekIndex', + ); + + String title = NonUII18n.translate( + locale, + 'course_reminder.title', + translateParams: {'name': customClass.name}, + ); + + String body = NonUII18n.translate( + locale, + 'course_reminder.body', + translateParams: {'time': minutesBefore.toString()}, + ); + + if (customClass.classroom != null && + customClass.classroom!.isNotEmpty) { + body += + '\n${NonUII18n.translate(locale, 'course_reminder.location', translateParams: {"location": customClass.classroom!})}'; + } + if (customClass.teacher != null && customClass.teacher!.isNotEmpty) { + body += + '\n${NonUII18n.translate(locale, 'course_reminder.teacher', translateParams: {"teacher": customClass.teacher!})}'; + } + + final Map payload = { + 'type': 'course_reminder', + 'className': customClass.name, + 'weekIndex': weekIndex, + }; + + await scheduleNotification( + id: notificationId, + title: title, + body: body, + scheduledTime: notificationTime, + payload: jsonEncode(payload), + ); + + scheduledCount++; + } + } + + log.info( + '[CourseReminderService] [scheduleNotificationsFromCustomCourseData] Scheduled $scheduledCount custom course reminder notifications', + ); + } catch (e, stackTrace) { + log.error( + '[CourseReminderService] [scheduleNotificationsFromCustomCourseData] Failed to schedule custom course reminder notifications', + e, + stackTrace, + ); + rethrow; + } } Future _scheduleNotificationFromCourseData({ @@ -448,9 +535,8 @@ class CourseReminderService extends NotificationService ClassDetail classDetail = data.getClassDetail(timeArrangement); int notificationId = _generateNotificationId( - timeArrangement.day, - timeArrangement.start, - weekIndex, + 'course|${timeArrangement.index}|${classDetail.name}|' + '${classStartTime.toIso8601String()}|$minutesBefore|$weekIndex', ); String locale = _getCurrentLocale(); @@ -576,16 +662,12 @@ class CourseReminderService extends NotificationService ); if (weekIndex < 0) weekIndex = 0; - int weekday = experimentStartTime.weekday; // 1=Mon, 7=Sun - - // Calculate which class period this experiment corresponds to - int startClass = _calculateClassPeriodFromTime(experimentStartTime); - - // Use a unique ID based on experiment start time to avoid conflicts + // Use a stable unique ID to avoid collisions across different + // notification sources. int notificationId = _generateNotificationId( - weekday, - startClass, - weekIndex, + 'experiment|$experimentIndex|$timeRangeIndex|' + '${experiment.name}|${experimentStartTime.toIso8601String()}|' + '$minutesBefore|$weekIndex', ); String locale = _getCurrentLocale(); @@ -686,12 +768,9 @@ class CourseReminderService extends NotificationService int weekIndex = classTableController.getCurrentWeek(examStartTime); if (weekIndex < 0) weekIndex = 0; - final weekday = examStartTime.weekday; - final startClass = _calculateClassPeriodFromTime(examStartTime); final notificationId = _generateNotificationId( - weekday, - startClass, - weekIndex, + 'exam|${exam.subject}|${exam.typeStr}|${exam.place}|' + '${examStartTime.toIso8601String()}|$minutesBefore|$weekIndex', ); final locale = _getCurrentLocale(); @@ -748,12 +827,16 @@ class CourseReminderService extends NotificationService int minutesBefore = 5, }) async { try { - // Schedule course, experiment and exam notifications in parallel + // Schedule course, custom course, experiment, and exam notifications in parallel. await Future.wait([ _scheduleNotificationFromCourseData( daysToSchedule: daysToSchedule, minutesBefore: minutesBefore, ), + _scheduleNotificationFromCustomCourseData( + daysToSchedule: daysToSchedule, + minutesBefore: minutesBefore, + ), if (enableExperimentNotifications) _scheduleNotificationFromExperimentData( daysToSchedule: daysToSchedule,