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_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';
|
||||
|
||||
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(),
|
||||
UxVideoPlayerPlugin(),
|
||||
UrlPlugin(),
|
||||
NotificationsPlugin(),
|
||||
]
|
||||
for plugin in plugins {
|
||||
plugin.register(with: registrar)
|
||||
|
||||
Reference in New Issue
Block a user