Fix release notification resource and add multiple advance reminders
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# TPSchedule
|
# 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+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
|
使用用户提供的原始 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.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;旧应用与数据保留,新应用需要重新导入课程并设置日期。
|
安装 `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` / `.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.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 {
|
) => _run(() async {
|
||||||
final previous = snapshot.settings;
|
final previous = snapshot.settings;
|
||||||
final value = change(previous);
|
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('提醒时间无效');
|
throw ArgumentError('提醒时间无效');
|
||||||
}
|
}
|
||||||
if (!value.courseHeightScale.isFinite ||
|
if (!value.courseHeightScale.isFinite ||
|
||||||
@@ -111,7 +114,7 @@ class AppController extends ChangeNotifier {
|
|||||||
final scheduleChanged =
|
final scheduleChanged =
|
||||||
previous.semester != value.semester ||
|
previous.semester != value.semester ||
|
||||||
previous.remindersEnabled != value.remindersEnabled ||
|
previous.remindersEnabled != value.remindersEnabled ||
|
||||||
previous.reminderMinutes != value.reminderMinutes ||
|
!listEquals(previous.reminderOffsets, value.reminderOffsets) ||
|
||||||
previous.calendarEnabled != value.calendarEnabled ||
|
previous.calendarEnabled != value.calendarEnabled ||
|
||||||
previous.calendarId != value.calendarId;
|
previous.calendarId != value.calendarId;
|
||||||
if (scheduleChanged) await _reconcile();
|
if (scheduleChanged) await _reconcile();
|
||||||
|
|||||||
@@ -3,23 +3,31 @@ import 'semester.dart';
|
|||||||
enum ColorMode { single, multicolor }
|
enum ColorMode { single, multicolor }
|
||||||
|
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
const AppSettings({
|
AppSettings({
|
||||||
this.semester,
|
this.semester,
|
||||||
this.remindersEnabled = true,
|
this.remindersEnabled = true,
|
||||||
this.reminderMinutes = 15,
|
int reminderMinutes = 15,
|
||||||
|
List<int>? reminderOffsets,
|
||||||
this.colorMode = ColorMode.multicolor,
|
this.colorMode = ColorMode.multicolor,
|
||||||
this.singleColorArgb = 0xFF348F91,
|
this.singleColorArgb = 0xFF348F91,
|
||||||
this.calendarEnabled = false,
|
this.calendarEnabled = false,
|
||||||
this.calendarId,
|
this.calendarId,
|
||||||
this.courseHeightScale = defaultCourseHeightScale,
|
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 defaultCourseHeightScale = 1.25;
|
||||||
static const minCourseHeightScale = 0.8;
|
static const minCourseHeightScale = 0.8;
|
||||||
static const maxCourseHeightScale = 2.0;
|
static const maxCourseHeightScale = 2.0;
|
||||||
final double courseHeightScale;
|
final double courseHeightScale;
|
||||||
final Semester? semester;
|
final Semester? semester;
|
||||||
final bool remindersEnabled, calendarEnabled;
|
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 ColorMode colorMode;
|
||||||
final String? calendarId;
|
final String? calendarId;
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -27,6 +35,7 @@ class AppSettings {
|
|||||||
bool clearSemester = false,
|
bool clearSemester = false,
|
||||||
bool? remindersEnabled,
|
bool? remindersEnabled,
|
||||||
int? reminderMinutes,
|
int? reminderMinutes,
|
||||||
|
List<int>? reminderOffsets,
|
||||||
ColorMode? colorMode,
|
ColorMode? colorMode,
|
||||||
int? singleColorArgb,
|
int? singleColorArgb,
|
||||||
bool? calendarEnabled,
|
bool? calendarEnabled,
|
||||||
@@ -35,7 +44,9 @@ class AppSettings {
|
|||||||
}) => AppSettings(
|
}) => AppSettings(
|
||||||
semester: clearSemester ? null : semester ?? this.semester,
|
semester: clearSemester ? null : semester ?? this.semester,
|
||||||
remindersEnabled: remindersEnabled ?? this.remindersEnabled,
|
remindersEnabled: remindersEnabled ?? this.remindersEnabled,
|
||||||
reminderMinutes: reminderMinutes ?? this.reminderMinutes,
|
reminderOffsets:
|
||||||
|
reminderOffsets ??
|
||||||
|
(reminderMinutes == null ? this.reminderOffsets : [reminderMinutes]),
|
||||||
colorMode: colorMode ?? this.colorMode,
|
colorMode: colorMode ?? this.colorMode,
|
||||||
singleColorArgb: singleColorArgb ?? this.singleColorArgb,
|
singleColorArgb: singleColorArgb ?? this.singleColorArgb,
|
||||||
calendarEnabled: calendarEnabled ?? this.calendarEnabled,
|
calendarEnabled: calendarEnabled ?? this.calendarEnabled,
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ class ImportMetadata {
|
|||||||
|
|
||||||
class AppSnapshot {
|
class AppSnapshot {
|
||||||
AppSnapshot({
|
AppSnapshot({
|
||||||
this.settings = const AppSettings(),
|
AppSettings? settings,
|
||||||
List<Course> courses = const [],
|
List<Course> courses = const [],
|
||||||
this.importMetadata,
|
this.importMetadata,
|
||||||
this.examSchedule,
|
this.examSchedule,
|
||||||
SchoolTerm? schoolTerm,
|
SchoolTerm? schoolTerm,
|
||||||
this.calendarCleanupPending = false,
|
this.calendarCleanupPending = false,
|
||||||
Map<String, String> calendarEventIds = const {},
|
Map<String, String> calendarEventIds = const {},
|
||||||
}) : schoolTerm = schoolTerm ?? _legacyTerm(courses, examSchedule),
|
}) : settings = settings ?? AppSettings(),
|
||||||
|
schoolTerm = schoolTerm ?? _legacyTerm(courses, examSchedule),
|
||||||
courses = List.unmodifiable(courses),
|
courses = List.unmodifiable(courses),
|
||||||
calendarEventIds = Map.unmodifiable(calendarEventIds);
|
calendarEventIds = Map.unmodifiable(calendarEventIds);
|
||||||
final AppSettings settings;
|
final AppSettings settings;
|
||||||
@@ -85,6 +86,7 @@ class AppSnapshot {
|
|||||||
},
|
},
|
||||||
'remindersEnabled': settings.remindersEnabled,
|
'remindersEnabled': settings.remindersEnabled,
|
||||||
'reminderMinutes': settings.reminderMinutes,
|
'reminderMinutes': settings.reminderMinutes,
|
||||||
|
'reminderOffsets': settings.reminderOffsets,
|
||||||
'colorMode': settings.colorMode.name,
|
'colorMode': settings.colorMode.name,
|
||||||
'singleColorArgb': settings.singleColorArgb,
|
'singleColorArgb': settings.singleColorArgb,
|
||||||
'calendarEnabled': settings.calendarEnabled,
|
'calendarEnabled': settings.calendarEnabled,
|
||||||
@@ -112,10 +114,17 @@ class AppSnapshot {
|
|||||||
throw const FormatException('保存课表无效');
|
throw const FormatException('保存课表无效');
|
||||||
}
|
}
|
||||||
final s = Map<String, dynamic>.from(json['settings'] as Map);
|
final s = Map<String, dynamic>.from(json['settings'] as Map);
|
||||||
final minutes = s['reminderMinutes'] as int;
|
final rawOffsets = s.containsKey('reminderOffsets')
|
||||||
if (![5, 10, 15, 30, 60].contains(minutes)) {
|
? s['reminderOffsets']
|
||||||
|
: [s['reminderMinutes']];
|
||||||
|
if (rawOffsets is! List ||
|
||||||
|
rawOffsets.isEmpty ||
|
||||||
|
rawOffsets.any(
|
||||||
|
(v) => v is! int || !AppSettings.allowedReminderMinutes.contains(v),
|
||||||
|
)) {
|
||||||
throw const FormatException('提醒时间无效');
|
throw const FormatException('提醒时间无效');
|
||||||
}
|
}
|
||||||
|
final offsets = rawOffsets.cast<int>();
|
||||||
final color = s['singleColorArgb'] as int;
|
final color = s['singleColorArgb'] as int;
|
||||||
if (color < 0 || color > 0xFFFFFFFF) throw const FormatException('颜色无效');
|
if (color < 0 || color > 0xFFFFFFFF) throw const FormatException('颜色无效');
|
||||||
final rawHeight = s.containsKey('courseHeightScale')
|
final rawHeight = s.containsKey('courseHeightScale')
|
||||||
@@ -152,7 +161,7 @@ class AppSnapshot {
|
|||||||
settings: AppSettings(
|
settings: AppSettings(
|
||||||
semester: term,
|
semester: term,
|
||||||
remindersEnabled: s['remindersEnabled'] as bool,
|
remindersEnabled: s['remindersEnabled'] as bool,
|
||||||
reminderMinutes: minutes,
|
reminderOffsets: offsets,
|
||||||
colorMode: ColorMode.values.byName(s['colorMode'] as String),
|
colorMode: ColorMode.values.byName(s['colorMode'] as String),
|
||||||
singleColorArgb: color,
|
singleColorArgb: color,
|
||||||
calendarEnabled: s['calendarEnabled'] as bool,
|
calendarEnabled: s['calendarEnabled'] as bool,
|
||||||
|
|||||||
@@ -324,17 +324,29 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
spacing: 6,
|
spacing: 6,
|
||||||
runSpacing: 7,
|
runSpacing: 7,
|
||||||
children: [
|
children: [
|
||||||
for (final minutes in [5, 10, 15, 30, 60])
|
for (final minutes in AppSettings.allowedReminderMinutes)
|
||||||
ChoiceChip(
|
FilterChip(
|
||||||
label: Text('$minutes 分钟'),
|
label: Text('$minutes 分钟'),
|
||||||
selected: s.reminderMinutes == minutes,
|
selected: s.reminderOffsets.contains(minutes),
|
||||||
onSelected: c.isDemo
|
onSelected: c.isDemo
|
||||||
? null
|
? null
|
||||||
: (_) => c.updateSettings(
|
: (selected) {
|
||||||
(current) => current.copyWith(
|
if (!selected &&
|
||||||
reminderMinutes: minutes,
|
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 ==
|
: c.reminderResult.status ==
|
||||||
ReminderStatus.permissionDenied
|
ReminderStatus.permissionDenied
|
||||||
? '尚未允许通知权限,点击下方按钮开启。'
|
? '尚未允许通知权限,点击下方按钮开启。'
|
||||||
: '连续课程只在开始前提醒一次。',
|
: '可多选提前时间,连续课程按每个选中的时间提醒。',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
height: 1.6,
|
height: 1.6,
|
||||||
|
|||||||
@@ -56,47 +56,62 @@ class LocalNotificationService extends NotificationService {
|
|||||||
) async {
|
) async {
|
||||||
if (!settings.remindersEnabled || settings.semester == null) {
|
if (!settings.remindersEnabled || settings.semester == null) {
|
||||||
await cancelOwned();
|
await cancelOwned();
|
||||||
return const ReminderResult(ReminderStatus.off);
|
return ReminderResult(
|
||||||
|
ReminderStatus.off,
|
||||||
|
message: settings.remindersEnabled ? '请先设置学期开始日和周数,再安排提醒。' : '课前提醒已关闭。',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final access = await gateway.permissions(request: false);
|
final access = await gateway.permissions(request: false);
|
||||||
if (!access.notifications) {
|
if (!access.notifications) {
|
||||||
await cancelOwned();
|
await cancelOwned();
|
||||||
return const ReminderResult(
|
return const ReminderResult(
|
||||||
ReminderStatus.permissionDenied,
|
ReminderStatus.permissionDenied,
|
||||||
message: '尚未允许通知权限,请在设置中开启。',
|
message: '请允许通知,并检查系统中的“课前提醒”通知类别。',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final upcoming =
|
final upcoming =
|
||||||
occurrences
|
<({CourseOccurrence event, int minutes, tz.TZDateTime fireAt})>[
|
||||||
.where(
|
for (final event in occurrences)
|
||||||
(e) =>
|
if (event.course.source != CourseSource.demo)
|
||||||
e.course.source != CourseSource.demo &&
|
for (final minutes in settings.reminderOffsets)
|
||||||
e.start
|
if (event.start
|
||||||
.subtract(Duration(minutes: settings.reminderMinutes))
|
.subtract(Duration(minutes: minutes))
|
||||||
.isAfter(now),
|
.isAfter(now))
|
||||||
)
|
(
|
||||||
.toList()
|
event: event,
|
||||||
..sort((a, b) {
|
minutes: minutes,
|
||||||
final order = a.start.compareTo(b.start);
|
fireAt: event.start.subtract(Duration(minutes: minutes)),
|
||||||
return order == 0 ? a.key.compareTo(b.key) : order;
|
),
|
||||||
});
|
]..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 limited = upcoming.length > 400;
|
||||||
final selected = upcoming.take(400).toList()
|
final selected = upcoming.take(400);
|
||||||
..sort((a, b) => a.key.compareTo(b.key));
|
|
||||||
final desired = <int, ReminderRequest>{};
|
final desired = <int, ReminderRequest>{};
|
||||||
for (final event in selected) {
|
for (final reminder in selected) {
|
||||||
var id = notificationId(event.key);
|
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)) {
|
while (desired.containsKey(id)) {
|
||||||
id = (id + 1) & 0x7FFFFFFF;
|
id = (id + 1) & 0x7FFFFFFF;
|
||||||
}
|
}
|
||||||
desired[id] = ReminderRequest(
|
desired[id] = ReminderRequest(
|
||||||
id: id,
|
id: id,
|
||||||
occurrenceKey: event.key,
|
occurrenceKey: event.key,
|
||||||
fireAt: event.start.subtract(
|
fireAt: reminder.fireAt,
|
||||||
Duration(minutes: settings.reminderMinutes),
|
|
||||||
),
|
|
||||||
title: event.course.name,
|
title: event.course.name,
|
||||||
body: '${event.course.room} · ${event.course.teacher}',
|
body:
|
||||||
|
'${reminder.minutes}分钟后上课 · ${event.course.room} · ${event.course.teacher}',
|
||||||
exact: access.exactAlarm,
|
exact: access.exactAlarm,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,6 +133,11 @@ class LocalNotificationService extends NotificationService {
|
|||||||
_fingerprints[entry.key] = fingerprint;
|
_fingerprints[entry.key] = fingerprint;
|
||||||
}
|
}
|
||||||
final message = [
|
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 (!access.exactAlarm) '未允许精确提醒,系统可能延迟送达。',
|
||||||
if (limited) '已安排最近 400 次提醒,后续提醒在再次打开时补充。',
|
if (limited) '已安排最近 400 次提醒,后续提醒在再次打开时补充。',
|
||||||
].join(' ');
|
].join(' ');
|
||||||
@@ -185,8 +205,17 @@ class PluginReminderGateway extends ReminderGateway {
|
|||||||
await _android?.requestNotificationsPermission();
|
await _android?.requestNotificationsPermission();
|
||||||
await _android?.requestExactAlarmsPermission();
|
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(
|
return ReminderPermissions(
|
||||||
await _android?.areNotificationsEnabled() ?? false,
|
(await _android?.areNotificationsEnabled() ?? false) &&
|
||||||
|
courseChannelEnabled,
|
||||||
await _android?.canScheduleExactNotifications() ?? false,
|
await _android?.canScheduleExactNotifications() ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: hepan_everyday
|
name: hepan_everyday
|
||||||
description: TPSchedule 学生课表
|
description: TPSchedule 学生课表
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.5.4+12
|
version: 0.5.5+13
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.13.5
|
sdk: ^3.13.5
|
||||||
dependencies:
|
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',
|
'reopening restores courses, settings and calendar identifiers',
|
||||||
() async {
|
() async {
|
||||||
final original = AppSnapshot(
|
final original = AppSnapshot(
|
||||||
settings: const AppSettings(reminderMinutes: 30),
|
settings: AppSettings(reminderMinutes: 30),
|
||||||
courses: [course()],
|
courses: [course()],
|
||||||
calendarEventIds: {'math/2026-09-09': '17'},
|
calendarEventIds: {'math/2026-09-09': '17'},
|
||||||
);
|
);
|
||||||
@@ -75,12 +75,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
test('valid previous snapshot is available for explicit recovery', () async {
|
test('valid previous snapshot is available for explicit recovery', () async {
|
||||||
final store = FileSnapshotStore(directory);
|
final store = FileSnapshotStore(directory);
|
||||||
await store.save(
|
await store.save(AppSnapshot(settings: AppSettings(reminderMinutes: 15)));
|
||||||
AppSnapshot(settings: const AppSettings(reminderMinutes: 15)),
|
await store.save(AppSnapshot(settings: AppSettings(reminderMinutes: 30)));
|
||||||
);
|
|
||||||
await store.save(
|
|
||||||
AppSnapshot(settings: const AppSettings(reminderMinutes: 30)),
|
|
||||||
);
|
|
||||||
await File('${directory.path}/hepan_snapshot.json')
|
await File('${directory.path}/hepan_snapshot.json')
|
||||||
.writeAsString('damaged');
|
.writeAsString('damaged');
|
||||||
expect((await store.restoreBackup()).settings.reminderMinutes, 15);
|
expect((await store.restoreBackup()).settings.reminderMinutes, 15);
|
||||||
|
|||||||
@@ -35,4 +35,57 @@ void main() {
|
|||||||
await gateway.initialize();
|
await gateway.initialize();
|
||||||
expect(attempts, 2);
|
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/services/notification_service.dart';
|
||||||
import 'package:hepan_everyday/domain/schedule_engine.dart';
|
import 'package:hepan_everyday/domain/schedule_engine.dart';
|
||||||
import 'package:hepan_everyday/domain/app_settings.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;
|
import '../domain/schedule_engine_test.dart' show course;
|
||||||
|
|
||||||
@@ -40,6 +41,78 @@ void main() {
|
|||||||
gateway = AlarmGateway();
|
gateway = AlarmGateway();
|
||||||
service = LocalNotificationService(gateway);
|
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(
|
test(
|
||||||
'continuous course schedules one alarm fifteen minutes before school start',
|
'continuous course schedules one alarm fifteen minutes before school start',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ void main() {
|
|||||||
events: events,
|
events: events,
|
||||||
monday: term.firstMonday,
|
monday: term.firstMonday,
|
||||||
today: term.startDate,
|
today: term.startDate,
|
||||||
settings: const AppSettings(),
|
settings: AppSettings(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -108,7 +108,7 @@ void main() {
|
|||||||
events: events,
|
events: events,
|
||||||
monday: term.firstMonday,
|
monday: term.firstMonday,
|
||||||
today: term.startDate,
|
today: term.startDate,
|
||||||
settings: const AppSettings(),
|
settings: AppSettings(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,43 @@ import 'package:hepan_everyday/domain/semester.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(tzdata.initializeTimeZones);
|
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(
|
testWidgets(
|
||||||
'partial initial calendar sync exposes working cleanup action while switch is off',
|
'partial initial calendar sync exposes working cleanup action while switch is off',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ void main() {
|
|||||||
events: events,
|
events: events,
|
||||||
monday: term.firstMonday,
|
monday: term.firstMonday,
|
||||||
today: term.startDate,
|
today: term.startDate,
|
||||||
settings: const AppSettings(),
|
settings: AppSettings(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user