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
+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,
);
}