372 lines
12 KiB
Dart
372 lines
12 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 桌面端托盘控制器
|
||
/// 创建时间: 2026-06-18
|
||
/// 更新时间: 2026-06-19
|
||
/// 作用: 整合托盘服务 + 菜单构建器 + 未读数 Provider,管理托盘生命周期
|
||
/// 上次更新: 添加语言变化监听,托盘菜单支持动态多语言
|
||
/// ============================================================
|
||
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:window_manager/window_manager.dart';
|
||
|
||
import '../../core/providers/split_view_provider.dart';
|
||
import '../../core/router/app_router.dart';
|
||
import '../../core/services/desktop/desktop_tray_menu_builder.dart';
|
||
import '../../core/services/desktop/desktop_tray_service.dart';
|
||
import '../../core/utils/logger.dart';
|
||
import '../../core/utils/platform/platform_utils.dart' as pu;
|
||
import '../../features/home/presentation/providers/readlater/readlater_provider.dart';
|
||
import '../../features/home/presentation/providers/readlater/tray_unread_count_provider.dart';
|
||
import '../../features/settings/providers/general_settings_provider.dart';
|
||
import '../../features/settings/providers/theme_settings_provider.dart';
|
||
|
||
/// 桌面端托盘控制器
|
||
///
|
||
/// 职责:
|
||
/// 1. 初始化/销毁托盘服务
|
||
/// 2. 监听未读数变化,更新托盘角标
|
||
/// 3. 监听主题变化,更新托盘图标
|
||
/// 4. 监听稍后阅读列表变化,更新托盘菜单
|
||
/// 5. 处理托盘事件(单击显示/隐藏、双击聚焦、右键菜单)
|
||
/// 6. 提供菜单项回调实现(导航、窗口控制、主题切换)
|
||
///
|
||
/// 使用方式:
|
||
/// ```dart
|
||
/// // 在 app.dart 的 initState 中
|
||
/// _trayController = DesktopTrayController(ref);
|
||
/// await _trayController.initialize();
|
||
///
|
||
/// // 在 dispose 中
|
||
/// await _trayController.dispose();
|
||
/// ```
|
||
class DesktopTrayController {
|
||
DesktopTrayController(this._ref);
|
||
|
||
final WidgetRef _ref;
|
||
StreamSubscription<TrayEvent>? _eventSub;
|
||
ProviderSubscription<int>? _unreadSub;
|
||
ProviderSubscription<GeneralSettingsState>? _generalSettingsSub;
|
||
bool _initialized = false;
|
||
bool _isWindowVisible = true;
|
||
|
||
/// 防止 popUpContextMenu 触发的 performClick 导致重入
|
||
bool _isPopUpMenuInProgress = false;
|
||
|
||
/// 初始化托盘控制器
|
||
Future<void> initialize() async {
|
||
if (!pu.isDesktop) return;
|
||
if (_initialized) return;
|
||
|
||
try {
|
||
final trayService = DesktopTrayService.instance;
|
||
if (!trayService.isSupported) {
|
||
Log.w('DesktopTrayController: 当前平台不支持托盘');
|
||
return;
|
||
}
|
||
|
||
// 1. 初始化托盘服务
|
||
await trayService.init();
|
||
|
||
// 2. 设置初始图标
|
||
final isDark = _ref.read(themeSettingsProvider).isDark;
|
||
await trayService.setIcon(isDark: isDark);
|
||
|
||
// 3. 设置初始 Tooltip
|
||
final unreadCount = _ref.read(trayUnreadCountProvider);
|
||
final languageId = _ref.read(generalSettingsProvider).languageId;
|
||
final labels = TrayMenuLabels.forLanguage(languageId);
|
||
await trayService.setToolTip(labels.formatTooltip(unreadCount));
|
||
|
||
// 标记已初始化(必须在调用 _updateMenu / _onUnreadCountChanged 之前设置,
|
||
// 否则这两个方法的 `if (!_initialized) return;` 守卫会导致菜单和角标不更新)
|
||
_initialized = true;
|
||
|
||
// 4. 设置初始菜单
|
||
await _updateMenu();
|
||
|
||
// 5. 设置初始未读角标
|
||
await trayService.setUnreadBadge(unreadCount);
|
||
|
||
// 6. 监听未读数变化
|
||
_unreadSub = _ref.listenManual(trayUnreadCountProvider, (previous, next) {
|
||
_onUnreadCountChanged(next);
|
||
});
|
||
|
||
// 7. 监听托盘事件
|
||
_eventSub = trayService.events.listen(_onTrayEvent);
|
||
|
||
// 8. 监听语言设置变化,动态更新托盘菜单
|
||
_generalSettingsSub = _ref.listenManual(generalSettingsProvider, (
|
||
previous,
|
||
next,
|
||
) {
|
||
if (previous?.languageId != next.languageId) {
|
||
_updateMenu();
|
||
// 更新 Tooltip
|
||
final unreadCount = _ref.read(trayUnreadCountProvider);
|
||
final labels = TrayMenuLabels.forLanguage(next.languageId);
|
||
trayService.setToolTip(labels.formatTooltip(unreadCount));
|
||
}
|
||
});
|
||
|
||
Log.i('DesktopTrayController 初始化完成');
|
||
} catch (e, st) {
|
||
Log.e('DesktopTrayController 初始化失败', e, st);
|
||
}
|
||
}
|
||
|
||
/// 销毁托盘控制器
|
||
Future<void> dispose() async {
|
||
await _eventSub?.cancel();
|
||
_eventSub = null;
|
||
_unreadSub?.close();
|
||
_unreadSub = null;
|
||
_generalSettingsSub?.close();
|
||
_generalSettingsSub = null;
|
||
|
||
if (_initialized) {
|
||
try {
|
||
await DesktopTrayService.instance.destroy();
|
||
} catch (e) {
|
||
Log.e('DesktopTrayController.dispose 销毁托盘失败: $e');
|
||
}
|
||
_initialized = false;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 主题切换响应(由 app.dart 调用)
|
||
// ============================================================
|
||
|
||
/// 主题切换时更新托盘图标
|
||
Future<void> onThemeChanged(bool isDark) async {
|
||
if (!_initialized) return;
|
||
try {
|
||
await DesktopTrayService.instance.setIcon(isDark: isDark);
|
||
await _updateMenu(); // 菜单中的"深色模式"勾选状态需要更新
|
||
} catch (e) {
|
||
Log.e('DesktopTrayController.onThemeChanged 失败: $e');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 内部方法
|
||
// ============================================================
|
||
|
||
/// 更新托盘菜单
|
||
Future<void> _updateMenu() async {
|
||
if (!_initialized) return;
|
||
try {
|
||
final readLaterState = _ref.read(readLaterProvider);
|
||
final isDark = _ref.read(themeSettingsProvider).isDark;
|
||
final isWorkbench = _ref.read(splitViewProvider).workbenchEnabled;
|
||
final languageId = _ref.read(generalSettingsProvider).languageId;
|
||
final labels = TrayMenuLabels.forLanguage(languageId);
|
||
|
||
final menu = DesktopTrayMenuBuilder.build(
|
||
readLaterEntries: readLaterState.entries,
|
||
isDark: isDark,
|
||
isWorkbenchMode: isWorkbench,
|
||
labels: labels,
|
||
callbacks: TrayMenuCallbacks(
|
||
onNewNote: _onNewNote,
|
||
onNewInspiration: _onNewInspiration,
|
||
onOpenReadLater: _onOpenReadLater,
|
||
onToggleWorkbench: _onToggleWorkbench,
|
||
onToggleDarkMode: _onToggleDarkMode,
|
||
onShowMainWindow: _onShowMainWindow,
|
||
onOpenSettings: _onOpenSettings,
|
||
onExit: _onExit,
|
||
onOpenReadLaterEntry: _onOpenReadLaterEntry,
|
||
),
|
||
);
|
||
|
||
await DesktopTrayService.instance.setMenu(menu);
|
||
} catch (e, st) {
|
||
Log.e('DesktopTrayController._updateMenu 失败: $e', e, st);
|
||
}
|
||
}
|
||
|
||
/// 未读数变化处理
|
||
Future<void> _onUnreadCountChanged(int count) async {
|
||
if (!_initialized) return;
|
||
try {
|
||
await DesktopTrayService.instance.setUnreadBadge(count);
|
||
|
||
final languageId = _ref.read(generalSettingsProvider).languageId;
|
||
final labels = TrayMenuLabels.forLanguage(languageId);
|
||
await DesktopTrayService.instance.setToolTip(labels.formatTooltip(count));
|
||
|
||
// 未读数变化时也更新菜单(最近阅读列表可能变化)
|
||
await _updateMenu();
|
||
} catch (e) {
|
||
Log.e('DesktopTrayController._onUnreadCountChanged 失败: $e');
|
||
}
|
||
}
|
||
|
||
/// 托盘事件处理
|
||
///
|
||
/// macOS 行为说明:
|
||
/// - tray_manager 的 macOS 实现不会自动弹出上下文菜单
|
||
/// (setContextMenu 只存储菜单,mouseDown 只触发回调)
|
||
/// - 需要在 click 事件中手动调用 popUpContextMenu() 弹出菜单
|
||
/// - popUpContextMenu 内部调用 performClick 会触发另一次 mouseDown,
|
||
/// 需要防重入保护,避免无限循环和误判为双击
|
||
void _onTrayEvent(TrayEvent event) {
|
||
// 如果正在弹出菜单,忽略所有事件(防止 performClick 触发的重入)
|
||
if (_isPopUpMenuInProgress) {
|
||
return;
|
||
}
|
||
switch (event.kind) {
|
||
case TrayEventKind.click:
|
||
// macOS/Windows: 手动弹出上下文菜单
|
||
_popUpContextMenu();
|
||
case TrayEventKind.doubleClick:
|
||
// 双击:聚焦主窗口(但 popUpContextMenu 的 performClick 可能误触发双击,
|
||
// 已被 _isPopUpMenuInProgress 保护)
|
||
_focusMainWindow();
|
||
case TrayEventKind.rightClick:
|
||
// 右键:同样手动弹出上下文菜单
|
||
_popUpContextMenu();
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// 弹出托盘上下文菜单
|
||
///
|
||
/// tray_manager 的 macOS 实现不会自动弹出菜单,
|
||
/// 需要调用 DesktopTrayService.popUpContextMenu() 手动弹出。
|
||
/// popUpContextMenu 内部调用 performClick 会触发另一次 mouseDown,
|
||
/// 使用 _isPopUpMenuInProgress 标志防止重入。
|
||
Future<void> _popUpContextMenu() async {
|
||
if (_isPopUpMenuInProgress) return;
|
||
_isPopUpMenuInProgress = true;
|
||
try {
|
||
await DesktopTrayService.instance.popUpContextMenu();
|
||
} catch (e, st) {
|
||
Log.e('DesktopTrayController._popUpContextMenu 失败: $e', e, st);
|
||
} finally {
|
||
// 延迟重置标志,避免 performClick 触发的 mouseDown 事件被误处理
|
||
Future.delayed(const Duration(milliseconds: 300), () {
|
||
_isPopUpMenuInProgress = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 切换窗口可见性(保留供未来"隐藏到托盘"菜单项使用)
|
||
///
|
||
/// 使用 minimize + setSkipTaskbar 组合替代 hide(),
|
||
/// 因为 windowManager.hide() 在 macOS 上与 macos_window_utils 的
|
||
/// NSVisualEffectView + titlebarAppearsTransparent 组合会导致原生崩溃。
|
||
// ignore: unused_element
|
||
Future<void> _toggleWindowVisibility() async {
|
||
try {
|
||
if (_isWindowVisible) {
|
||
// 最小化到托盘:先隐藏 dock 图标,再最小化窗口
|
||
await windowManager.setSkipTaskbar(true);
|
||
await windowManager.minimize();
|
||
_isWindowVisible = false;
|
||
} else {
|
||
// 从托盘恢复:先取消隐藏 dock 图标,再显示并聚焦窗口
|
||
await windowManager.setSkipTaskbar(false);
|
||
await windowManager.show();
|
||
await windowManager.focus();
|
||
_isWindowVisible = true;
|
||
}
|
||
} catch (e, st) {
|
||
Log.e('DesktopTrayController._toggleWindowVisibility 失败: $e', e, st);
|
||
}
|
||
}
|
||
|
||
/// 聚焦主窗口
|
||
Future<void> _focusMainWindow() async {
|
||
try {
|
||
await windowManager.setSkipTaskbar(false);
|
||
await windowManager.show();
|
||
await windowManager.focus();
|
||
_isWindowVisible = true;
|
||
} catch (e, st) {
|
||
Log.e('DesktopTrayController._focusMainWindow 失败: $e', e, st);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 菜单项回调
|
||
// ============================================================
|
||
|
||
void _onNewNote() {
|
||
_navigateTo(AppRoutes.noteEdit);
|
||
}
|
||
|
||
void _onNewInspiration() {
|
||
_navigateTo(AppRoutes.inspiration);
|
||
}
|
||
|
||
void _onOpenReadLater() {
|
||
_navigateTo(AppRoutes.readLater);
|
||
}
|
||
|
||
void _onToggleWorkbench() {
|
||
final current = _ref.read(splitViewProvider).workbenchEnabled;
|
||
_ref.read(splitViewProvider.notifier).setWorkbenchEnabled(!current);
|
||
_updateMenu();
|
||
}
|
||
|
||
void _onToggleDarkMode() {
|
||
final settings = _ref.read(themeSettingsProvider);
|
||
final newMode = settings.isDark ? AppThemeMode.light : AppThemeMode.dark;
|
||
_ref.read(themeSettingsProvider.notifier).setThemeMode(newMode);
|
||
// 主题变化会触发 onThemeChanged 回调,无需在此更新菜单
|
||
}
|
||
|
||
void _onShowMainWindow() {
|
||
_focusMainWindow();
|
||
}
|
||
|
||
void _onOpenSettings() {
|
||
_navigateTo(AppRoutes.generalSettings);
|
||
}
|
||
|
||
void _onExit() async {
|
||
try {
|
||
await dispose();
|
||
await windowManager.destroy();
|
||
} catch (e) {
|
||
Log.e('DesktopTrayController._onExit 失败: $e');
|
||
// 强制退出
|
||
SystemNavigator.pop();
|
||
}
|
||
}
|
||
|
||
void _onOpenReadLaterEntry(String entryId) {
|
||
// 导航到稍后阅读页面,并定位到指定条目
|
||
_navigateTo(AppRoutes.readLater);
|
||
// 后续可扩展为直接打开条目详情
|
||
}
|
||
|
||
/// 导航到指定路由
|
||
void _navigateTo(String route) {
|
||
try {
|
||
// 先显示窗口
|
||
_focusMainWindow();
|
||
|
||
final context = _ref.context;
|
||
if (!context.mounted) {
|
||
Log.w('DesktopTrayController._navigateTo: context 不可用');
|
||
return;
|
||
}
|
||
|
||
if (pu.isOhos) {
|
||
// 鸿蒙端导航(虽然托盘只在桌面端,但保持一致性)
|
||
} else {
|
||
appRouter.push(route);
|
||
}
|
||
} catch (e) {
|
||
Log.e('DesktopTrayController._navigateTo 失败: $e');
|
||
}
|
||
}
|
||
}
|