feat: add local reminders and owned calendar synchronization
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../domain/course_occurrence.dart';
|
||||
import '../domain/course.dart';
|
||||
import 'calendar_service.dart';
|
||||
|
||||
class CalendarRecord {
|
||||
const CalendarRecord({
|
||||
required this.id,
|
||||
required this.key,
|
||||
required this.calendarId,
|
||||
required this.owned,
|
||||
});
|
||||
final String id, key, calendarId;
|
||||
final bool owned;
|
||||
}
|
||||
|
||||
class CalendarEventDraft {
|
||||
const CalendarEventDraft({
|
||||
required this.key,
|
||||
required this.calendarId,
|
||||
required this.title,
|
||||
required this.room,
|
||||
required this.description,
|
||||
required this.startMs,
|
||||
required this.endMs,
|
||||
});
|
||||
final String key, calendarId, title, room, description;
|
||||
final int startMs, endMs;
|
||||
Map<String, Object> toMap() => {
|
||||
'key': key,
|
||||
'calendarId': calendarId,
|
||||
'title': title,
|
||||
'room': room,
|
||||
'description': description,
|
||||
'startMs': startMs,
|
||||
'endMs': endMs,
|
||||
'timeZone': 'Asia/Shanghai',
|
||||
};
|
||||
}
|
||||
|
||||
abstract class CalendarGateway {
|
||||
Future<bool> requestAccess();
|
||||
Future<List<DeviceCalendar>> calendars();
|
||||
Future<List<CalendarRecord>> ownedEvents(String calendarId);
|
||||
Future<String> save(CalendarEventDraft event, String? eventId);
|
||||
Future<void> delete(String eventId, String expectedKey);
|
||||
}
|
||||
|
||||
class AndroidCalendarService extends CalendarService {
|
||||
AndroidCalendarService(this.gateway);
|
||||
final CalendarGateway gateway;
|
||||
@override
|
||||
Future<bool> requestAccess() => gateway.requestAccess();
|
||||
@override
|
||||
Future<List<DeviceCalendar>> listWritableCalendars() => gateway.calendars();
|
||||
@override
|
||||
Future<Map<String, String>> sync(
|
||||
List<CourseOccurrence> occurrences,
|
||||
String calendarId,
|
||||
Map<String, String> knownEventIds,
|
||||
) async {
|
||||
if (!(await gateway.calendars()).any((c) => c.id == calendarId)) {
|
||||
throw StateError('所选日历不可写入');
|
||||
}
|
||||
final existing = (await gateway.ownedEvents(calendarId))
|
||||
.where((e) => e.owned && e.calendarId == calendarId)
|
||||
.toList();
|
||||
final result = <String, String>{};
|
||||
for (final occurrence in occurrences.where(
|
||||
(e) => e.course.source != CourseSource.demo,
|
||||
)) {
|
||||
final matches = existing.where((e) => e.key == occurrence.key).toList();
|
||||
matches.sort(
|
||||
(a, b) => (a.id == knownEventIds[occurrence.key] ? -1 : 0).compareTo(
|
||||
b.id == knownEventIds[occurrence.key] ? -1 : 0,
|
||||
),
|
||||
);
|
||||
result[occurrence.key] = await gateway.save(
|
||||
CalendarEventDraft(
|
||||
key: occurrence.key,
|
||||
calendarId: calendarId,
|
||||
title: occurrence.course.name,
|
||||
room: occurrence.course.room,
|
||||
description: '任课老师:${occurrence.course.teacher}\n河畔Everyday',
|
||||
startMs: occurrence.start.millisecondsSinceEpoch,
|
||||
endMs: occurrence.end.millisecondsSinceEpoch,
|
||||
),
|
||||
matches.isEmpty ? null : matches.first.id,
|
||||
);
|
||||
}
|
||||
for (final old in existing) {
|
||||
if (result[old.key] != old.id) {
|
||||
await gateway.delete(old.id, old.key);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteOwned(Map<String, String> eventIds) async {
|
||||
for (final entry in eventIds.entries) {
|
||||
await gateway.delete(entry.value, entry.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MethodCalendarGateway extends CalendarGateway {
|
||||
static const channel = MethodChannel('hepan_everyday/calendar');
|
||||
@override
|
||||
Future<bool> requestAccess() async =>
|
||||
await channel.invokeMethod<bool>('requestAccess') ?? false;
|
||||
@override
|
||||
Future<List<DeviceCalendar>> calendars() async {
|
||||
final rows =
|
||||
await channel.invokeMethod<List<dynamic>>('listCalendars') ?? [];
|
||||
return rows
|
||||
.map((r) => DeviceCalendar(r['id'] as String, r['name'] as String))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CalendarRecord>> ownedEvents(String calendarId) async {
|
||||
final rows =
|
||||
await channel.invokeMethod<List<dynamic>>('ownedEvents', {
|
||||
'calendarId': calendarId,
|
||||
}) ??
|
||||
[];
|
||||
return rows
|
||||
.map(
|
||||
(r) => CalendarRecord(
|
||||
id: r['id'] as String,
|
||||
key: r['key'] as String,
|
||||
calendarId: r['calendarId'] as String,
|
||||
owned: r['owned'] as bool,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> save(CalendarEventDraft event, String? eventId) async =>
|
||||
(await channel.invokeMethod<String>('saveEvent', {
|
||||
...event.toMap(),
|
||||
'eventId': eventId,
|
||||
}))!;
|
||||
@override
|
||||
Future<void> delete(String eventId, String expectedKey) => channel
|
||||
.invokeMethod<void>('deleteEvent', {'id': eventId, 'key': expectedKey});
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../domain/course.dart';
|
||||
import '../domain/app_settings.dart';
|
||||
import '../domain/course_occurrence.dart';
|
||||
import 'notification_service.dart';
|
||||
|
||||
class ReminderPermissions {
|
||||
const ReminderPermissions(this.notifications, this.exactAlarm);
|
||||
final bool notifications, exactAlarm;
|
||||
}
|
||||
|
||||
class ReminderRequest {
|
||||
const ReminderRequest({
|
||||
required this.id,
|
||||
required this.occurrenceKey,
|
||||
required this.fireAt,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.exact,
|
||||
});
|
||||
final int id;
|
||||
final String occurrenceKey, title, body;
|
||||
final tz.TZDateTime fireAt;
|
||||
final bool exact;
|
||||
}
|
||||
|
||||
abstract class ReminderGateway {
|
||||
Future<ReminderPermissions> permissions({required bool request});
|
||||
Future<Set<int>> pendingIds();
|
||||
Future<void> schedule(ReminderRequest value);
|
||||
Future<void> cancel(int id);
|
||||
}
|
||||
|
||||
int notificationId(String occurrenceKey) {
|
||||
var hash = 0x811C9DC5;
|
||||
for (final byte in utf8.encode(occurrenceKey)) {
|
||||
hash = ((hash ^ byte) * 0x01000193) & 0xFFFFFFFF;
|
||||
}
|
||||
return hash & 0x7FFFFFFF;
|
||||
}
|
||||
|
||||
class LocalNotificationService extends NotificationService {
|
||||
LocalNotificationService(this.gateway);
|
||||
final ReminderGateway gateway;
|
||||
final _fingerprints = <int, String>{};
|
||||
@override
|
||||
Future<ReminderResult> reconcile(
|
||||
List<CourseOccurrence> occurrences,
|
||||
AppSettings settings,
|
||||
DateTime now,
|
||||
) async {
|
||||
if (!settings.remindersEnabled || settings.semester == null) {
|
||||
await cancelOwned();
|
||||
return const ReminderResult(ReminderStatus.off);
|
||||
}
|
||||
final access = await gateway.permissions(request: false);
|
||||
if (!access.notifications) {
|
||||
await cancelOwned();
|
||||
return const ReminderResult(
|
||||
ReminderStatus.permissionDenied,
|
||||
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;
|
||||
});
|
||||
final limited = upcoming.length > 400;
|
||||
final selected = upcoming.take(400).toList()
|
||||
..sort((a, b) => a.key.compareTo(b.key));
|
||||
final desired = <int, ReminderRequest>{};
|
||||
for (final event in selected) {
|
||||
var id = notificationId(event.key);
|
||||
while (desired.containsKey(id)) {
|
||||
id = (id + 1) & 0x7FFFFFFF;
|
||||
}
|
||||
desired[id] = ReminderRequest(
|
||||
id: id,
|
||||
occurrenceKey: event.key,
|
||||
fireAt: event.start.subtract(
|
||||
Duration(minutes: settings.reminderMinutes),
|
||||
),
|
||||
title: event.course.name,
|
||||
body: '${event.course.room} · ${event.course.teacher}',
|
||||
exact: access.exactAlarm,
|
||||
);
|
||||
}
|
||||
final pending = await gateway.pendingIds();
|
||||
for (final old in pending.difference(desired.keys.toSet())) {
|
||||
await gateway.cancel(old);
|
||||
_fingerprints.remove(old);
|
||||
}
|
||||
for (final entry in desired.entries) {
|
||||
final request = entry.value;
|
||||
final fingerprint =
|
||||
'${request.fireAt.millisecondsSinceEpoch}/${request.title}/${request.body}/${request.exact}';
|
||||
if (pending.contains(entry.key) &&
|
||||
_fingerprints[entry.key] == fingerprint) {
|
||||
continue;
|
||||
}
|
||||
if (pending.contains(entry.key)) await gateway.cancel(entry.key);
|
||||
await gateway.schedule(request);
|
||||
_fingerprints[entry.key] = fingerprint;
|
||||
}
|
||||
final message = [
|
||||
if (!access.exactAlarm) '未允许精确提醒,系统可能延迟送达。',
|
||||
if (limited) '已安排最近 400 次提醒,后续提醒在再次打开时补充。',
|
||||
].join(' ');
|
||||
return ReminderResult(
|
||||
access.exactAlarm ? ReminderStatus.exact : ReminderStatus.inexact,
|
||||
scheduledCount: desired.length,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestPermissions() async {
|
||||
await gateway.permissions(request: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancelOwned() async {
|
||||
for (final id in await gateway.pendingIds()) {
|
||||
await gateway.cancel(id);
|
||||
}
|
||||
_fingerprints.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class PluginReminderGateway extends ReminderGateway {
|
||||
PluginReminderGateway({this.onTap});
|
||||
final void Function(String key)? onTap;
|
||||
final _plugin = FlutterLocalNotificationsPlugin();
|
||||
Future<void>? _initializing;
|
||||
Future<void> initialize() => _initializing ??= () async {
|
||||
await _plugin.initialize(
|
||||
settings: const InitializationSettings(
|
||||
android: AndroidInitializationSettings('ic_stat_calendar'),
|
||||
),
|
||||
onDidReceiveNotificationResponse: (response) => _select(response.payload),
|
||||
);
|
||||
final launch = await _plugin.getNotificationAppLaunchDetails();
|
||||
if (launch?.didNotificationLaunchApp == true) {
|
||||
_select(launch?.notificationResponse?.payload);
|
||||
}
|
||||
}();
|
||||
void _select(String? payload) {
|
||||
if (payload?.startsWith('hepan:') == true) {
|
||||
onTap?.call(payload!.substring(6));
|
||||
}
|
||||
}
|
||||
|
||||
AndroidFlutterLocalNotificationsPlugin? get _android => _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
@override
|
||||
Future<ReminderPermissions> permissions({required bool request}) async {
|
||||
await initialize();
|
||||
if (request) {
|
||||
await _android?.requestNotificationsPermission();
|
||||
await _android?.requestExactAlarmsPermission();
|
||||
}
|
||||
return ReminderPermissions(
|
||||
await _android?.areNotificationsEnabled() ?? false,
|
||||
await _android?.canScheduleExactNotifications() ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<int>> pendingIds() async {
|
||||
await initialize();
|
||||
final pending = await _plugin.pendingNotificationRequests();
|
||||
final active = await _plugin.getActiveNotifications();
|
||||
return {
|
||||
for (final item in pending)
|
||||
if (item.payload?.startsWith('hepan:') == true) item.id,
|
||||
for (final item in active)
|
||||
if (item.tag == 'hepan-course' && item.id != null) item.id!,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(int id) async {
|
||||
await initialize();
|
||||
await _plugin.cancel(id: id, tag: 'hepan-course');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> schedule(ReminderRequest value) async {
|
||||
await initialize();
|
||||
await _plugin.zonedSchedule(
|
||||
id: value.id,
|
||||
title: value.title,
|
||||
body: value.body,
|
||||
scheduledDate: value.fireAt,
|
||||
payload: 'hepan:${value.occurrenceKey}',
|
||||
androidScheduleMode: value.exact
|
||||
? AndroidScheduleMode.exactAllowWhileIdle
|
||||
: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
notificationDetails: const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'hepan_lessons',
|
||||
'课前提醒',
|
||||
channelDescription: '课程开始前的提醒',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: 'ic_stat_calendar',
|
||||
tag: 'hepan-course',
|
||||
visibility: NotificationVisibility.private,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user