feat: add local reminders and owned calendar synchronization

This commit is contained in:
Codex
2026-10-13 00:11:48 +08:00
parent ffd3920c0d
commit 2c4256b880
13 changed files with 978 additions and 8 deletions
+48 -6
View File
@@ -140,21 +140,33 @@ class AppController extends ChangeNotifier {
});
Future<void> clearCourses() => _run(() async {
// Preserve ownership mapping until cleanup has actually succeeded.
await _save(snapshot.copyWith(courses: [], clearMetadata: true));
await _save(
snapshot.copyWith(
courses: [],
clearMetadata: true,
calendarCleanupPending: true,
),
);
isDemo = false;
await notifications.cancelOwned();
await calendar.deleteOwned(snapshot.calendarEventIds);
await _save(snapshot.copyWith(calendarEventIds: {}));
try {
await notifications.cancelOwned();
} catch (_) {
reminderResult = const ReminderResult(
ReminderStatus.error,
message: '提醒清理失败,可重试',
);
}
await _cleanupCalendar();
});
Future<void> disableCalendar({required bool deleteEvents}) => _run(() async {
await _save(
snapshot.copyWith(
settings: snapshot.settings.copyWith(calendarEnabled: false),
calendarCleanupPending: deleteEvents || snapshot.calendarCleanupPending,
),
);
if (deleteEvents) {
await calendar.deleteOwned(snapshot.calendarEventIds);
await _save(snapshot.copyWith(calendarEventIds: {}));
await _cleanupCalendar();
}
calendarSyncError = null;
});
@@ -166,6 +178,9 @@ class AppController extends ChangeNotifier {
calendarSyncError = '日历权限未获允许';
return;
}
if (snapshot.calendarCleanupPending) {
await _cleanupCalendar();
}
final calendars = await calendar.listWritableCalendars();
if (!calendars.any((c) => c.id == calendarId)) {
calendarSyncError = '请选择可写入的日历';
@@ -200,6 +215,26 @@ class AppController extends ChangeNotifier {
await _reconcile();
});
Future<void> refreshServices() => _run(_reconcile);
Future<void> retryCalendarCleanup() => _run(() async {
if (await calendar.requestAccess()) {
await _cleanupCalendar();
} else {
calendarSyncError = '日历权限未获允许,课程日程尚未清理';
}
});
Future<void> _cleanupCalendar() async {
try {
await calendar.deleteOwned(snapshot.calendarEventIds);
await _save(
snapshot.copyWith(calendarEventIds: {}, calendarCleanupPending: false),
);
calendarSyncError = null;
} catch (_) {
calendarSyncError = '课程日程尚未清理,请允许日历访问后重试';
rethrow;
}
}
Future<void> _reconcile() async {
if (!storageReady) {
return;
@@ -220,6 +255,13 @@ class AppController extends ChangeNotifier {
message: '提醒设置未完成,可重试',
);
}
if (snapshot.calendarCleanupPending) {
try {
await _cleanupCalendar();
} catch (_) {
return;
}
}
if (settings.calendarEnabled && settings.calendarId != null) {
try {
final ids = await calendar.sync(
+8
View File
@@ -18,6 +18,7 @@ class AppSnapshot {
this.settings = const AppSettings(),
List<Course> courses = const [],
this.importMetadata,
this.calendarCleanupPending = false,
Map<String, String> calendarEventIds = const {},
}) : courses = List.unmodifiable(courses),
calendarEventIds = Map.unmodifiable(calendarEventIds);
@@ -25,12 +26,14 @@ class AppSnapshot {
final List<Course> courses;
final ImportMetadata? importMetadata;
final Map<String, String> calendarEventIds;
final bool calendarCleanupPending;
AppSnapshot copyWith({
AppSettings? settings,
List<Course>? courses,
ImportMetadata? importMetadata,
Map<String, String>? calendarEventIds,
bool clearMetadata = false,
bool? calendarCleanupPending,
}) => AppSnapshot(
settings: settings ?? this.settings,
courses: courses ?? this.courses,
@@ -38,6 +41,8 @@ class AppSnapshot {
? null
: importMetadata ?? this.importMetadata,
calendarEventIds: calendarEventIds ?? this.calendarEventIds,
calendarCleanupPending:
calendarCleanupPending ?? this.calendarCleanupPending,
);
Map<String, dynamic> toJson() => {
'schemaVersion': 1,
@@ -66,6 +71,7 @@ class AppSnapshot {
'parserVersion': importMetadata!.parserVersion,
},
'calendarEventIds': calendarEventIds,
'calendarCleanupPending': calendarCleanupPending,
};
static AppSnapshot fromJson(Map<String, dynamic> json) {
try {
@@ -93,6 +99,8 @@ class AppSnapshot {
);
final metadata = json['importMetadata'];
return AppSnapshot(
calendarCleanupPending:
(json['calendarCleanupPending'] ?? false) as bool,
courses: values,
settings: AppSettings(
semester: term,
@@ -275,6 +275,11 @@ class _SettingsPageState extends State<SettingsPage> {
color: Color(0xFFB45D54),
),
),
if (c.snapshot.calendarCleanupPending)
TextButton(
onPressed: c.retryCalendarCleanup,
child: const Text('重试清理课程日程'),
),
],
),
),
@@ -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,
),
),
);
}
}