主要变更: 1. 新增桌面端托盘图标支持深色/浅色主题切换 2. 重构应用锁、动画配置、小组件导航服务职责 3. 修复Riverpod初始化断言、防重复点击、工作台模式残留选中态问题 4. 优化诗词服务、阅读进度、搜索结果空状态体验 5. 完善macOS打包配置与错误静默处理逻辑 6. 新增快速卡片多语言适配与动画退出队列管理
504 lines
18 KiB
Dart
504 lines
18 KiB
Dart
/// ============================================================
|
||
/// 闲言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);
|
||
}
|
||
}
|
||
}
|