feat: 实现日历提醒 in-app fallback 机制及通知服务重构
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
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 = <Map<String, String>>[];
|
||||
|
||||
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 = <String>[];
|
||||
|
||||
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<void>.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 = <String>[];
|
||||
|
||||
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<void>.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 = <String>[];
|
||||
|
||||
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 <ScheduleItemModel>[]);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 220));
|
||||
|
||||
expect(presentedEventIds, isNot(contains('evt_stale')));
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user