From 4f1b85cf760aba3915671fec8852c4cf382d4dc9 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Mon, 8 Jun 2026 22:54:46 +0900 Subject: [PATCH] feat(flutter): add poke notification service --- .../services/poke_notification_service.dart | 179 ++++++++++++++++++ .../poke_notification_service_test.dart | 63 ++++++ 2 files changed, 242 insertions(+) create mode 100644 apps/chanora_flutter/lib/services/poke_notification_service.dart create mode 100644 apps/chanora_flutter/test/services/poke_notification_service_test.dart diff --git a/apps/chanora_flutter/lib/services/poke_notification_service.dart b/apps/chanora_flutter/lib/services/poke_notification_service.dart new file mode 100644 index 0000000..2cd1168 --- /dev/null +++ b/apps/chanora_flutter/lib/services/poke_notification_service.dart @@ -0,0 +1,179 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import '../src/rust/api.dart' as rust; + +class PokeNotificationService { + PokeNotificationService({FlutterLocalNotificationsPlugin? notifications}) + : _notifications = notifications ?? FlutterLocalNotificationsPlugin(); + + static const _strongAndroidChannelId = 'chanora_pokes_strong_v1'; + static const _defaultAndroidChannelId = 'chanora_pokes_default_v1'; + static const _groupKey = 'chanora.pokes'; + static const _darwinThreadId = 'chanora.pokes'; + static const _windowsHeader = WindowsHeader( + id: 'chanora.pokes', + title: 'Pokes', + arguments: 'pokes', + ); + static const _windowsAppUserModelId = 'Chanora.Client'; + static const _windowsGuid = '6B7F3DCB-4418-4E0A-8CC7-02B7C95B675E'; + + final FlutterLocalNotificationsPlugin _notifications; + bool _initialized = false; + + Future init() async { + if (_initialized) return; + await _notifications.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('ic_chanora_notification'), + iOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + requestSoundPermission: false, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + defaultPresentSound: false, + ), + macOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + requestSoundPermission: false, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + defaultPresentSound: false, + ), + linux: LinuxInitializationSettings( + defaultActionName: 'Open', + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + defaultSuppressSound: true, + ), + windows: WindowsInitializationSettings( + appName: 'Chanora', + appUserModelId: _windowsAppUserModelId, + guid: _windowsGuid, + ), + ), + ); + _initialized = true; + } + + Future requestPermission() async { + await init(); + if (kIsWeb) return true; + + final android = _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + if (android != null) { + return await android.requestNotificationsPermission() ?? true; + } + + final ios = _notifications + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >(); + if (ios != null) { + return await ios.requestPermissions(alert: true, badge: true) ?? false; + } + + final macOS = _notifications + .resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin + >(); + if (macOS != null) { + return await macOS.requestPermissions(alert: true, badge: true) ?? false; + } + + return true; + } + + Future show({ + required String senderName, + required String message, + required BigInt senderId, + required rust.BridgePokeStrength strength, + }) async { + await init(); + final permitted = await requestPermission(); + if (!permitted) return; + + final trimmedMessage = message.trim(); + final body = trimmedMessage.isEmpty + ? '$senderName pokes you' + : trimmedMessage; + + await _notifications.show( + id: senderId.toUnsigned(31).toInt(), + title: 'Poke from $senderName', + body: body, + notificationDetails: NotificationDetails( + android: _androidDetails(strength), + iOS: _darwinDetails(strength), + macOS: _darwinDetails(strength), + linux: _linuxDetails(strength), + windows: _windowsDetails(strength), + ), + payload: 'poke:$senderId', + ); + } + + AndroidNotificationDetails _androidDetails(rust.BridgePokeStrength strength) { + final isStrong = strength == rust.BridgePokeStrength.strong; + return AndroidNotificationDetails( + isStrong ? _strongAndroidChannelId : _defaultAndroidChannelId, + isStrong ? 'Pokes' : 'Pokes (quiet)', + channelDescription: 'TeamSpeak poke notifications', + importance: isStrong ? Importance.max : Importance.defaultImportance, + priority: isStrong ? Priority.high : Priority.defaultPriority, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + playSound: false, + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + silent: true, + groupKey: _groupKey, + category: AndroidNotificationCategory.message, + visibility: NotificationVisibility.private, + ); + } + + DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) { + return DarwinNotificationDetails( + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + presentSound: false, + threadIdentifier: _darwinThreadId, + interruptionLevel: switch (strength) { + rust.BridgePokeStrength.strong => InterruptionLevel.timeSensitive, + rust.BridgePokeStrength.suppressed => InterruptionLevel.active, + rust.BridgePokeStrength.suppressedOverflow => InterruptionLevel.passive, + }, + ); + } + + LinuxNotificationDetails _linuxDetails(rust.BridgePokeStrength strength) { + return LinuxNotificationDetails( + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + suppressSound: true, + urgency: switch (strength) { + rust.BridgePokeStrength.strong => LinuxNotificationUrgency.critical, + rust.BridgePokeStrength.suppressed => LinuxNotificationUrgency.normal, + rust.BridgePokeStrength.suppressedOverflow => + LinuxNotificationUrgency.low, + }, + ); + } + + WindowsNotificationDetails _windowsDetails(rust.BridgePokeStrength strength) { + return WindowsNotificationDetails( + // TODO(event-sounds): handled by future EventSoundService, not the OS channel. + audio: WindowsNotificationAudio.silent(), + header: _windowsHeader, + scenario: strength == rust.BridgePokeStrength.strong + ? WindowsNotificationScenario.urgent + : null, + duration: strength == rust.BridgePokeStrength.strong + ? WindowsNotificationDuration.long + : WindowsNotificationDuration.short, + ); + } +} diff --git a/apps/chanora_flutter/test/services/poke_notification_service_test.dart b/apps/chanora_flutter/test/services/poke_notification_service_test.dart new file mode 100644 index 0000000..d356be3 --- /dev/null +++ b/apps/chanora_flutter/test/services/poke_notification_service_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import 'package:chanora_flutter/services/poke_notification_service.dart'; +import 'package:chanora_flutter/src/rust/api.dart' as rust; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('dexterous.com/flutter/local_notifications'); + late List calls; + + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + AndroidFlutterLocalNotificationsPlugin.registerWith(); + calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return switch (call.method) { + 'initialize' => true, + 'requestNotificationsPermission' => true, + _ => null, + }; + }); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test( + 'show dispatches a silent poke notification with sender payload', + () async { + final service = PokeNotificationService(); + + await service.show( + senderName: 'Alice', + message: 'wake up', + senderId: BigInt.from(42), + strength: rust.BridgePokeStrength.strong, + ); + + final showCall = calls.singleWhere((call) => call.method == 'show'); + final arguments = Map.from(showCall.arguments as Map); + + expect(arguments['id'], 42); + expect(arguments['title'], 'Poke from Alice'); + expect(arguments['body'], 'wake up'); + expect(arguments['payload'], 'poke:42'); + final specifics = Map.from( + arguments['platformSpecifics'] as Map, + ); + expect(specifics['silent'], true); + expect(specifics['playSound'], false); + expect(specifics['groupKey'], 'chanora.pokes'); + }, + ); +}