window: extract XWindow primitive; XNotifications stops carrying focus
The window-focus signal had no business living on the notifications primitive — it was there because the same NotificationsPlugin happened to observe NSApplication active/resign for its own reasons. Splitting it into a sibling XWindow primitive (with its own WindowPlugin on macOS, ux/window/events) lets future consumers — paused video, deferred-work scheduling, dock badge counts — read focus state without pulling in UNUserNotificationCenter. XNotifications now only exposes notification I/O (show/cancel + tap + authorization). The 'type:focus' event-channel branch is gone.
This commit is contained in:
@@ -8,20 +8,16 @@ import '../log.dart' show Log;
|
|||||||
|
|
||||||
final _log = Log.tag('notifications');
|
final _log = Log.tag('notifications');
|
||||||
|
|
||||||
/// Generic OS-notification + window-focus surface. Domain-agnostic — the
|
/// OS-notification primitive. Domain-agnostic — the caller decides when
|
||||||
/// caller decides when to emit and how to format the payload. Implemented
|
/// to emit and how to format the payload. Implemented on macOS via
|
||||||
/// on macOS via `UNUserNotificationCenter` and NSWindow key-focus
|
/// `UNUserNotificationCenter` (see `macos/Classes/NotificationsPlugin.swift`).
|
||||||
/// observers (see `macos/Classes/NotificationsPlugin.swift`).
|
/// Window-focus state lives in [XWindow], not here.
|
||||||
abstract interface class XNotifications {
|
abstract interface class XNotifications {
|
||||||
/// Production singleton — talks to the native plugin via MethodChannel
|
/// Production singleton — talks to the native plugin via MethodChannel
|
||||||
/// + EventChannel. Tests inject a fake `XNotifications` directly into
|
/// + EventChannel. Tests inject a fake `XNotifications` directly into
|
||||||
/// their service-under-test rather than swap this field.
|
/// their service-under-test rather than swap this field.
|
||||||
static final XNotifications instance = MethodChannelXNotifications();
|
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
|
/// `null` = OS hasn't been asked; `true`/`false` after the user has
|
||||||
/// answered an authorization prompt.
|
/// answered an authorization prompt.
|
||||||
ValueListenable<bool?> get authorized;
|
ValueListenable<bool?> get authorized;
|
||||||
@@ -50,9 +46,6 @@ class MethodChannelXNotifications implements XNotifications {
|
|||||||
static const _channel = MethodChannel('ux/notifications');
|
static const _channel = MethodChannel('ux/notifications');
|
||||||
static const _eventsChannel = EventChannel('ux/notifications/events');
|
static const _eventsChannel = EventChannel('ux/notifications/events');
|
||||||
|
|
||||||
@override
|
|
||||||
final ValueEmitter<bool> windowFocused = ValueEmitter(true);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final ValueEmitter<bool?> authorized = ValueEmitter(null);
|
final ValueEmitter<bool?> authorized = ValueEmitter(null);
|
||||||
|
|
||||||
@@ -121,9 +114,6 @@ class MethodChannelXNotifications implements XNotifications {
|
|||||||
if (event is! Map) return;
|
if (event is! Map) return;
|
||||||
final m = event.cast<Object?, Object?>();
|
final m = event.cast<Object?, Object?>();
|
||||||
switch (m['type']) {
|
switch (m['type']) {
|
||||||
case 'focus':
|
|
||||||
final f = m['focused'];
|
|
||||||
if (f is bool) windowFocused.value = f;
|
|
||||||
case 'authorization':
|
case 'authorization':
|
||||||
final g = m['granted'];
|
final g = m['granted'];
|
||||||
if (g is bool) authorized.value = g;
|
if (g is bool) authorized.value = g;
|
||||||
|
|||||||
49
lib/src/window/x_window.dart
Normal file
49
lib/src/window/x_window.dart
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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('window');
|
||||||
|
|
||||||
|
/// Host-window state primitive. Today exposes only [focused] (the macOS
|
||||||
|
/// `NSApp.didBecomeActive` / `didResignActive` signal); future window-level
|
||||||
|
/// state — minimized, occluded, screen — can land alongside it without
|
||||||
|
/// disturbing call sites.
|
||||||
|
abstract interface class XWindow {
|
||||||
|
static final XWindow instance = MethodChannelXWindow();
|
||||||
|
|
||||||
|
/// `true` when this app currently has user focus.
|
||||||
|
///
|
||||||
|
/// - macOS: tracks `NSApplication.didBecomeActiveNotification` /
|
||||||
|
/// `didResignActiveNotification` via the native plugin.
|
||||||
|
/// - iOS / Android: no native plugin registers, so the emitter stays
|
||||||
|
/// at its `true` default. That's correct because socket frames only
|
||||||
|
/// reach the Dart isolate while the process is alive — by the time
|
||||||
|
/// the OS suspends the app, deliveries have stopped.
|
||||||
|
ValueListenable<bool> get focused;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MethodChannelXWindow implements XWindow {
|
||||||
|
MethodChannelXWindow() {
|
||||||
|
_events.receiveBroadcastStream().listen(_onEvent, onError: (e, st) {
|
||||||
|
_log.w('event channel error', error: e, stackTrace: st);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _events = EventChannel('ux/window/events');
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ValueEmitter<bool> focused = ValueEmitter(true);
|
||||||
|
|
||||||
|
void _onEvent(Object? event) {
|
||||||
|
if (event is! Map) return;
|
||||||
|
final m = event.cast<Object?, Object?>();
|
||||||
|
if (m['type'] == 'focus') {
|
||||||
|
final f = m['focused'];
|
||||||
|
if (f is bool) focused.value = f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export 'src/video/x_video_player_channel.dart' show MethodChannelXVideoPlayerBac
|
|||||||
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/notifications/x_notifications.dart';
|
||||||
|
export 'src/window/x_window.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';
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import AppKit
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
/// `ux/notifications` + `ux/notifications/events`. Generic OS-notification
|
/// `ux/notifications` + `ux/notifications/events`. Domain-agnostic native
|
||||||
/// + app-focus surface used by hosts that want native Notification Center
|
/// OS-notification surface: show / cancel via the method channel, tap +
|
||||||
/// entries on macOS. Domain-agnostic — the Dart caller decides when to
|
/// authorization changes via the event channel. Window-focus state lives
|
||||||
/// emit and how to format the payload.
|
/// in `WindowPlugin` (see `XWindow` on the Dart side).
|
||||||
public class NotificationsPlugin: NSObject, NativePlugin, FlutterStreamHandler,
|
public class NotificationsPlugin: NSObject, NativePlugin, FlutterStreamHandler,
|
||||||
UNUserNotificationCenterDelegate
|
UNUserNotificationCenterDelegate
|
||||||
{
|
{
|
||||||
@@ -31,7 +31,6 @@ public class NotificationsPlugin: NSObject, NativePlugin, FlutterStreamHandler,
|
|||||||
eventChannel = events
|
eventChannel = events
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
observeAppActivation()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - FlutterStreamHandler
|
// MARK: - FlutterStreamHandler
|
||||||
@@ -41,9 +40,6 @@ public class NotificationsPlugin: NSObject, NativePlugin, FlutterStreamHandler,
|
|||||||
eventSink events: @escaping FlutterEventSink
|
eventSink events: @escaping FlutterEventSink
|
||||||
) -> FlutterError? {
|
) -> FlutterError? {
|
||||||
eventSink = events
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,32 +167,6 @@ public class NotificationsPlugin: NSObject, NativePlugin, FlutterStreamHandler,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
|
|
||||||
public func userNotificationCenter(
|
public func userNotificationCenter(
|
||||||
|
|||||||
68
macos/Classes/WindowPlugin.swift
Normal file
68
macos/Classes/WindowPlugin.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import AppKit
|
||||||
|
import FlutterMacOS
|
||||||
|
|
||||||
|
/// `ux/window/events`. Reports host-application focus state — emits
|
||||||
|
/// `{type: "focus", focused: Bool}` whenever the active NSApplication
|
||||||
|
/// flips. Seeded from `NSApp.isActive` when Dart subscribes so the
|
||||||
|
/// emitter starts in a deterministic value.
|
||||||
|
public class WindowPlugin: NSObject, NativePlugin, FlutterStreamHandler {
|
||||||
|
private var eventChannel: FlutterEventChannel?
|
||||||
|
private var eventSink: FlutterEventSink?
|
||||||
|
|
||||||
|
public func register(with registrar: FlutterPluginRegistrar) {
|
||||||
|
let events = FlutterEventChannel(
|
||||||
|
name: "ux/window/events",
|
||||||
|
binaryMessenger: registrar.messenger,
|
||||||
|
)
|
||||||
|
events.setStreamHandler(self)
|
||||||
|
eventChannel = events
|
||||||
|
|
||||||
|
observeAppActivation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - FlutterStreamHandler
|
||||||
|
|
||||||
|
public func onListen(
|
||||||
|
withArguments arguments: Any?,
|
||||||
|
eventSink events: @escaping FlutterEventSink
|
||||||
|
) -> FlutterError? {
|
||||||
|
eventSink = events
|
||||||
|
emit(["type": "focus", "focused": NSApp.isActive])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||||
|
eventSink = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 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])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emit(_ event: [String: Any]) {
|
||||||
|
eventSink?(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ public class XPlugin: NSObject, FlutterPlugin {
|
|||||||
UxVideoPlayerPlugin(),
|
UxVideoPlayerPlugin(),
|
||||||
UrlPlugin(),
|
UrlPlugin(),
|
||||||
NotificationsPlugin(),
|
NotificationsPlugin(),
|
||||||
|
WindowPlugin(),
|
||||||
]
|
]
|
||||||
for plugin in plugins {
|
for plugin in plugins {
|
||||||
plugin.register(with: registrar)
|
plugin.register(with: registrar)
|
||||||
|
|||||||
Reference in New Issue
Block a user