From 4d86db3dd5f38f7ee1cecf97982239045ac0658e Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 14 Oct 2026 11:30:37 +0800 Subject: [PATCH] Fix release notification resource and add multiple advance reminders --- README.md | 8 +- docs/tpschedule-0.5.5-validation.md | 21 +++++ mobile/android/app/src/main/res/raw/keep.xml | 4 + mobile/lib/app/app_controller.dart | 7 +- mobile/lib/domain/app_settings.dart | 21 +++-- mobile/lib/domain/app_snapshot.dart | 19 +++-- .../lib/features/profile/settings_page.dart | 30 +++++--- .../services/local_notification_service.dart | 77 +++++++++++++------ mobile/pubspec.yaml | 2 +- .../data/multiple_reminder_settings_test.dart | 40 ++++++++++ mobile/test/data/snapshot_store_test.dart | 10 +-- .../services/notification_gateway_test.dart | 53 +++++++++++++ .../services/notification_service_test.dart | 73 ++++++++++++++++++ .../test/widgets/conflict_metadata_test.dart | 4 +- mobile/test/widgets/settings_test.dart | 37 +++++++++ mobile/test/widgets/timetable_test.dart | 2 +- 16 files changed, 350 insertions(+), 58 deletions(-) create mode 100644 docs/tpschedule-0.5.5-validation.md create mode 100644 mobile/android/app/src/main/res/raw/keep.xml create mode 100644 mobile/test/data/multiple_reminder_settings_test.dart diff --git a/README.md b/README.md index 56763cb..abf2262 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TPSchedule -学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.5.4+12,Android 7.0 及以上。 +学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.5.5+13,Android 7.0 及以上。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 @@ -14,6 +14,10 @@ 0.5.4主要缩小Dock高度,由68降至58,宽度保持264。Dock改为常驻。课表向左滑动下一周、向右滑动上一周,一次手势只换一周,限制在设置的学期周数内;箭头和“本周”按钮保留。未设置学期开始日时仍提示设置。详见[0.5.4验证记录](docs/tpschedule-0.5.4-validation.md)。 +0.5.5修复发布包裁剪通知图标导致提醒初始化失败的问题。课前提醒可多选5/10/15/30/60分钟,各时间点独立安排通知;旧单选设置自动保留,至少选择一项。设置页显示待提醒次数、最近提醒时刻以及通知类别/精确提醒权限状态。详见[0.5.5验证记录](docs/tpschedule-0.5.5-validation.md)。 + +0.5.4源码和安装包已冻结用于登记,位于dist/backups/TPSchedule-0.5.4-registration-20261014,Git标签tpschedule-0.5.4-registration。软著材料登记版本按用户要求为V1.0,使用冻结源码,未混入0.5.5功能,另存于output/pdf/TPSchedule-软著登记材料-V1.0。 + ## 安装与学校登录 安装 `dist/TPSchedule.apk`(release运行模式),或使用 `dist/TPSchedule-debug.apk` 测试。Android包名已按用户确认改为 `com.tpschedule.app`,显示名称为TPSchedule。它与旧 `com.example.hepan_everyday` 是两个独立应用,不能覆盖安装旧RSchedule;旧应用与数据保留,新应用需要重新导入课程并设置日期。 @@ -40,7 +44,7 @@ 「个人设置 → 从文件导入」支持学校课表 `.html` / `.htm` 与版本1的 JSON,先预览再确认;考试文件从考试页导入。文件最多2MB,UTF-8编码;其中脚本不会执行。 -HTML没有学期开始日,仍须在课表设置填写实际开始日和学期周数。离线文件只有保存时那个学期的数据,预览显示实际学期;其他学期通过学校在线查询获取。JSON 支持可选 `schoolTerm: {"id": 73}` 并兼容旧课表编号推断,不允许混合学校学期。默认课前15分钟提醒,可选5/10/15/30/60分钟。启用通知和手机日历时按提示授权。JSON样例:[schedule.schema-v1.json](examples/schedule.schema-v1.json)。示例课表不覆盖本人数据,不创建通知或日历事件。 +HTML没有学期开始日,仍须在课表设置填写实际开始日和学期周数。离线文件只有保存时那个学期的数据,预览显示实际学期;其他学期通过学校在线查询获取。JSON 支持可选 `schoolTerm: {"id": 73}` 并兼容旧课表编号推断,不允许混合学校学期。默认课前15分钟提醒,可多选5/10/15/30/60分钟。启用通知和手机日历时按提示授权。JSON样例:[schedule.schema-v1.json](examples/schedule.schema-v1.json)。示例课表不覆盖本人数据,不创建通知或日历事件。 0.4.0 修复点按滑块重复回弹及速度形变:点按平滑移动,拖动和按住时放大约30%,松手弹性回位。0.5.0让Dock底座和滑块呈现较通透的液态玻璃,加入实时边缘反光,降低模糊和灰色覆盖,让背景轮廓与色彩更清楚。支持的渲染方式组合折射与轻度高斯模糊,其他方式保留轻度背景模糊和玻璃亮边。详见 [0.5.0验证记录](docs/tpschedule-0.5-validation.md)。 diff --git a/docs/tpschedule-0.5.5-validation.md b/docs/tpschedule-0.5.5-validation.md new file mode 100644 index 0000000..322437c --- /dev/null +++ b/docs/tpschedule-0.5.5-validation.md @@ -0,0 +1,21 @@ +# TPSchedule Android 0.5.5+13:通知修复与多时间点提醒 + +日期:2026-10-14。用户测试设备为vivo X200 Pro、Android 16;本次原生验证使用Android API36模拟器。 + +0.5.4已先行冻结:安装包、调试包、源码、签名公开信息及登记材料复制到dist/backups/TPSchedule-0.5.4-registration-20261014,并建立Git标签tpschedule-0.5.4-registration,指向caf7abe0c01b3be9e7afed96f5c03f7cd59eaf55。所有原文件有SHA256清单,原私钥未导出。 + +用户随后要求登记版本改为V1.0。程序材料和使用说明书使用冻结源码重新生成,仍为5902个非空源码行、60页程序和9页说明书。功能描述扩写为882个字符、其中775个中文字符,未将0.5.5多选提醒写入V1.0冻结材料。V1.0材料另存并备份,原0.5.4冻结目录不改动。 + +通知根因:0.5.4发布包资源表没有ic_stat_calendar,调试包资源表存在该图标。通知插件按名称查找图标,缺失时initialize返回invalid_icon,导致真实发布包不能建立提醒。新增res/raw/keep.xml明确保留通知图标,0.5.5发布包资源表确认图标存在。 + +课前提醒改为多选5、10、15、30、60分钟,默认15,至少保留一个选项。新快照保存reminderOffsets不可变列表,兼容旧reminderMinutes字段并保留用户旧选择。每门课程每个时间点独立ID;15分钟保留原有ID,其他时间点使用包含分钟的标识。通知点击仍用原课程键进入详情。去掉的选项清理对应通知;过期时间点跳过,其他尚未来临的时间点继续安排。400次上限按全部提醒的实际触发时刻排序选取。 + +设置页显示已安排次数和最近提醒时刻;缺少学期日期、没有待提醒课程、精确提醒权限不足时显示对应提示。系统关闭课前提醒通知类别时按通知不可用处理,避免只检查总通知权限后显示成功。 + +新测试在修改前复现单时间点调度和缺失多选保存行为;之后158项完整Flutter测试通过,静态分析无问题。覆盖多次调度、取消选项、保留未来提醒、400次排序、旧文件迁移、非法设置拒绝、输入列表隔离、UI多选及保留最后选项、关闭通知类别。时间比较采用同一时刻语义,避免TZDateTime与DateTime的类型相等差异。独立源码审查无可操作问题。 + +发布与调试包构建通过。Android发布包覆盖安装后,使用隔离的14:30课程及5/15分钟配置;系统AlarmManager记录两个独立RTC_WAKEUP精确任务。将应用放到后台,在14:15和14:25分别收到“15分钟后上课”和“5分钟后上课”的课程通知。通知ID不同且同时保留,验证没有互相覆盖。截图位于dist/screenshots/tpschedule-reminder-first.png、tpschedule-reminder-both.png与tpschedule-reminder-settings.png。测试控制了模拟器时间以缩短等待,结束后恢复原课表和时间设置。 + +签名与公钥与冻结0.5.4完全一致。包名com.tpschedule.app,versionName0.5.5、versionCode13,最低Android API24。发布APK SHA256:62AF7B0B46BCF7DBBC3AFB7069D9C31B03A68B1615C70BB40D0556FB2547C1EA。 + +未直接连接用户vivo真机,设备厂商的后台、省电和通知设置仍需本人安装新版核验;不能把模拟器通过当作所有手机行为一致。 diff --git a/mobile/android/app/src/main/res/raw/keep.xml b/mobile/android/app/src/main/res/raw/keep.xml new file mode 100644 index 0000000..8d07554 --- /dev/null +++ b/mobile/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,4 @@ + + + diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index 832919b..f05c4cf 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -92,7 +92,10 @@ class AppController extends ChangeNotifier { ) => _run(() async { final previous = snapshot.settings; final value = change(previous); - if (![5, 10, 15, 30, 60].contains(value.reminderMinutes)) { + if (value.reminderOffsets.isEmpty || + value.reminderOffsets.any( + (v) => !AppSettings.allowedReminderMinutes.contains(v), + )) { throw ArgumentError('提醒时间无效'); } if (!value.courseHeightScale.isFinite || @@ -111,7 +114,7 @@ class AppController extends ChangeNotifier { final scheduleChanged = previous.semester != value.semester || previous.remindersEnabled != value.remindersEnabled || - previous.reminderMinutes != value.reminderMinutes || + !listEquals(previous.reminderOffsets, value.reminderOffsets) || previous.calendarEnabled != value.calendarEnabled || previous.calendarId != value.calendarId; if (scheduleChanged) await _reconcile(); diff --git a/mobile/lib/domain/app_settings.dart b/mobile/lib/domain/app_settings.dart index 8203f97..03dc554 100644 --- a/mobile/lib/domain/app_settings.dart +++ b/mobile/lib/domain/app_settings.dart @@ -3,23 +3,31 @@ import 'semester.dart'; enum ColorMode { single, multicolor } class AppSettings { - const AppSettings({ + AppSettings({ this.semester, this.remindersEnabled = true, - this.reminderMinutes = 15, + int reminderMinutes = 15, + List? reminderOffsets, this.colorMode = ColorMode.multicolor, this.singleColorArgb = 0xFF348F91, this.calendarEnabled = false, this.calendarId, this.courseHeightScale = defaultCourseHeightScale, - }); + }) : reminderOffsets = List.unmodifiable( + (reminderOffsets ?? [reminderMinutes]).toSet().toList()..sort(), + ); + static const allowedReminderMinutes = [5, 10, 15, 30, 60]; static const defaultCourseHeightScale = 1.25; static const minCourseHeightScale = 0.8; static const maxCourseHeightScale = 2.0; final double courseHeightScale; final Semester? semester; final bool remindersEnabled, calendarEnabled; - final int reminderMinutes, singleColorArgb; + final int singleColorArgb; + final List reminderOffsets; + // Retain the single-value API and JSON field for existing saved data. + int get reminderMinutes => + reminderOffsets.isEmpty ? 15 : reminderOffsets.first; final ColorMode colorMode; final String? calendarId; AppSettings copyWith({ @@ -27,6 +35,7 @@ class AppSettings { bool clearSemester = false, bool? remindersEnabled, int? reminderMinutes, + List? reminderOffsets, ColorMode? colorMode, int? singleColorArgb, bool? calendarEnabled, @@ -35,7 +44,9 @@ class AppSettings { }) => AppSettings( semester: clearSemester ? null : semester ?? this.semester, remindersEnabled: remindersEnabled ?? this.remindersEnabled, - reminderMinutes: reminderMinutes ?? this.reminderMinutes, + reminderOffsets: + reminderOffsets ?? + (reminderMinutes == null ? this.reminderOffsets : [reminderMinutes]), colorMode: colorMode ?? this.colorMode, singleColorArgb: singleColorArgb ?? this.singleColorArgb, calendarEnabled: calendarEnabled ?? this.calendarEnabled, diff --git a/mobile/lib/domain/app_snapshot.dart b/mobile/lib/domain/app_snapshot.dart index b69dd46..19b0b72 100644 --- a/mobile/lib/domain/app_snapshot.dart +++ b/mobile/lib/domain/app_snapshot.dart @@ -17,14 +17,15 @@ class ImportMetadata { class AppSnapshot { AppSnapshot({ - this.settings = const AppSettings(), + AppSettings? settings, List courses = const [], this.importMetadata, this.examSchedule, SchoolTerm? schoolTerm, this.calendarCleanupPending = false, Map calendarEventIds = const {}, - }) : schoolTerm = schoolTerm ?? _legacyTerm(courses, examSchedule), + }) : settings = settings ?? AppSettings(), + schoolTerm = schoolTerm ?? _legacyTerm(courses, examSchedule), courses = List.unmodifiable(courses), calendarEventIds = Map.unmodifiable(calendarEventIds); final AppSettings settings; @@ -85,6 +86,7 @@ class AppSnapshot { }, 'remindersEnabled': settings.remindersEnabled, 'reminderMinutes': settings.reminderMinutes, + 'reminderOffsets': settings.reminderOffsets, 'colorMode': settings.colorMode.name, 'singleColorArgb': settings.singleColorArgb, 'calendarEnabled': settings.calendarEnabled, @@ -112,10 +114,17 @@ class AppSnapshot { throw const FormatException('保存课表无效'); } final s = Map.from(json['settings'] as Map); - final minutes = s['reminderMinutes'] as int; - if (![5, 10, 15, 30, 60].contains(minutes)) { + final rawOffsets = s.containsKey('reminderOffsets') + ? s['reminderOffsets'] + : [s['reminderMinutes']]; + if (rawOffsets is! List || + rawOffsets.isEmpty || + rawOffsets.any( + (v) => v is! int || !AppSettings.allowedReminderMinutes.contains(v), + )) { throw const FormatException('提醒时间无效'); } + final offsets = rawOffsets.cast(); final color = s['singleColorArgb'] as int; if (color < 0 || color > 0xFFFFFFFF) throw const FormatException('颜色无效'); final rawHeight = s.containsKey('courseHeightScale') @@ -152,7 +161,7 @@ class AppSnapshot { settings: AppSettings( semester: term, remindersEnabled: s['remindersEnabled'] as bool, - reminderMinutes: minutes, + reminderOffsets: offsets, colorMode: ColorMode.values.byName(s['colorMode'] as String), singleColorArgb: color, calendarEnabled: s['calendarEnabled'] as bool, diff --git a/mobile/lib/features/profile/settings_page.dart b/mobile/lib/features/profile/settings_page.dart index e618335..6ccfad2 100644 --- a/mobile/lib/features/profile/settings_page.dart +++ b/mobile/lib/features/profile/settings_page.dart @@ -324,17 +324,29 @@ class _SettingsPageState extends State { spacing: 6, runSpacing: 7, children: [ - for (final minutes in [5, 10, 15, 30, 60]) - ChoiceChip( + for (final minutes in AppSettings.allowedReminderMinutes) + FilterChip( label: Text('$minutes 分钟'), - selected: s.reminderMinutes == minutes, + selected: s.reminderOffsets.contains(minutes), onSelected: c.isDemo ? null - : (_) => c.updateSettings( - (current) => current.copyWith( - reminderMinutes: minutes, - ), - ), + : (selected) { + if (!selected && + s.reminderOffsets.length == 1) { + _notice('请至少保留一个提醒时间'); + return; + } + c.updateSettings( + (current) => current.copyWith( + reminderOffsets: [ + for (final value + in current.reminderOffsets) + if (value != minutes) value, + if (selected) minutes, + ], + ), + ); + }, ), ], ), @@ -345,7 +357,7 @@ class _SettingsPageState extends State { : c.reminderResult.status == ReminderStatus.permissionDenied ? '尚未允许通知权限,点击下方按钮开启。' - : '连续课程只在开始前提醒一次。', + : '可多选提前时间,连续课程按每个选中的时间提醒。', style: TextStyle( fontSize: 11, height: 1.6, diff --git a/mobile/lib/services/local_notification_service.dart b/mobile/lib/services/local_notification_service.dart index 4bddc58..83c83f9 100644 --- a/mobile/lib/services/local_notification_service.dart +++ b/mobile/lib/services/local_notification_service.dart @@ -56,47 +56,62 @@ class LocalNotificationService extends NotificationService { ) async { if (!settings.remindersEnabled || settings.semester == null) { await cancelOwned(); - return const ReminderResult(ReminderStatus.off); + return ReminderResult( + ReminderStatus.off, + message: settings.remindersEnabled ? '请先设置学期开始日和周数,再安排提醒。' : '课前提醒已关闭。', + ); } final access = await gateway.permissions(request: false); if (!access.notifications) { await cancelOwned(); return const ReminderResult( ReminderStatus.permissionDenied, - message: '尚未允许通知权限,请在设置中开启。', + message: '请允许通知,并检查系统中的“课前提醒”通知类别。', ); } final upcoming = - occurrences - .where( - (e) => - e.course.source != CourseSource.demo && - e.start - .subtract(Duration(minutes: settings.reminderMinutes)) - .isAfter(now), - ) - .toList() - ..sort((a, b) { - final order = a.start.compareTo(b.start); - return order == 0 ? a.key.compareTo(b.key) : order; - }); + <({CourseOccurrence event, int minutes, tz.TZDateTime fireAt})>[ + for (final event in occurrences) + if (event.course.source != CourseSource.demo) + for (final minutes in settings.reminderOffsets) + if (event.start + .subtract(Duration(minutes: minutes)) + .isAfter(now)) + ( + event: event, + minutes: minutes, + fireAt: event.start.subtract(Duration(minutes: minutes)), + ), + ]..sort((a, b) { + final order = a.fireAt.compareTo(b.fireAt); + if (order != 0) return order; + final courseOrder = a.event.key.compareTo(b.event.key); + return courseOrder != 0 + ? courseOrder + : a.minutes.compareTo(b.minutes); + }); final limited = upcoming.length > 400; - final selected = upcoming.take(400).toList() - ..sort((a, b) => a.key.compareTo(b.key)); + final selected = upcoming.take(400); final desired = {}; - for (final event in selected) { - var id = notificationId(event.key); + for (final reminder in selected) { + final event = reminder.event; + // Keep the original 15-minute ID; other times need independent IDs so + // Android cannot replace an earlier reminder for the same course. + var id = notificationId( + reminder.minutes == 15 + ? event.key + : '${event.key}/reminder/${reminder.minutes}', + ); while (desired.containsKey(id)) { id = (id + 1) & 0x7FFFFFFF; } desired[id] = ReminderRequest( id: id, occurrenceKey: event.key, - fireAt: event.start.subtract( - Duration(minutes: settings.reminderMinutes), - ), + fireAt: reminder.fireAt, title: event.course.name, - body: '${event.course.room} · ${event.course.teacher}', + body: + '${reminder.minutes}分钟后上课 · ${event.course.room} · ${event.course.teacher}', exact: access.exactAlarm, ); } @@ -118,6 +133,11 @@ class LocalNotificationService extends NotificationService { _fingerprints[entry.key] = fingerprint; } final message = [ + if (desired.isEmpty) '当前没有待提醒的课程,请核对学期日期和上课周次。', + if (desired.isNotEmpty) '已安排 ${desired.length} 次课前提醒。', + if (upcoming.isNotEmpty) + '最近一次:${upcoming.first.fireAt.month}月${upcoming.first.fireAt.day}日 ' + '${upcoming.first.fireAt.hour.toString().padLeft(2, '0')}:${upcoming.first.fireAt.minute.toString().padLeft(2, '0')}。', if (!access.exactAlarm) '未允许精确提醒,系统可能延迟送达。', if (limited) '已安排最近 400 次提醒,后续提醒在再次打开时补充。', ].join(' '); @@ -185,8 +205,17 @@ class PluginReminderGateway extends ReminderGateway { await _android?.requestNotificationsPermission(); await _android?.requestExactAlarmsPermission(); } + final channels = await _android?.getNotificationChannels(); + final courseChannelEnabled = + channels == null || + !channels.any( + (channel) => + channel.id == 'hepan_lessons' && + channel.importance == Importance.none, + ); return ReminderPermissions( - await _android?.areNotificationsEnabled() ?? false, + (await _android?.areNotificationsEnabled() ?? false) && + courseChannelEnabled, await _android?.canScheduleExactNotifications() ?? false, ); } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 70ac8b0..197cbd3 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday description: TPSchedule 学生课表 publish_to: none -version: 0.5.4+12 +version: 0.5.5+13 environment: sdk: ^3.13.5 dependencies: diff --git a/mobile/test/data/multiple_reminder_settings_test.dart b/mobile/test/data/multiple_reminder_settings_test.dart new file mode 100644 index 0000000..6dc1778 --- /dev/null +++ b/mobile/test/data/multiple_reminder_settings_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/app_settings.dart'; + +void main() { + test('changing input list cannot change saved reminder choices', () { + final choices = [5, 15]; + final settings = AppSettings(reminderOffsets: choices); + choices.clear(); + expect(settings.reminderOffsets, [5, 15]); + expect(() => settings.reminderOffsets.add(30), throwsUnsupportedError); + }); + test('legacy single advance setting migrates to one selected time', () { + final json = AppSnapshot(settings: AppSettings(reminderMinutes: 30)) + .toJson(); + (json['settings'] as Map).remove('reminderOffsets'); + final migrated = AppSnapshot.fromJson(json).toJson(); + expect((migrated['settings'] as Map)['reminderOffsets'], [30]); + }); + test('selected times are normalized and survive snapshot round trip', () { + final json = AppSnapshot().toJson(); + (json['settings'] as Map)['reminderOffsets'] = [15, 5, 15]; + final saved = AppSnapshot.fromJson(AppSnapshot.fromJson(json).toJson()) + .toJson(); + expect((saved['settings'] as Map)['reminderOffsets'], [5, 15]); + }); + for (final invalid in [ + [], + [0], + [7], + ['15'], + [15, 90], + ]) { + test('invalid selected reminder times $invalid are rejected', () { + final json = AppSnapshot().toJson(); + (json['settings'] as Map)['reminderOffsets'] = invalid; + expect(() => AppSnapshot.fromJson(json), throwsFormatException); + }); + } +} diff --git a/mobile/test/data/snapshot_store_test.dart b/mobile/test/data/snapshot_store_test.dart index 73d4f25..704649f 100644 --- a/mobile/test/data/snapshot_store_test.dart +++ b/mobile/test/data/snapshot_store_test.dart @@ -50,7 +50,7 @@ void main() { 'reopening restores courses, settings and calendar identifiers', () async { final original = AppSnapshot( - settings: const AppSettings(reminderMinutes: 30), + settings: AppSettings(reminderMinutes: 30), courses: [course()], calendarEventIds: {'math/2026-09-09': '17'}, ); @@ -75,12 +75,8 @@ void main() { ); test('valid previous snapshot is available for explicit recovery', () async { final store = FileSnapshotStore(directory); - await store.save( - AppSnapshot(settings: const AppSettings(reminderMinutes: 15)), - ); - await store.save( - AppSnapshot(settings: const AppSettings(reminderMinutes: 30)), - ); + await store.save(AppSnapshot(settings: AppSettings(reminderMinutes: 15))); + await store.save(AppSnapshot(settings: AppSettings(reminderMinutes: 30))); await File('${directory.path}/hepan_snapshot.json') .writeAsString('damaged'); expect((await store.restoreBackup()).settings.reminderMinutes, 15); diff --git a/mobile/test/services/notification_gateway_test.dart b/mobile/test/services/notification_gateway_test.dart index aceb88e..b115495 100644 --- a/mobile/test/services/notification_gateway_test.dart +++ b/mobile/test/services/notification_gateway_test.dart @@ -35,4 +35,57 @@ void main() { await gateway.initialize(); expect(attempts, 2); }); + test( + 'disabled course notification channel is reported even with app permission', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + AndroidFlutterLocalNotificationsPlugin.registerWith(); + const channel = MethodChannel( + 'dexterous.com/flutter/local_notifications', + ); + var importance = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'initialize': + return true; + case 'getNotificationAppLaunchDetails': + return {'notificationLaunchedApp': false}; + case 'areNotificationsEnabled': + return true; + case 'canScheduleExactNotifications': + return true; + case 'getNotificationChannels': + return [ + { + 'id': 'hepan_lessons', + 'name': '课前提醒', + 'description': '课程开始前的提醒', + 'importance': importance, + 'showBadge': true, + 'playSound': true, + 'enableVibration': true, + 'enableLights': false, + 'bypassDnd': false, + 'ledColor': 0, + 'audioAttributesUsage': 5, + }, + ]; + } + return null; + }); + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final gateway = PluginReminderGateway(); + expect( + (await gateway.permissions(request: false)).notifications, + isFalse, + ); + importance = 4; + expect((await gateway.permissions(request: false)).notifications, isTrue); + }, + ); } diff --git a/mobile/test/services/notification_service_test.dart b/mobile/test/services/notification_service_test.dart index 553b775..99d0375 100644 --- a/mobile/test/services/notification_service_test.dart +++ b/mobile/test/services/notification_service_test.dart @@ -4,6 +4,7 @@ import 'package:hepan_everyday/services/local_notification_service.dart'; import 'package:hepan_everyday/services/notification_service.dart'; import 'package:hepan_everyday/domain/schedule_engine.dart'; import 'package:hepan_everyday/domain/app_settings.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; import '../domain/schedule_engine_test.dart' show course; @@ -40,6 +41,78 @@ void main() { gateway = AlarmGateway(); service = LocalNotificationService(gateway); }); + AppSettings multiple(List minutes) { + final json = AppSnapshot(settings: settings).toJson(); + (json['settings'] as Map)['reminderOffsets'] = minutes; + return AppSnapshot.fromJson(json).settings; + } + + test( + 'selected advance times produce independent alarms and survive refresh', + () async { + final events = ScheduleEngine.occurrences([course(last: 2)], term); + await service.reconcile( + events, + multiple([5, 15]), + DateTime.utc(2026, 9, 9), + ); + expect(gateway.alarms, hasLength(2)); + expect(gateway.alarms.values.map((a) => a.fireAt.toUtc()).toSet(), { + DateTime.utc(2026, 9, 9, 0, 15), + DateTime.utc(2026, 9, 9, 0, 25), + }); + final ids = gateway.alarms.keys.toSet(); + await service.reconcile( + events, + multiple([15, 5]), + DateTime.utc(2026, 9, 9), + ); + expect(gateway.alarms.keys.toSet(), ids); + await service.reconcile(events, multiple([5]), DateTime.utc(2026, 9, 9)); + expect(gateway.alarms, hasLength(1)); + expect( + gateway.alarms.values.single.fireAt.toUtc(), + DateTime.utc(2026, 9, 9, 0, 25), + ); + }, + ); + test( + 'elapsed earlier reminder does not remove the later selected reminder', + () async { + final events = ScheduleEngine.occurrences([course()], term); + await service.reconcile( + events, + multiple([5, 15]), + DateTime.utc(2026, 9, 9, 0, 20), + ); + expect(gateway.alarms, hasLength(1)); + expect( + gateway.alarms.values.single.fireAt.toUtc(), + DateTime.utc(2026, 9, 9, 0, 25), + ); + }, + ); + test( + 'limit selects earliest reminder instants across all selected times', + () async { + final events = ScheduleEngine.occurrences( + List.generate(501, (i) => course(id: 'c-$i')), + term, + ); + final result = await service.reconcile( + events, + multiple([5, 15]), + DateTime.utc(2026, 9, 9), + ); + expect(result.scheduledCount, 400); + expect( + gateway.alarms.values.every( + (a) => a.fireAt.isAtSameMomentAs(DateTime.utc(2026, 9, 9, 0, 15)), + ), + isTrue, + ); + }, + ); test( 'continuous course schedules one alarm fifteen minutes before school start', () async { diff --git a/mobile/test/widgets/conflict_metadata_test.dart b/mobile/test/widgets/conflict_metadata_test.dart index 4f0d9b0..25a5790 100644 --- a/mobile/test/widgets/conflict_metadata_test.dart +++ b/mobile/test/widgets/conflict_metadata_test.dart @@ -41,7 +41,7 @@ void main() { events: events, monday: term.firstMonday, today: term.startDate, - settings: const AppSettings(), + settings: AppSettings(), ), ), ), @@ -108,7 +108,7 @@ void main() { events: events, monday: term.firstMonday, today: term.startDate, - settings: const AppSettings(), + settings: AppSettings(), ), ), ), diff --git a/mobile/test/widgets/settings_test.dart b/mobile/test/widgets/settings_test.dart index 16b6f3d..085e17c 100644 --- a/mobile/test/widgets/settings_test.dart +++ b/mobile/test/widgets/settings_test.dart @@ -19,6 +19,43 @@ import 'package:hepan_everyday/domain/semester.dart'; void main() { setUpAll(tzdata.initializeTimeZones); + testWidgets( + 'reminder times can be selected together and last choice stays selected', + (tester) async { + final controller = uiController(); + await controller.initialize(); + await tester.pumpWidget( + MaterialApp(home: SettingsPage(controller: controller)), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('5 分钟'), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible(find.text('5 分钟')); + await tester.pumpAndSettle(); + await tester.tap(find.text('5 分钟')); + await tester.pumpAndSettle(); + expect( + (controller.snapshot.toJson()['settings'] as Map)['reminderOffsets'], + [5, 15], + ); + await tester.tap(find.text('15 分钟')); + await tester.pumpAndSettle(); + expect( + (controller.snapshot.toJson()['settings'] as Map)['reminderOffsets'], + [5], + ); + await tester.tap(find.text('5 分钟')); + await tester.pumpAndSettle(); + expect( + (controller.snapshot.toJson()['settings'] as Map)['reminderOffsets'], + [5], + ); + controller.dispose(); + }, + ); testWidgets( 'partial initial calendar sync exposes working cleanup action while switch is off', (tester) async { diff --git a/mobile/test/widgets/timetable_test.dart b/mobile/test/widgets/timetable_test.dart index 1f3d1d1..709b7c5 100644 --- a/mobile/test/widgets/timetable_test.dart +++ b/mobile/test/widgets/timetable_test.dart @@ -35,7 +35,7 @@ void main() { events: events, monday: term.firstMonday, today: term.startDate, - settings: const AppSettings(), + settings: AppSettings(), ), ), ),