/// ============================================================ /// 闲言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? _eventSub; ProviderSubscription? _unreadSub; ProviderSubscription? _generalSettingsSub; bool _initialized = false; bool _isWindowVisible = true; /// 防止 popUpContextMenu 触发的 performClick 导致重入 bool _isPopUpMenuInProgress = false; /// 初始化托盘控制器 Future 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 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 onThemeChanged(bool isDark) async { if (!_initialized) return; try { await DesktopTrayService.instance.setIcon(isDark: isDark); await _updateMenu(); // 菜单中的"深色模式"勾选状态需要更新 } catch (e) { Log.e('DesktopTrayController.onThemeChanged 失败: $e'); } } // ============================================================ // 内部方法 // ============================================================ /// 更新托盘菜单 Future _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 _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 _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 _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 _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'); } } }