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 permissions({required bool request}); Future> pendingIds(); Future schedule(ReminderRequest value); Future 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 = {}; @override Future reconcile( List occurrences, AppSettings settings, DateTime now, ) async { if (!settings.remindersEnabled || settings.semester == null) { await cancelOwned(); 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: '请允许通知,并检查系统中的“课前提醒”通知类别。', ); } final upcoming = <({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); final desired = {}; 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: reminder.fireAt, title: event.course.name, body: '${reminder.minutes}分钟后上课 · ${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 (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(' '); return ReminderResult( access.exactAlarm ? ReminderStatus.exact : ReminderStatus.inexact, scheduledCount: desired.length, message: message, ); } @override Future requestPermissions() async { await gateway.permissions(request: true); } @override Future 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? _initializing; Future initialize() => _initializing ??= Future(() async { try { final ready = await _plugin.initialize( settings: const InitializationSettings( android: AndroidInitializationSettings('ic_stat_calendar'), ), onDidReceiveNotificationResponse: (response) => _select(response.payload), ); if (ready != true) { throw StateError('通知服务初始化失败'); } final launch = await _plugin.getNotificationAppLaunchDetails(); if (launch?.didNotificationLaunchApp == true) { _select(launch?.notificationResponse?.payload); } } catch (_) { _initializing = null; rethrow; } }); void _select(String? payload) { if (payload?.startsWith('hepan:') == true) { onTap?.call(payload!.substring(6)); } } AndroidFlutterLocalNotificationsPlugin? get _android => _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); @override Future permissions({required bool request}) async { await initialize(); if (request) { 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) && courseChannelEnabled, await _android?.canScheduleExactNotifications() ?? false, ); } @override Future> 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 cancel(int id) async { await initialize(); await _plugin.cancel(id: id, tag: 'hepan-course'); } @override Future 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, ), ), ); } }