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(),
),
),
),