Fix release notification resource and add multiple advance reminders
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user