Files
xianyan/lib/features/desktop/desktop_tray_controller.dart
Developer f7520b17b2 win提交
2026-06-22 03:50:59 +08:00

372 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言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');
}
}
}