Files
xianyan/lib/features/desktop/desktop_tray_controller.dart
Developer 6119918185 release: bump version to 6.6.25+2606241
主要变更:
1. 新增桌面端托盘图标支持深色/浅色主题切换
2. 重构应用锁、动画配置、小组件导航服务职责
3. 修复Riverpod初始化断言、防重复点击、工作台模式残留选中态问题
4. 优化诗词服务、阅读进度、搜索结果空状态体验
5. 完善macOS打包配置与错误静默处理逻辑
6. 新增快速卡片多语言适配与动画退出队列管理
2026-06-24 04:26:50 +08:00

504 lines
18 KiB
Dart
Raw Permalink 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-23
/// 作用: 整合托盘服务 + 菜单构建器 + 未读数 Provider管理托盘生命周期
/// 上次更新: 修复7.1托盘导航独占窗口直接读workbenchEnabled绕过MediaQuery+7.2切换工作台/导航栏位置概率不生效添加focusMainWindow+延迟updateMenu
/// ============================================================
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:window_manager/window_manager.dart';
import '../../core/providers/split_view_provider.dart';
import '../../core/router/app_router.dart';
import '../../core/router/route_registry.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 '../../core/layout/workbench/right_panel_navigator.dart';
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 themeSettings = _ref.read(themeSettingsProvider);
await trayService.setIcon(
isDark: themeSettings.isDark,
isAmoled: themeSettings.isAmoled,
);
// 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 调用)
// ============================================================
/// 主题切换时更新托盘图标
///
/// [isDark] 是否为深色模式dark 或 amoled
/// [isAmoled] 是否为纯黑模式amoled
/// macOS 在纯黑模式下需手动使用白色 PNG不依赖 isTemplate 自动反色。
Future<void> onThemeChanged(bool isDark, {bool isAmoled = false}) async {
if (!_initialized) return;
try {
await DesktopTrayService.instance.setIcon(
isDark: isDark,
isAmoled: isAmoled,
);
await _updateMenu(); // 菜单中的"深色模式"勾选状态需要更新
} catch (e) {
Log.e('DesktopTrayController.onThemeChanged 失败: $e');
}
}
// ============================================================
// 内部方法
// ============================================================
/// 更新托盘菜单
Future<void> _updateMenu() async {
if (!_initialized) return;
try {
final readLaterState = _ref.read(readLaterProvider);
final themeSettings = _ref.read(themeSettingsProvider);
final splitState = _ref.read(splitViewProvider);
final languageId = _ref.read(generalSettingsProvider).languageId;
final labels = TrayMenuLabels.forLanguage(languageId);
// 主题模式索引:直接使用 AppThemeMode.sortOrder
// light=0, dark=1, amoled=2, system=3
final themeModeIndex = themeSettings.themeMode.sortOrder;
final menu = DesktopTrayMenuBuilder.build(
readLaterEntries: readLaterState.entries,
themeModeIndex: themeModeIndex,
isWorkbenchMode: splitState.workbenchEnabled,
navBarPositionIndex: splitState.navBarPosition.index,
splitRatio: splitState.splitRatio,
focusReadingEnabled: splitState.focusReadingMode,
labels: labels,
callbacks: TrayMenuCallbacks(
onNewNote: _onNewNote,
onNewInspiration: _onNewInspiration,
onOpenReadLater: _onOpenReadLater,
onToggleWorkbench: _onToggleWorkbench,
onSetNavBarPosition: _onSetNavBarPosition,
onSetSplitRatio: _onSetSplitRatio,
onToggleFocusReading: _onToggleFocusReading,
onSetThemeMode: _onSetThemeMode,
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);
Log.i('托盘菜单: 工作台模式已${!current ? '开启' : '关闭'}');
// 修复7.2:聚焦主窗口,确保用户能看到变化
_focusMainWindow();
// 修复7.2:延迟更新菜单,避免在原生菜单回调中同步调用 setContextMenu 冲突
Future.microtask(() => _updateMenu());
}
/// 设置导航栏位置
///
/// [positionIndex] 0=左 1=右 2=上 3=下
/// 同步到通用设置中的工作台模式导航栏位置
void _onSetNavBarPosition(int positionIndex) {
if (positionIndex < 0 || positionIndex >= NavBarPosition.values.length) {
Log.w('DesktopTrayController._onSetNavBarPosition: 无效索引 $positionIndex');
return;
}
final position = NavBarPosition.values[positionIndex];
_ref.read(splitViewProvider.notifier).setNavBarPosition(position);
Log.i('托盘菜单: 导航栏位置已设置为 ${position.label}');
// 修复7.2:聚焦主窗口,确保用户能看到变化
_focusMainWindow();
// 修复7.2:延迟更新菜单,避免在原生菜单回调中同步调用 setContextMenu 冲突
Future.microtask(() => _updateMenu());
}
/// 设置分屏比例
///
/// [ratio] 分屏比例0.3/0.4/0.5/0.6
/// 调用 splitViewProvider.notifier.setSplitRatio 并刷新菜单
void _onSetSplitRatio(double ratio) {
_ref.read(splitViewProvider.notifier).setSplitRatio(ratio);
_updateMenu();
Log.i('托盘菜单: 分屏比例已设置为 $ratio');
}
/// 切换专注阅读模式
///
/// [enabled] true=开启专注阅读false=关闭
/// 调用 splitViewProvider.notifier.setFocusReadingMode 并刷新菜单
void _onToggleFocusReading(bool enabled) {
_ref.read(splitViewProvider.notifier).setFocusReadingMode(enabled);
_updateMenu();
Log.i('托盘菜单: 专注阅读模式已${enabled ? '开启' : '关闭'}');
}
/// 设置主题模式
///
/// [themeModeIndex] 0=白天 1=深色 2=纯黑 3=跟随系统
void _onSetThemeMode(int themeModeIndex) {
final AppThemeMode newMode;
switch (themeModeIndex) {
case 0:
newMode = AppThemeMode.light;
case 1:
newMode = AppThemeMode.dark;
case 2:
newMode = AppThemeMode.amoled;
case 3:
newMode = AppThemeMode.system;
default:
Log.w('DesktopTrayController._onSetThemeMode: 无效索引 $themeModeIndex');
return;
}
_ref.read(themeSettingsProvider.notifier).setThemeMode(newMode);
// 主题变化会触发 onThemeChanged 回调,无需在此更新菜单
Log.i('托盘菜单: 主题模式已设置为 ${newMode.name}');
}
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);
// 后续可扩展为直接打开条目详情
}
/// 导航到指定路由
///
/// 修复7.1:托盘点击打开页面独占窗口导致工作台布局失效
/// 根因:原实现使用 [BuildContext.appPush],内部依赖
/// `MediaQuery.sizeOf(this).width >= kCompactBreakpoint` 判断工作台模式。
/// 当窗口从隐藏/最小化恢复时MediaQuery 尺寸可能为 0 或未就绪,
/// 导致判断失败,页面被 push 到根 GoRouter Navigator 独占整个窗口。
///
/// 修复方案:直接读取 [splitViewProvider.workbenchEnabled] 判断工作台模式,
/// 不依赖 MediaQuery。工作台模式开启时直接 push 到右栏嵌套 Navigator
/// 完全绕过 MediaQuery 尺寸判断。
void _navigateTo(String route) {
try {
// 先显示窗口
_focusMainWindow();
final splitState = _ref.read(splitViewProvider);
final currentTab = splitState.currentTab;
Log.i(
'DesktopTrayController._navigateTo: route=$route, '
'currentTab=$currentTab, workbench=${splitState.workbenchEnabled}',
);
if (pu.isOhos) {
// 鸿蒙端导航(虽然托盘只在桌面端,但保持一致性)
appRouter.push(route);
return;
}
// 修复7.1:直接检查工作台开关,不依赖 MediaQuery 尺寸判断
// 全屏路由始终走根 Navigator
if (splitState.workbenchEnabled && !isFullScreenRoute(route)) {
// 工作台模式:直接 push 到右栏嵌套 Navigator
final entry = RightPanelEntry(
route: route,
title: inferRouteTitle(route),
);
_ref
.read(rightPanelStackProvider.notifier)
.push(currentTab, entry);
Log.i('DesktopTrayController._navigateTo: 已推送到右栏 (tab=$currentTab)');
return;
}
// 非工作台模式或全屏路由:走 GoRouter push
// 优先使用当前活动 Tab 的 navigatorKey.currentContext
final tabNavigatorKey = tabNavigatorKeyFor(currentTab);
final BuildContext? tabContext = tabNavigatorKey?.currentContext;
final BuildContext context;
if (tabContext != null && tabContext.mounted) {
context = tabContext;
} else {
final fallbackContext = _ref.context;
if (!fallbackContext.mounted) {
Log.w(
'DesktopTrayController._navigateTo: context 不可用 (tab=$currentTab)',
);
return;
}
context = fallbackContext;
Log.w(
'DesktopTrayController._navigateTo: tabNavigatorKey context 不可用,回退到 _ref.context',
);
}
context.push(route);
} catch (e, st) {
Log.e('DesktopTrayController._navigateTo 失败: $e', e, st);
}
}
}