diff --git a/apps/lib/core/notifications/local_notification_service.dart b/apps/lib/core/notifications/local_notification_service.dart index 19e3b0d..ea463d1 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:timezone/data/latest.dart' as tz_data; import 'package:timezone/timezone.dart' as tz; @@ -18,45 +16,22 @@ 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'; static const String _actionSnooze = 'snooze10m'; final FlutterLocalNotificationsPlugin _plugin; - final ReminderPermissionFallbackTracker? _permissionFallbackTracker; bool _initialized = false; - bool _canDeliverSystemNotification = true; ReminderNotificationActionHandler? _actionHandler; - ReminderInAppReminderHandler? _inAppReminderHandler; - final Map> _inAppFallbackTimersByEventId = - >{}; - LocalNotificationService({ - FlutterLocalNotificationsPlugin? plugin, - ReminderPermissionFallbackTracker? permissionFallbackTracker, - }) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(), - _permissionFallbackTracker = permissionFallbackTracker; + LocalNotificationService({FlutterLocalNotificationsPlugin? plugin}) + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); void bindActionHandler(ReminderNotificationActionHandler handler) { _actionHandler = handler; } - void bindInAppReminderHandler(ReminderInAppReminderHandler handler) { - _inAppReminderHandler = handler; - } - Future initialize() async { if (_initialized) { return; @@ -98,18 +73,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(); @@ -122,24 +86,8 @@ class LocalNotificationService { _initialized = true; } - 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); @@ -154,14 +102,6 @@ class LocalNotificationService { return; } - if (!_canDeliverSystemNotification) { - await _scheduleInAppFallbackRemindersFrom( - event: event, - firstFireAt: fireAt, - ); - return; - } - await cancelEventReminder(event.id); await _scheduleRemindersFrom(event: event, firstFireAt: fireAt); } @@ -171,21 +111,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) { @@ -206,21 +137,7 @@ class LocalNotificationService { Iterable events, ) async { await initialize(); - await _refreshAndroidNotificationAvailability(); for (final event in events) { - if (!_canDeliverSystemNotification) { - final reminderMinutes = event.metadata?.reminderMinutes ?? 0; - final fireAt = event.startAt.subtract( - Duration(minutes: reminderMinutes), - ); - if (fireAt.isAfter(DateTime.now())) { - await _scheduleInAppFallbackRemindersFrom( - event: event, - firstFireAt: fireAt, - ); - } - continue; - } await upsertEventReminder(event); } } @@ -241,10 +158,6 @@ class LocalNotificationService { } Future _resolveAndroidScheduleMode() async { - if (defaultTargetPlatform != TargetPlatform.android) { - return AndroidScheduleMode.exactAllowWhileIdle; - } - final androidImpl = _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin @@ -260,7 +173,7 @@ class LocalNotificationService { : AndroidScheduleMode.inexactAllowWhileIdle; } - NotificationDetails _buildNotificationDetails() { + NotificationDetails _buildNotificationDetails(DateTime fireAt) { return NotificationDetails( android: AndroidNotificationDetails( 'calendar_alarm_channel_v2', @@ -276,6 +189,7 @@ class LocalNotificationService { vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]), timeoutAfter: 30000, autoCancel: true, + groupKey: _getGroupKey(fireAt), actions: [ AndroidNotificationAction(_actionCancel, '取消'), AndroidNotificationAction( @@ -285,15 +199,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, @@ -319,7 +248,7 @@ class LocalNotificationService { version: 1, ); - final details = _buildNotificationDetails(); + final details = _buildNotificationDetails(fireAt); final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); final mode = await _resolveAndroidScheduleMode(); @@ -350,185 +279,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, @@ -546,56 +296,6 @@ class LocalNotificationService { } } - Future _scheduleAggregateReminder( - List events, - DateTime fireAt, - ) async { - if (events.isEmpty) { - return; - } - - final first = events.first; - final aggregateIds = events.map((event) => event.id).toList(); - for (final id in aggregateIds) { - await cancelEventReminder(id); - } - - 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, - ); - - final details = _buildNotificationDetails(); - final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); - final mode = await _resolveAndroidScheduleMode(); - final preview = events.take(3).map((item) => item.title).join('、'); - - await _plugin.zonedSchedule( - _notificationIdForEventCycle( - first.id, - fireAt, - ReminderPayloadMode.aggregate, - ), - '你有${events.length}个日程提醒', - preview, - scheduledAt, - details, - payload: jsonEncode(payload.toJson()), - androidScheduleMode: mode, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - ); - } - ReminderPayload? _decodePayload(String? raw) { if (raw == null || raw.isEmpty) { return null; @@ -650,13 +350,6 @@ class LocalNotificationService { } if (action == null) { - if (response.notificationResponseType == - NotificationResponseType.selectedNotification) { - final presenter = _inAppReminderHandler; - if (presenter != null) { - await presenter(payload); - } - } return; } diff --git a/apps/lib/core/startup/auth_session_bootstrapper.dart b/apps/lib/core/startup/auth_session_bootstrapper.dart index 0fb74fe..003fa69 100644 --- a/apps/lib/core/startup/auth_session_bootstrapper.dart +++ b/apps/lib/core/startup/auth_session_bootstrapper.dart @@ -1,20 +1,16 @@ import '../../features/auth/presentation/bloc/auth_state.dart'; import '../../features/calendar/data/services/calendar_service.dart'; -import '../../features/calendar/reminders/reminder_action_executor.dart'; import '../notifications/local_notification_service.dart'; class AuthSessionBootstrapper { AuthSessionBootstrapper({ required CalendarService calendarService, required LocalNotificationService notificationService, - required ReminderActionExecutor reminderActionExecutor, }) : _calendarService = calendarService, - _notificationService = notificationService, - _reminderActionExecutor = reminderActionExecutor; + _notificationService = notificationService; final CalendarService _calendarService; final LocalNotificationService _notificationService; - final ReminderActionExecutor _reminderActionExecutor; String? _syncedUserId; diff --git a/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart b/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart index 788a3a2..c2cb2b7 100644 --- a/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart +++ b/apps/lib/features/calendar/reminders/ui/reminder_overlay.dart @@ -24,7 +24,6 @@ class ReminderOverlay extends StatefulWidget { } class _ReminderOverlayState extends State { - bool _showSnoozeOptions = false; OverlayEntry? _overlayEntry; ReminderPayload? get _currentPayload => widget.queueManager.currentPayload; @@ -38,9 +37,6 @@ class _ReminderOverlayState extends State { void _hideSnoozeOptions() { _overlayEntry?.remove(); _overlayEntry = null; - setState(() { - _showSnoozeOptions = false; - }); } void _showSnoozeDropdown() { @@ -91,9 +87,6 @@ class _ReminderOverlayState extends State { ); Overlay.of(context).insert(_overlayEntry!); - setState(() { - _showSnoozeOptions = true; - }); } void _handleComplete() { diff --git a/apps/lib/main.dart b/apps/lib/main.dart index c7832fa..6fee5af 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -81,7 +81,6 @@ void main() async { sessionBootstrapper: AuthSessionBootstrapper( calendarService: sl(), notificationService: sl(), - reminderActionExecutor: sl(), ), queueManager: queueManager, payloadBridge: payloadBridge, diff --git a/apps/test/core/startup/auth_session_bootstrapper_test.dart b/apps/test/core/startup/auth_session_bootstrapper_test.dart deleted file mode 100644 index bd1169d..0000000 --- a/apps/test/core/startup/auth_session_bootstrapper_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:social_app/core/notifications/local_notification_service.dart'; -import 'package:social_app/core/startup/auth_session_bootstrapper.dart'; -import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; -import 'package:social_app/features/calendar/data/services/calendar_service.dart'; -import 'package:social_app/features/calendar/reminders/reminder_action_executor.dart'; - -class MockCalendarService extends Mock implements CalendarService {} - -class MockLocalNotificationService extends Mock - implements LocalNotificationService {} - -class MockReminderActionExecutor extends Mock - implements ReminderActionExecutor {} - -void main() { - late MockCalendarService calendarService; - late MockLocalNotificationService notificationService; - late MockReminderActionExecutor reminderActionExecutor; - late AuthSessionBootstrapper bootstrapper; - - setUp(() { - calendarService = MockCalendarService(); - notificationService = MockLocalNotificationService(); - reminderActionExecutor = MockReminderActionExecutor(); - bootstrapper = AuthSessionBootstrapper( - calendarService: calendarService, - notificationService: notificationService, - reminderActionExecutor: reminderActionExecutor, - ); - }); - - test('does not fetch calendar events for unauthenticated state', () async { - await bootstrapper.syncForAuthState(AuthUnauthenticated()); - - verifyNever(() => calendarService.getEventsForRange(any(), any())); - verifyNever(() => notificationService.rebuildUpcomingReminders(any())); - }); - - test('fetches upcoming events after authenticated state', () async { - when( - () => calendarService.getEventsForRange(any(), any()), - ).thenAnswer((_) async => []); - when( - () => notificationService.rebuildUpcomingReminders(any()), - ).thenAnswer((_) async {}); - - await bootstrapper.syncForAuthState( - const AuthAuthenticated( - user: AuthUser(id: 'u1', phone: 'a@test.com'), - ), - ); - - verify(() => calendarService.getEventsForRange(any(), any())).called(1); - verify(() => notificationService.rebuildUpcomingReminders(any())).called(1); - }); -} diff --git a/apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart b/apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart deleted file mode 100644 index 7d95c4c..0000000 --- a/apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:social_app/core/notifications/local_notification_service.dart'; -import 'package:social_app/core/notifications/reminder_notification_callbacks.dart'; -import 'package:social_app/features/calendar/reminders/models/reminder_action.dart'; -import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart'; - -class MockFlutterLocalNotificationsPlugin extends Mock - implements FlutterLocalNotificationsPlugin {} - -void main() { - setUpAll(() { - registerFallbackValue( - const InitializationSettings( - android: AndroidInitializationSettings('@mipmap/ic_launcher'), - iOS: DarwinInitializationSettings(), - ), - ); - }); - - late MockFlutterLocalNotificationsPlugin plugin; - late LocalNotificationService service; - late List handledActions; - late List presentedPayloads; - DidReceiveNotificationResponseCallback? callback; - - setUp(() async { - SharedPreferences.setMockInitialValues({}); - plugin = MockFlutterLocalNotificationsPlugin(); - service = LocalNotificationService(plugin: plugin); - handledActions = []; - presentedPayloads = []; - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((invocation) async { - callback = - invocation.namedArguments[#onDidReceiveNotificationResponse] - as DidReceiveNotificationResponseCallback?; - return true; - }); - - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - service.bindActionHandler(({required action, required payload}) async { - handledActions.add(action); - }); - service.bindInAppReminderHandler((payload) async { - presentedPayloads.add(payload); - }); - await ReminderNotificationCallbacks.bindResponseHandler( - service.handleNotificationResponse, - ); - - await service.initialize(); - }); - - test('cancel action from system notification maps to archive', () async { - callback!( - NotificationResponse( - notificationResponseType: - NotificationResponseType.selectedNotificationAction, - id: 101, - actionId: 'cancel', - payload: jsonEncode( - ReminderPayload( - eventId: 'evt_1', - title: 'sync', - startAt: DateTime.parse('2026-03-19T10:00:00+08:00'), - timezone: 'Asia/Shanghai', - ).toJson(), - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(handledActions, [ReminderAction.archive]); - }); - - test('duplicate notification response is handled only once', () async { - final response = NotificationResponse( - notificationResponseType: - NotificationResponseType.selectedNotificationAction, - id: 201, - actionId: 'cancel', - payload: jsonEncode( - ReminderPayload( - eventId: 'evt_2', - title: 'retro', - startAt: DateTime.parse('2026-03-19T11:00:00+08:00'), - timezone: 'Asia/Shanghai', - ).toJson(), - ), - ); - - callback!(response); - callback!(response); - await Future.delayed(Duration.zero); - - expect(handledActions, [ReminderAction.archive]); - }); - - test('notification body tap forwards payload to in-app presenter', () async { - callback!( - NotificationResponse( - notificationResponseType: NotificationResponseType.selectedNotification, - id: 301, - payload: jsonEncode( - ReminderPayload( - eventId: 'evt_3', - title: 'daily sync', - startAt: DateTime.parse('2026-03-19T12:00:00+08:00'), - timezone: 'Asia/Shanghai', - ).toJson(), - ), - ), - ); - await Future.delayed(Duration.zero); - - expect(presentedPayloads.map((item) => item.eventId), ['evt_3']); - expect(handledActions, isEmpty); - }); -} diff --git a/apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart b/apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart deleted file mode 100644 index ad8fb25..0000000 --- a/apps/test/features/calendar/reminders/reminder_permission_fallback_test.dart +++ /dev/null @@ -1,392 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:social_app/core/notifications/local_notification_service.dart'; -import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; -import 'package:timezone/data/latest.dart' as tz_data; -import 'package:timezone/timezone.dart' as tz; - -class MockFlutterLocalNotificationsPlugin extends Mock - implements FlutterLocalNotificationsPlugin {} - -class MockAndroidFlutterLocalNotificationsPlugin extends Mock - implements AndroidFlutterLocalNotificationsPlugin {} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUpAll(() { - tz_data.initializeTimeZones(); - registerFallbackValue(tz.TZDateTime.now(tz.local)); - registerFallbackValue(const NotificationDetails()); - registerFallbackValue( - const InitializationSettings( - android: AndroidInitializationSettings('@mipmap/ic_launcher'), - iOS: DarwinInitializationSettings(), - ), - ); - }); - - setUp(() { - SharedPreferences.setMockInitialValues({}); - debugDefaultTargetPlatformOverride = TargetPlatform.android; - }); - - tearDown(() { - debugDefaultTargetPlatformOverride = null; - }); - - test( - 'tracks fallback when Android notifications permission is denied', - () async { - final plugin = MockFlutterLocalNotificationsPlugin(); - final androidImpl = MockAndroidFlutterLocalNotificationsPlugin(); - final fallbackEvents = >[]; - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((_) async => true); - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(androidImpl); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - when( - () => androidImpl.requestNotificationsPermission(), - ).thenAnswer((_) async => false); - when( - () => androidImpl.requestExactAlarmsPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.requestFullScreenIntentPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.areNotificationsEnabled(), - ).thenAnswer((_) async => false); - - final service = LocalNotificationService( - plugin: plugin, - permissionFallbackTracker: - ({ - required actionExecutionId, - required permissionState, - required appLifecycleState, - required platform, - }) { - fallbackEvents.add({ - 'actionExecutionId': actionExecutionId, - 'permissionState': permissionState, - 'appLifecycleState': appLifecycleState, - 'platform': platform, - }); - }, - ); - - await service.initialize(); - - expect(fallbackEvents.length, 1); - expect(fallbackEvents.first['permissionState'], 'denied'); - expect(fallbackEvents.first['platform'], 'android'); - }, - ); - - test( - 'skips reminder scheduling when Android notifications are denied', - () async { - final plugin = MockFlutterLocalNotificationsPlugin(); - final androidImpl = MockAndroidFlutterLocalNotificationsPlugin(); - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((_) async => true); - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(androidImpl); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - when( - () => androidImpl.requestNotificationsPermission(), - ).thenAnswer((_) async => false); - when( - () => androidImpl.requestExactAlarmsPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.requestFullScreenIntentPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.areNotificationsEnabled(), - ).thenAnswer((_) async => false); - - final service = LocalNotificationService(plugin: plugin); - final event = ScheduleItemModel( - id: 'evt_1', - ownerId: 'u1', - title: 'sync', - startAt: DateTime.now().add(const Duration(minutes: 20)), - endAt: DateTime.now().add(const Duration(minutes: 50)), - metadata: ScheduleMetadata(reminderMinutes: 15), - ); - - await service.upsertEventReminder(event); - - verifyNever(() => plugin.pendingNotificationRequests()); - }, - ); - - test( - 'dispatches in-app reminder callback when notifications are denied', - () async { - final plugin = MockFlutterLocalNotificationsPlugin(); - final androidImpl = MockAndroidFlutterLocalNotificationsPlugin(); - final presentedEventIds = []; - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((_) async => true); - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(androidImpl); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - when( - () => androidImpl.requestNotificationsPermission(), - ).thenAnswer((_) async => false); - when( - () => androidImpl.requestExactAlarmsPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.requestFullScreenIntentPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.areNotificationsEnabled(), - ).thenAnswer((_) async => false); - - final service = LocalNotificationService(plugin: plugin); - service.bindInAppReminderHandler((payload) async { - presentedEventIds.add(payload.eventId); - }); - - final event = ScheduleItemModel( - id: 'evt_2', - ownerId: 'u1', - title: 'retro', - startAt: DateTime.now().add(const Duration(minutes: 20)), - endAt: DateTime.now().add(const Duration(minutes: 50)), - metadata: ScheduleMetadata(reminderMinutes: 15), - ); - - await service.scheduleReminderAt( - event, - DateTime.now().add(const Duration(milliseconds: 20)), - ); - await Future.delayed(const Duration(milliseconds: 100)); - - expect(presentedEventIds, contains('evt_2')); - verifyNever( - () => plugin.zonedSchedule( - any(), - any(), - any(), - any(), - any(), - payload: any(named: 'payload'), - androidScheduleMode: any(named: 'androidScheduleMode'), - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - ), - ); - }, - ); - - test('rebuild twice only dispatches one aggregate in-app fallback', () async { - final plugin = MockFlutterLocalNotificationsPlugin(); - final androidImpl = MockAndroidFlutterLocalNotificationsPlugin(); - final presentedPayloads = []; - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((_) async => true); - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(androidImpl); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - when( - () => androidImpl.requestNotificationsPermission(), - ).thenAnswer((_) async => false); - when( - () => androidImpl.requestExactAlarmsPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.requestFullScreenIntentPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.areNotificationsEnabled(), - ).thenAnswer((_) async => false); - - final service = LocalNotificationService(plugin: plugin); - service.bindInAppReminderHandler((payload) async { - presentedPayloads.add(payload.title); - }); - - final startAt = DateTime.now().add(const Duration(milliseconds: 50)); - final event1 = ScheduleItemModel( - id: 'evt_a', - ownerId: 'u1', - title: 'evt_a', - startAt: startAt, - endAt: startAt.add(const Duration(minutes: 30)), - metadata: ScheduleMetadata(reminderMinutes: 0), - ); - final event2 = ScheduleItemModel( - id: 'evt_b', - ownerId: 'u1', - title: 'evt_b', - startAt: startAt, - endAt: startAt.add(const Duration(minutes: 30)), - metadata: ScheduleMetadata(reminderMinutes: 0), - ); - - await service.rebuildUpcomingReminders([event1, event2]); - await service.rebuildUpcomingReminders([event1, event2]); - await Future.delayed(const Duration(milliseconds: 180)); - - expect( - presentedPayloads.where((title) => title.contains('你有2个日程提醒')).length, - 1, - ); - }); - - test( - 'rebuild clears stale in-app fallback timers for removed events', - () async { - final plugin = MockFlutterLocalNotificationsPlugin(); - final androidImpl = MockAndroidFlutterLocalNotificationsPlugin(); - final presentedEventIds = []; - - when( - () => plugin.initialize( - any(), - onDidReceiveNotificationResponse: any( - named: 'onDidReceiveNotificationResponse', - ), - onDidReceiveBackgroundNotificationResponse: any( - named: 'onDidReceiveBackgroundNotificationResponse', - ), - ), - ).thenAnswer((_) async => true); - when( - () => plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(), - ).thenReturn(androidImpl); - when( - () => plugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin - >(), - ).thenReturn(null); - - when( - () => androidImpl.requestNotificationsPermission(), - ).thenAnswer((_) async => false); - when( - () => androidImpl.requestExactAlarmsPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.requestFullScreenIntentPermission(), - ).thenAnswer((_) async => true); - when( - () => androidImpl.areNotificationsEnabled(), - ).thenAnswer((_) async => false); - - final service = LocalNotificationService(plugin: plugin); - service.bindInAppReminderHandler((payload) async { - presentedEventIds.add(payload.eventId); - }); - - final startAt = DateTime.now().add(const Duration(milliseconds: 80)); - final staleEvent = ScheduleItemModel( - id: 'evt_stale', - ownerId: 'u1', - title: 'evt_stale', - startAt: startAt, - endAt: startAt.add(const Duration(minutes: 20)), - metadata: ScheduleMetadata(reminderMinutes: 0), - ); - - await service.rebuildUpcomingReminders([staleEvent]); - await service.rebuildUpcomingReminders(const []); - await Future.delayed(const Duration(milliseconds: 220)); - - expect(presentedEventIds, isNot(contains('evt_stale'))); - }, - ); -}