notifications: add XNotifications + macOS UNUserNotificationCenter plugin
Generic OS-notification + window-focus surface. Hand-rolled MethodChannel + EventChannel, registered through XPlugin alongside the existing camera / video / clipboard plugins. macOS native handler uses UNUserNotificationCenter with thread-grouping support, NSApp activation on tap, and NSApplicationDidBecomeActive/DidResignActive for the focus signal (more reliable than Flutter's AppLifecycleState on macOS).
This commit is contained in:
167
lib/src/notifications/x_notifications.dart
Normal file
167
lib/src/notifications/x_notifications.dart
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show ValueListenable;
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../core/emitter.dart';
|
||||||
|
import '../log.dart' show Log;
|
||||||
|
|
||||||
|
final _log = Log.tag('notifications');
|
||||||
|
|
||||||
|
/// Generic OS-notification + window-focus surface. Domain-agnostic — the
|
||||||
|
/// caller decides when to emit and how to format the payload. Implemented
|
||||||
|
/// on macOS via `UNUserNotificationCenter` and NSWindow key-focus
|
||||||
|
/// observers (see `macos/Classes/NotificationsPlugin.swift`).
|
||||||
|
abstract interface class XNotifications {
|
||||||
|
/// Production singleton — talks to the native plugin via MethodChannel
|
||||||
|
/// + EventChannel. Tests inject a fake `XNotifications` directly into
|
||||||
|
/// their service-under-test rather than swap this field.
|
||||||
|
static final XNotifications instance = MethodChannelXNotifications();
|
||||||
|
|
||||||
|
/// True when the host window currently holds key focus. Defaults to
|
||||||
|
/// `true` until the native side emits an initial focus event.
|
||||||
|
ValueListenable<bool> get windowFocused;
|
||||||
|
|
||||||
|
/// `null` = OS hasn't been asked; `true`/`false` after the user has
|
||||||
|
/// answered an authorization prompt.
|
||||||
|
ValueListenable<bool?> get authorized;
|
||||||
|
|
||||||
|
/// Tap events. Payload is the `data` Map supplied to [show].
|
||||||
|
Stream<Map<String, String>> get onTap;
|
||||||
|
|
||||||
|
Future<bool> requestPermission();
|
||||||
|
|
||||||
|
/// Posts (or replaces) a system notification. Same [XNotification.id]
|
||||||
|
/// replaces any prior notification with that id. [XNotification.threadId]
|
||||||
|
/// groups multiple notifications in the OS's notification UI.
|
||||||
|
Future<void> show(XNotification n);
|
||||||
|
|
||||||
|
Future<void> cancelByThread(String threadId);
|
||||||
|
Future<void> cancelAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MethodChannelXNotifications implements XNotifications {
|
||||||
|
MethodChannelXNotifications() {
|
||||||
|
_eventsChannel.receiveBroadcastStream().listen(_onEvent, onError: (e, st) {
|
||||||
|
_log.w('event channel error', error: e, stackTrace: st);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _channel = MethodChannel('ux/notifications');
|
||||||
|
static const _eventsChannel = EventChannel('ux/notifications/events');
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ValueEmitter<bool> windowFocused = ValueEmitter(true);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ValueEmitter<bool?> authorized = ValueEmitter(null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Map<String, String>> get onTap => _onTap.stream;
|
||||||
|
final StreamController<Map<String, String>> _onTap =
|
||||||
|
StreamController<Map<String, String>>.broadcast();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestPermission() async {
|
||||||
|
try {
|
||||||
|
final granted =
|
||||||
|
await _channel.invokeMethod<bool>('requestPermission') ?? false;
|
||||||
|
authorized.value = granted;
|
||||||
|
return granted;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
_log.w('requestPermission failed: ${e.message}');
|
||||||
|
authorized.value = false;
|
||||||
|
return false;
|
||||||
|
} on MissingPluginException {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> show(XNotification n) async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod('show', {
|
||||||
|
'id': n.id,
|
||||||
|
'title': n.title,
|
||||||
|
'body': n.body,
|
||||||
|
if (n.threadId != null) 'threadId': n.threadId,
|
||||||
|
'data': n.data,
|
||||||
|
});
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
_log.w('show failed: ${e.message}');
|
||||||
|
} on MissingPluginException {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelByThread(String threadId) async {
|
||||||
|
try {
|
||||||
|
await _channel
|
||||||
|
.invokeMethod('cancelByThread', {'threadId': threadId});
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
_log.w('cancelByThread failed: ${e.message}');
|
||||||
|
} on MissingPluginException {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> cancelAll() async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod('cancelAll');
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
_log.w('cancelAll failed: ${e.message}');
|
||||||
|
} on MissingPluginException {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEvent(Object? event) {
|
||||||
|
if (event is! Map) return;
|
||||||
|
final m = event.cast<Object?, Object?>();
|
||||||
|
switch (m['type']) {
|
||||||
|
case 'focus':
|
||||||
|
final f = m['focused'];
|
||||||
|
if (f is bool) windowFocused.value = f;
|
||||||
|
case 'authorization':
|
||||||
|
final g = m['granted'];
|
||||||
|
if (g is bool) authorized.value = g;
|
||||||
|
case 'tap':
|
||||||
|
final raw = m['data'];
|
||||||
|
final data = raw is Map
|
||||||
|
? Map<String, String>.fromEntries(
|
||||||
|
raw.entries.map((e) =>
|
||||||
|
MapEntry(e.key.toString(), e.value?.toString() ?? '')),
|
||||||
|
)
|
||||||
|
: <String, String>{};
|
||||||
|
_onTap.add(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class XNotification {
|
||||||
|
XNotification({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
this.threadId,
|
||||||
|
this.data = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Stable identifier; posting another notification with the same id
|
||||||
|
/// replaces the existing one (typical use: id = chat/thread id so the
|
||||||
|
/// latest preview from a chat replaces the older toast).
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
|
||||||
|
/// Groups notifications in the OS UI (macOS Notification Center
|
||||||
|
/// collapses entries sharing a `threadIdentifier`).
|
||||||
|
final String? threadId;
|
||||||
|
|
||||||
|
/// Delivered verbatim to the tap event when the user clicks the
|
||||||
|
/// notification.
|
||||||
|
final Map<String, String> data;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export 'src/video/x_video_player_backend.dart' show XVideoPlayerBackend, XVideoP
|
|||||||
export 'src/video/x_video_player_channel.dart' show MethodChannelXVideoPlayerBackend;
|
export 'src/video/x_video_player_channel.dart' show MethodChannelXVideoPlayerBackend;
|
||||||
export 'src/video/x_video_player_view.dart' show XVideoPlayerView;
|
export 'src/video/x_video_player_view.dart' show XVideoPlayerView;
|
||||||
export 'src/clipboard.dart';
|
export 'src/clipboard.dart';
|
||||||
|
export 'src/notifications/x_notifications.dart';
|
||||||
export 'src/file.dart';
|
export 'src/file.dart';
|
||||||
export 'src/gallery.dart';
|
export 'src/gallery.dart';
|
||||||
export 'src/keyboard.dart';
|
export 'src/keyboard.dart';
|
||||||
|
|||||||
231
macos/Classes/NotificationsPlugin.swift
Normal file
231
macos/Classes/NotificationsPlugin.swift
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import AppKit
|
||||||
|
import FlutterMacOS
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// `ux/notifications` + `ux/notifications/events`. Generic OS-notification
|
||||||
|
/// + app-focus surface used by hosts that want native Notification Center
|
||||||
|
/// entries on macOS. Domain-agnostic — the Dart caller decides when to
|
||||||
|
/// emit and how to format the payload.
|
||||||
|
public class NotificationsPlugin: NSObject, NativePlugin, FlutterStreamHandler,
|
||||||
|
UNUserNotificationCenterDelegate
|
||||||
|
{
|
||||||
|
private var methodChannel: FlutterMethodChannel?
|
||||||
|
private var eventChannel: FlutterEventChannel?
|
||||||
|
private var eventSink: FlutterEventSink?
|
||||||
|
|
||||||
|
public func register(with registrar: FlutterPluginRegistrar) {
|
||||||
|
let methods = FlutterMethodChannel(
|
||||||
|
name: "ux/notifications",
|
||||||
|
binaryMessenger: registrar.messenger,
|
||||||
|
)
|
||||||
|
methods.setMethodCallHandler { [weak self] call, result in
|
||||||
|
self?.handle(call, result: result)
|
||||||
|
}
|
||||||
|
methodChannel = methods
|
||||||
|
|
||||||
|
let events = FlutterEventChannel(
|
||||||
|
name: "ux/notifications/events",
|
||||||
|
binaryMessenger: registrar.messenger,
|
||||||
|
)
|
||||||
|
events.setStreamHandler(self)
|
||||||
|
eventChannel = events
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
observeAppActivation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FlutterStreamHandler
|
||||||
|
|
||||||
|
public func onListen(
|
||||||
|
withArguments arguments: Any?,
|
||||||
|
eventSink events: @escaping FlutterEventSink
|
||||||
|
) -> FlutterError? {
|
||||||
|
eventSink = events
|
||||||
|
// Seed the initial focus state so Dart-side starts in a deterministic
|
||||||
|
// value rather than the constructor default.
|
||||||
|
emit(["type": "focus", "focused": NSApp.isActive])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||||
|
eventSink = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MethodChannel dispatch
|
||||||
|
|
||||||
|
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
switch call.method {
|
||||||
|
case "requestPermission":
|
||||||
|
requestPermission(result: result)
|
||||||
|
case "show":
|
||||||
|
show(args: call.arguments, result: result)
|
||||||
|
case "cancelByThread":
|
||||||
|
cancelByThread(args: call.arguments, result: result)
|
||||||
|
case "cancelAll":
|
||||||
|
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
|
||||||
|
result(nil)
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestPermission(result: @escaping FlutterResult) {
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(
|
||||||
|
options: [.alert, .sound, .badge]
|
||||||
|
) { [weak self] granted, error in
|
||||||
|
if let error = error {
|
||||||
|
NSLog("ux notifications: requestAuthorization error: \(error)")
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.emit(["type": "authorization", "granted": granted])
|
||||||
|
result(granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func show(args: Any?, result: @escaping FlutterResult) {
|
||||||
|
guard let m = args as? [String: Any],
|
||||||
|
let id = m["id"] as? String,
|
||||||
|
let title = m["title"] as? String,
|
||||||
|
let body = m["body"] as? String
|
||||||
|
else {
|
||||||
|
result(FlutterError(code: "bad_args", message: "show expects id/title/body", details: nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let threadId = m["threadId"] as? String
|
||||||
|
let data = m["data"] as? [String: Any] ?? [:]
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
|
||||||
|
switch settings.authorizationStatus {
|
||||||
|
case .notDetermined:
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(
|
||||||
|
options: [.alert, .sound, .badge]
|
||||||
|
) { [weak self] granted, _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.emit(["type": "authorization", "granted": granted])
|
||||||
|
if granted {
|
||||||
|
self?.post(id: id, title: title, body: body, threadId: threadId, data: data)
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .denied:
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.emit(["type": "authorization", "granted": false])
|
||||||
|
result(nil)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.post(id: id, title: title, body: body, threadId: threadId, data: data)
|
||||||
|
result(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func post(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
threadId: String?,
|
||||||
|
data: [String: Any]
|
||||||
|
) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
if let t = threadId { content.threadIdentifier = t }
|
||||||
|
content.userInfo = data
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(identifier: id, content: content, trigger: nil)
|
||||||
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
|
if let error = error {
|
||||||
|
NSLog("ux notifications: add failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelByThread(args: Any?, result: @escaping FlutterResult) {
|
||||||
|
guard let m = args as? [String: Any],
|
||||||
|
let threadId = m["threadId"] as? String
|
||||||
|
else {
|
||||||
|
result(FlutterError(code: "bad_args", message: "cancelByThread expects threadId", details: nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.getDeliveredNotifications { delivered in
|
||||||
|
let ids = delivered
|
||||||
|
.filter { $0.request.content.threadIdentifier == threadId }
|
||||||
|
.map { $0.request.identifier }
|
||||||
|
if !ids.isEmpty {
|
||||||
|
center.removeDeliveredNotifications(withIdentifiers: ids)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { result(nil) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App-activation observers
|
||||||
|
|
||||||
|
private func observeAppActivation() {
|
||||||
|
let nc = NotificationCenter.default
|
||||||
|
nc.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(onDidBecomeActive),
|
||||||
|
name: NSApplication.didBecomeActiveNotification,
|
||||||
|
object: nil,
|
||||||
|
)
|
||||||
|
nc.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(onDidResignActive),
|
||||||
|
name: NSApplication.didResignActiveNotification,
|
||||||
|
object: nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func onDidBecomeActive() {
|
||||||
|
emit(["type": "focus", "focused": true])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func onDidResignActive() {
|
||||||
|
emit(["type": "focus", "focused": false])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
|
|
||||||
|
public func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler:
|
||||||
|
@escaping (UNNotificationPresentationOptions) -> Void,
|
||||||
|
) {
|
||||||
|
completionHandler([.alert, .sound])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse,
|
||||||
|
withCompletionHandler completionHandler: @escaping () -> Void,
|
||||||
|
) {
|
||||||
|
let data = response.notification.request.content.userInfo
|
||||||
|
let payload = stringifiedData(data)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
NSApp.mainWindow?.makeKeyAndOrderFront(nil)
|
||||||
|
emit(["type": "tap", "data": payload])
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stringifiedData(_ raw: [AnyHashable: Any]) -> [String: String] {
|
||||||
|
var out: [String: String] = [:]
|
||||||
|
for (k, v) in raw {
|
||||||
|
out["\(k)"] = "\(v)"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func emit(_ event: [String: Any]) {
|
||||||
|
eventSink?(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ public class XPlugin: NSObject, FlutterPlugin {
|
|||||||
CameraPlugin(),
|
CameraPlugin(),
|
||||||
UxVideoPlayerPlugin(),
|
UxVideoPlayerPlugin(),
|
||||||
UrlPlugin(),
|
UrlPlugin(),
|
||||||
|
NotificationsPlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
plugin.register(with: registrar)
|
plugin.register(with: registrar)
|
||||||
|
|||||||
Reference in New Issue
Block a user