import 'package:timezone/timezone.dart' as tz; import 'dart:convert'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter/services.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> ownedIds() => pendingIds(); Future sendTestReminder({required bool scheduled}) async => '当前环境不支持通知测试'; Future> diagnose() async => {}; Future openSettings({required bool background}) async {} 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 (await gateway.ownedIds()).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 saved = await gateway.pendingIds(); if (!saved.containsAll(desired.keys)) { throw const ReminderSetupException( 'schedule_not_saved', '系统未保存全部提醒,请点击“检查提醒”后重试。', ); } 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.ownedIds()) { await gateway.cancel(id); } _fingerprints.clear(); } @override Future sendTestReminder({required bool scheduled}) => gateway.sendTestReminder(scheduled: scheduled); @override Future> diagnose() => gateway.diagnose(); @override Future openSettings({required bool background}) => gateway.openSettings(background: background); } class PluginReminderGateway extends ReminderGateway { PluginReminderGateway({this.onTap}); final void Function(String key)? onTap; final _plugin = FlutterLocalNotificationsPlugin(); static const _system = MethodChannel('com.tpschedule.app/reminders'); String? _activeQueryWarning; 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(); return { for (final item in pending) if (item.payload?.startsWith('hepan:') == true) item.id, }; } @override Future> ownedIds() async { final owned = await pendingIds(); try { final active = await _plugin.getActiveNotifications(); owned.addAll([ for (final item in active) if (item.tag == 'hepan-course' && item.id != null) item.id!, ]); _activeQueryWarning = null; } catch (error) { // Some OEMs deny active notification access. Future alarms must still run. _activeQueryWarning = error is PlatformException ? error.code : error.runtimeType.toString(); } return owned; } @override Future sendTestReminder({required bool scheduled}) async { final access = await permissions(request: false); if (!access.notifications) return '请先允许通知和“课前提醒”通知类别。'; if (scheduled && !access.exactAlarm) return '请先允许“闹钟与提醒”,再测试后台通知。'; const details = NotificationDetails( android: AndroidNotificationDetails( 'hepan_lessons', '课前提醒', channelDescription: '课程开始前的提醒', importance: Importance.high, priority: Priority.high, icon: 'ic_stat_calendar', tag: 'hepan-test', ), ); if (scheduled) { await _plugin.cancel(id: -1002, tag: 'hepan-test'); await _plugin.zonedSchedule( id: -1002, title: 'TPSchedule 后台提醒测试', body: '后台定时提醒已送达。', scheduledDate: tz.TZDateTime.now(tz.getLocation('Asia/Shanghai')) .add(const Duration(seconds: 10)), payload: 'hepan-test:scheduled', notificationDetails: details, androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, ); final saved = await _plugin.pendingNotificationRequests(); if (!saved.any((item) => item.id == -1002)) { throw const ReminderSetupException('test_not_saved', '系统未保存测试提醒。'); } return '已安排测试,请按手机 Home 键回到桌面,10 秒后查看通知栏。'; } await _plugin.show( id: -1001, title: 'TPSchedule 通知测试', body: '立即通知已发送,请查看通知栏。', notificationDetails: details, payload: 'hepan-test:immediate', ); return '已发送测试,请查看手机通知栏。'; } @override Future> diagnose() async { final access = await permissions(request: false); final pending = await pendingIds(); final channels = await _android?.getNotificationChannels(); final channel = channels ?.where((item) => item.id == 'hepan_lessons') .firstOrNull; Map? system; String? systemError; try { system = await _system.invokeMapMethod('getStatus'); } catch (error) { systemError = error is PlatformException ? error.code : error.runtimeType.toString(); } return { 'notifications': access.notifications, 'exactAlarm': access.exactAlarm, 'pendingCount': pending.length, 'channelImportance': channel?.importance.value, 'channelSound': channel?.playSound, 'activeQueryWarning': _activeQueryWarning, 'system': system == null ? null : {for (final e in system.entries) '${e.key}': e.value}, 'systemError': ?systemError, }; } @override Future openSettings({required bool background}) async { await _system.invokeMethod( background ? 'openBackgroundSettings' : 'openNotificationSettings', ); } @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, ), ), ); } }