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:
agra
2026-05-27 13:02:51 +03:00
parent ff520be971
commit f5d32a828f
4 changed files with 400 additions and 0 deletions

View 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;
}

View File

@@ -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_view.dart' show XVideoPlayerView;
export 'src/clipboard.dart';
export 'src/notifications/x_notifications.dart';
export 'src/file.dart';
export 'src/gallery.dart';
export 'src/keyboard.dart';

View 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)
}
}

View File

@@ -12,6 +12,7 @@ public class XPlugin: NSObject, FlutterPlugin {
CameraPlugin(),
UxVideoPlayerPlugin(),
UrlPlugin(),
NotificationsPlugin(),
]
for plugin in plugins {
plugin.register(with: registrar)