diff --git a/apps/lib/core/notifications/local_notification_service.dart b/apps/lib/core/notifications/local_notification_service.dart index 2a9de1e..4821ea1 100644 --- a/apps/lib/core/notifications/local_notification_service.dart +++ b/apps/lib/core/notifications/local_notification_service.dart @@ -1,8 +1,6 @@ -import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:timezone/data/latest.dart' as tz_data; @@ -21,17 +19,6 @@ typedef ReminderNotificationActionHandler = required ReminderPayload payload, }); -typedef ReminderPermissionFallbackTracker = - void Function({ - required String actionExecutionId, - required String permissionState, - required String appLifecycleState, - required String platform, - }); - -typedef ReminderInAppReminderHandler = - Future Function(ReminderPayload payload); - class LocalNotificationService { static const String _iosCategoryId = 'calendar_reminder_v2'; static const String _actionCancel = 'cancel'; @@ -39,33 +26,22 @@ class LocalNotificationService { final FlutterLocalNotificationsPlugin _plugin; final ReminderOverlapPolicy _overlapPolicy; - final ReminderPermissionFallbackTracker? _permissionFallbackTracker; ReminderActionDedupeStore? _dedupeStore; bool _initialized = false; - bool _canDeliverSystemNotification = true; ReminderNotificationActionHandler? _actionHandler; - ReminderInAppReminderHandler? _inAppReminderHandler; - final Map> _inAppFallbackTimersByEventId = - >{}; LocalNotificationService({ FlutterLocalNotificationsPlugin? plugin, ReminderOverlapPolicy? overlapPolicy, ReminderActionDedupeStore? dedupeStore, - ReminderPermissionFallbackTracker? permissionFallbackTracker, }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), _overlapPolicy = overlapPolicy ?? const ReminderOverlapPolicy(), - _dedupeStore = dedupeStore, - _permissionFallbackTracker = permissionFallbackTracker; + _dedupeStore = dedupeStore; void bindActionHandler(ReminderNotificationActionHandler handler) { _actionHandler = handler; } - void bindInAppReminderHandler(ReminderInAppReminderHandler handler) { - _inAppReminderHandler = handler; - } - Future initialize() async { if (_initialized) { return; @@ -107,18 +83,7 @@ class LocalNotificationService { .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); - final androidPermissionGranted = await androidImpl - ?.requestNotificationsPermission(); - if (defaultTargetPlatform == TargetPlatform.android && - androidPermissionGranted == false) { - _canDeliverSystemNotification = false; - _permissionFallbackTracker?.call( - actionExecutionId: 'permission_check', - permissionState: 'denied', - appLifecycleState: 'unknown', - platform: 'android', - ); - } + await androidImpl?.requestNotificationsPermission(); await androidImpl?.requestExactAlarmsPermission(); await androidImpl?.requestFullScreenIntentPermission(); @@ -141,24 +106,8 @@ class LocalNotificationService { _dedupeStore = ReminderActionDedupeStore(prefs); } - Future _refreshAndroidNotificationAvailability() async { - if (defaultTargetPlatform != TargetPlatform.android) { - return; - } - final androidImpl = _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - final enabled = await androidImpl?.areNotificationsEnabled(); - if (enabled == null) { - return; - } - _canDeliverSystemNotification = enabled; - } - Future upsertEventReminder(ScheduleItemModel event) async { await initialize(); - await _refreshAndroidNotificationAvailability(); if (event.status != ScheduleStatus.active || event.metadata?.reminderMinutes == null) { await cancelEventReminder(event.id); @@ -172,14 +121,6 @@ class LocalNotificationService { return; } - if (!_canDeliverSystemNotification) { - await _scheduleInAppFallbackRemindersFrom( - event: event, - firstFireAt: fireAt, - ); - return; - } - await cancelEventReminder(event.id); await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); } @@ -189,21 +130,12 @@ class LocalNotificationService { DateTime fireAt, ) async { await initialize(); - await _refreshAndroidNotificationAvailability(); - if (!_canDeliverSystemNotification) { - await _scheduleInAppFallbackRemindersFrom( - event: event, - firstFireAt: fireAt, - ); - return; - } await cancelEventReminder(event.id); await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); } Future cancelEventReminder(String eventId) async { await initialize(); - _cancelInAppFallbackTimers(eventId); final pending = await _plugin.pendingNotificationRequests(); for (final request in pending) { @@ -224,23 +156,6 @@ class LocalNotificationService { Iterable events, ) async { await initialize(); - await _refreshAndroidNotificationAvailability(); - if (!_canDeliverSystemNotification) { - _clearAllInAppFallbackTimers(); - final now = DateTime.now(); - final groups = _overlapPolicy.groupByMinute(events, now: now); - for (final group in groups) { - if (group.isAggregate) { - await _scheduleInAppAggregateFallback(group.events, group.fireAt); - continue; - } - await _scheduleInAppFallbackRemindersFrom( - event: group.events.first, - firstFireAt: group.fireAt, - ); - } - return; - } final now = DateTime.now(); final groups = _overlapPolicy.groupByMinute(events, now: now); @@ -288,7 +203,7 @@ class LocalNotificationService { : AndroidScheduleMode.inexactAllowWhileIdle; } - NotificationDetails _buildNotificationDetails() { + NotificationDetails _buildNotificationDetails(DateTime fireAt) { return NotificationDetails( android: AndroidNotificationDetails( 'calendar_alarm_channel_v2', @@ -304,6 +219,7 @@ class LocalNotificationService { vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]), timeoutAfter: 30000, autoCancel: true, + groupKey: _getGroupKey(fireAt), actions: [ AndroidNotificationAction(_actionCancel, '取消'), AndroidNotificationAction( @@ -313,15 +229,30 @@ class LocalNotificationService { ), ], ), - iOS: const DarwinNotificationDetails( + iOS: DarwinNotificationDetails( presentAlert: true, presentSound: true, presentBadge: true, categoryIdentifier: _iosCategoryId, + threadIdentifier: _getThreadIdentifier(fireAt), ), ); } + String _getThreadIdentifier(DateTime fireAt) { + final bucket = + fireAt.millisecondsSinceEpoch ~/ + const Duration(minutes: 1).inMilliseconds; + return 'calendar_reminder_$bucket'; + } + + String _getGroupKey(DateTime fireAt) { + final bucket = + fireAt.millisecondsSinceEpoch ~/ + const Duration(minutes: 1).inMilliseconds; + return 'com.socialapp.calendar.$bucket'; + } + Future _scheduleSingleReminder({ required ScheduleItemModel event, required DateTime fireAt, @@ -347,7 +278,7 @@ class LocalNotificationService { version: 1, ); - final details = _buildNotificationDetails(); + final details = _buildNotificationDetails(fireAt); final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); final mode = await _resolveAndroidScheduleMode(); @@ -378,185 +309,6 @@ class LocalNotificationService { } } - Future _scheduleInAppAggregateFallback( - List events, - DateTime fireAt, - ) async { - if (events.isEmpty) { - return; - } - - final aggregateIds = events.map((event) => event.id).toList(); - for (final eventId in aggregateIds) { - _cancelInAppFallbackTimers(eventId); - } - - final first = events.first; - final payload = ReminderPayload( - eventId: first.id, - title: '你有${events.length}个日程提醒', - startAt: first.startAt, - endAt: first.endAt, - timezone: first.timezone, - mode: ReminderPayloadMode.aggregate, - aggregateIds: aggregateIds, - fireTimeBucket: - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds, - version: 1, - ); - await _scheduleInAppFallbackPayload( - eventId: first.id, - fireAt: fireAt, - payload: payload, - relatedEventIds: aggregateIds, - ); - } - - Future _scheduleInAppFallbackRemindersFrom({ - required ScheduleItemModel event, - required DateTime firstFireAt, - }) async { - _cancelInAppFallbackTimers(event.id); - - final endAt = event.endAt; - var cursor = firstFireAt; - Future scheduleAt(DateTime fireAt) async { - final payload = ReminderPayload( - eventId: event.id, - title: event.title, - startAt: event.startAt, - endAt: event.endAt, - timezone: event.timezone, - location: event.metadata?.location, - notes: event.metadata?.notes, - color: event.metadata?.color, - mode: ReminderPayloadMode.single, - fireTimeBucket: - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds, - version: 1, - ); - await _scheduleInAppFallbackPayload( - eventId: event.id, - fireAt: fireAt, - payload: payload, - relatedEventIds: [event.id], - ); - } - - if (endAt == null) { - await scheduleAt(cursor); - return; - } - - while (cursor.isBefore(endAt)) { - await scheduleAt(cursor); - cursor = cursor.add(const Duration(minutes: 10)); - } - } - - Future _scheduleInAppFallbackPayload({ - required String eventId, - required DateTime fireAt, - required ReminderPayload payload, - required List relatedEventIds, - }) async { - final handler = _inAppReminderHandler; - final bucket = - fireAt.millisecondsSinceEpoch ~/ - const Duration(minutes: 1).inMilliseconds; - final actionExecutionId = '$eventId|fallback|$bucket'; - _trackFallback( - actionExecutionId: actionExecutionId, - permissionState: 'denied', - ); - - if (handler == null) { - return null; - } - - final now = DateTime.now(); - final delay = fireAt.isAfter(now) ? fireAt.difference(now) : Duration.zero; - late final Timer timer; - timer = Timer(delay, () { - final activeHandler = _inAppReminderHandler; - if (activeHandler == null) { - _unregisterInAppFallbackTimer(relatedEventIds, timer); - return; - } - activeHandler(payload); - _unregisterInAppFallbackTimer(relatedEventIds, timer); - }); - _registerInAppFallbackTimer(relatedEventIds, timer); - return timer; - } - - void _registerInAppFallbackTimer(List eventIds, Timer timer) { - for (final eventId in eventIds) { - final timers = _inAppFallbackTimersByEventId.putIfAbsent( - eventId, - () => [], - ); - timers.add(timer); - } - } - - void _unregisterInAppFallbackTimer(List eventIds, Timer timer) { - for (final eventId in eventIds) { - final timers = _inAppFallbackTimersByEventId[eventId]; - if (timers == null) { - continue; - } - timers.remove(timer); - if (timers.isEmpty) { - _inAppFallbackTimersByEventId.remove(eventId); - } - } - } - - void _cancelInAppFallbackTimers(String eventId) { - final timers = _inAppFallbackTimersByEventId.remove(eventId); - if (timers == null) { - return; - } - - for (final timer in timers.toSet()) { - for (final entry in _inAppFallbackTimersByEventId.entries) { - entry.value.remove(timer); - } - timer.cancel(); - } - - _inAppFallbackTimersByEventId.removeWhere((_, value) => value.isEmpty); - } - - void _clearAllInAppFallbackTimers() { - final allTimers = {}; - for (final timers in _inAppFallbackTimersByEventId.values) { - allTimers.addAll(timers); - } - _inAppFallbackTimersByEventId.clear(); - - for (final timer in allTimers) { - timer.cancel(); - } - } - - void _trackFallback({ - required String actionExecutionId, - required String permissionState, - }) { - final lifecycleState = - WidgetsBinding.instance.lifecycleState?.name ?? 'unknown'; - _permissionFallbackTracker?.call( - actionExecutionId: actionExecutionId, - permissionState: permissionState, - appLifecycleState: lifecycleState, - platform: 'android', - ); - } - Future _scheduleRemindersFrom({ required ScheduleItemModel event, required DateTime firstFireAt, @@ -602,7 +354,7 @@ class LocalNotificationService { version: 1, ); - final details = _buildNotificationDetails(); + final details = _buildNotificationDetails(fireAt); final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); final mode = await _resolveAndroidScheduleMode(); final preview = events.take(3).map((item) => item.title).join('、'); @@ -678,13 +430,6 @@ class LocalNotificationService { } if (action == null) { - if (response.notificationResponseType == - NotificationResponseType.selectedNotification) { - final presenter = _inAppReminderHandler; - if (presenter != null) { - await presenter(payload); - } - } return; }