Fix release notification resource and add multiple advance reminders
This commit is contained in:
@@ -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)。
|
||||
|
||||
|
||||
@@ -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" />
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -35,7 +35,7 @@ void main() {
|
||||
events: events,
|
||||
monday: term.firstMonday,
|
||||
today: term.startDate,
|
||||
settings: const AppSettings(),
|
||||
settings: AppSettings(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user