Fix release notification resource and add multiple advance reminders

This commit is contained in:
Codex
2026-10-14 11:30:37 +08:00
parent caf7abe0c0
commit 4d86db3dd5
16 changed files with 350 additions and 58 deletions
+6 -2
View File
@@ -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)。
+21
View File
@@ -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真机,设备厂商的后台、省电和通知设置仍需本人安装新版核验;不能把模拟器通过当作所有手机行为一致。
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Notification icons are resolved by name from Dart, outside R8 references. -->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/ic_stat_calendar" />
+5 -2
View File
@@ -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();
+16 -5
View File
@@ -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<int>? 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<int> 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<int>? 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,
+14 -5
View File
@@ -17,14 +17,15 @@ class ImportMetadata {
class AppSnapshot {
AppSnapshot({
this.settings = const AppSettings(),
AppSettings? settings,
List<Course> courses = const [],
this.importMetadata,
this.examSchedule,
SchoolTerm? schoolTerm,
this.calendarCleanupPending = false,
Map<String, String> 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<String, dynamic>.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<int>();
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,
+21 -9
View File
@@ -324,17 +324,29 @@ class _SettingsPageState extends State<SettingsPage> {
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<SettingsPage> {
: c.reminderResult.status ==
ReminderStatus.permissionDenied
? '尚未允许通知权限,点击下方按钮开启。'
: '连续课程只在开始前提醒一次。',
: '可多选提前时间,连续课程按每个选中的时间提醒。',
style: TextStyle(
fontSize: 11,
height: 1.6,
@@ -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 = <int, ReminderRequest>{};
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,
);
}
+1 -1
View File
@@ -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:
@@ -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);
});
}
}
+3 -7
View File
@@ -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);
@@ -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);
},
);
}
@@ -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<int> minutes) {
final json = AppSnapshot(settings: settings).toJson();
(json['settings'] as Map<String, dynamic>)['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 {
@@ -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(),
),
),
),
+37
View File
@@ -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 {
+1 -1
View File
@@ -35,7 +35,7 @@ void main() {
events: events,
monday: term.firstMonday,
today: term.startDate,
settings: const AppSettings(),
settings: AppSettings(),
),
),
),