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 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 = {}; 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 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 ??= () 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 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> 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, ), ), ); } }