feat: 新增工作台模式、系统托盘,修复多平台兼容性问题
1. 新增工作台三栏布局模式,适配宽屏设备 2. 添加跨平台系统托盘支持,新增托盘图标资源 3. 修复工作台模式下导航返回异常问题 4. 统一JSON类型安全解析,替换硬类型转换 5. 增加macOS深度链接支持,统一渠道分发信息 6. 优化部分页面生命周期和状态加载逻辑 7. 移除废弃的nearby_connections依赖
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 权限管理服务
|
||||
/// 创建时间: 2026-04-23
|
||||
/// 更新时间: 2026-06-06
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 统一管理应用权限请求,支持相机/相册/通知/位置/蓝牙/附近设备/麦克风/存储/网络/剪贴板/分享权限 + iOS ATT授权
|
||||
/// 上次更新: 鸿蒙端permission_handler不支持时引导用户去系统设置+MissingPluginException捕获
|
||||
/// 上次更新: 类型安全修复(int vs num): 权限使用计数使用 SafeJson.parseInt
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
@@ -13,6 +13,7 @@ import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
|
||||
import 'package:xianyan/core/utils/safe_json.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../../storage/kv_storage.dart';
|
||||
@@ -296,7 +297,7 @@ class PermissionService {
|
||||
final key = permission.name;
|
||||
final existing = stats[key];
|
||||
if (existing != null) {
|
||||
existing['count'] = (existing['count'] as int) + 1;
|
||||
existing['count'] = SafeJson.parseInt(existing['count']) + 1;
|
||||
existing['lastUsed'] = DateTime.now().toIso8601String();
|
||||
} else {
|
||||
stats[key] = {
|
||||
|
||||
@@ -45,6 +45,12 @@ class BackgroundTaskService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pu.isMacOS) {
|
||||
Log.i('BackgroundTaskService: macOS端 workmanager 不支持,跳过后台任务初始化');
|
||||
_initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Workmanager().initialize(
|
||||
callbackDispatcher,
|
||||
|
||||
@@ -32,7 +32,8 @@ class Catcher2ConfigService {
|
||||
}
|
||||
|
||||
/// 初始化 Catcher2(使用 rootWidget 而非 runAppFunction,避免 Zone mismatch)
|
||||
void init({required Widget rootWidget}) {
|
||||
/// [screenshotsPath] 截图保存路径,非空时启用截图;为空时 Catcher2 会输出 WARNING
|
||||
void init({required Widget rootWidget, String screenshotsPath = ''}) {
|
||||
final enabled = isEnabled;
|
||||
|
||||
final debugConfig = enabled
|
||||
@@ -42,12 +43,17 @@ class Catcher2ConfigService {
|
||||
localizationOptions: [
|
||||
LocalizationOptions.buildDefaultChineseOptions(),
|
||||
],
|
||||
screenshotsPath: screenshotsPath,
|
||||
)
|
||||
: Catcher2Options(SilentReportMode(), []);
|
||||
: Catcher2Options(SilentReportMode(), [], screenshotsPath: screenshotsPath);
|
||||
|
||||
final releaseConfig = enabled
|
||||
? Catcher2Options(SilentReportMode(), [_ConsoleLogHandler()])
|
||||
: Catcher2Options(SilentReportMode(), []);
|
||||
? Catcher2Options(
|
||||
SilentReportMode(),
|
||||
[_ConsoleLogHandler()],
|
||||
screenshotsPath: screenshotsPath,
|
||||
)
|
||||
: Catcher2Options(SilentReportMode(), [], screenshotsPath: screenshotsPath);
|
||||
|
||||
// 使用 rootWidget 而非 runAppFunction,Catcher2 会在内部调用 runApp
|
||||
// 但不会创建新的 Zone,避免 Zone mismatch 警告
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 图片缓存元数据服务
|
||||
/// 创建时间: 2026-05-30
|
||||
/// 更新时间: 2026-06-05
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 基于Hive的图片缓存元数据索引,支持按类型/日期分组、过期清理
|
||||
/// 上次更新: 修复Web平台兼容性,添加kIsWeb守卫保护文件系统操作
|
||||
/// 上次更新: 类型安全修复(int vs num): CacheMetaEntry.fromJson 使用 SafeJson.parseInt
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
@@ -11,6 +11,7 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:xianyan/core/utils/safe_json.dart';
|
||||
|
||||
import '../../storage/kv_storage.dart';
|
||||
import '../../utils/logger.dart';
|
||||
@@ -49,7 +50,7 @@ class CacheMetaEntry {
|
||||
factory CacheMetaEntry.fromJson(Map<String, dynamic> json) {
|
||||
return CacheMetaEntry(
|
||||
path: json['path'] as String,
|
||||
size: json['size'] as int,
|
||||
size: SafeJson.parseInt(json['size']),
|
||||
category: json['category'] as String?,
|
||||
sourceUrl: json['sourceUrl'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
|
||||
118
lib/core/services/desktop/daily_sentence_viewed_service.dart
Normal file
118
lib/core/services/desktop/daily_sentence_viewed_service.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 每日拾句已查看服务
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 本地存储已查看的每日拾句 id 列表,用于计算未读数
|
||||
/// 上次更新: 初始创建,实现 DailySentenceViewedService
|
||||
/// ============================================================
|
||||
|
||||
import '../../storage/kv_storage.dart';
|
||||
import '../../utils/logger.dart';
|
||||
|
||||
/// 每日拾句已查看服务
|
||||
///
|
||||
/// 使用 KvStorage 持久化已查看的每日拾句 id 列表。
|
||||
/// 用于计算每日拾句的未读数(托盘角标)。
|
||||
///
|
||||
/// 存储格式:JSON 编码的 List<String>,key 为 `viewed_daily_sentence_ids`。
|
||||
/// 保留最近 100 条记录,避免无限增长。
|
||||
class DailySentenceViewedService {
|
||||
DailySentenceViewedService._();
|
||||
|
||||
/// 存储 key
|
||||
static const String _storageKey = 'viewed_daily_sentence_ids';
|
||||
|
||||
/// 最大保留记录数
|
||||
static const int _maxRecords = 100;
|
||||
|
||||
/// 内存缓存(避免频繁读取 KvStorage)
|
||||
static Set<String>? _cache;
|
||||
|
||||
/// 获取已查看的每日拾句 id 集合
|
||||
static Set<String> getViewedIds() {
|
||||
if (_cache != null) return _cache!;
|
||||
try {
|
||||
final list = KvStorage.getStringList(_storageKey) ?? <String>[];
|
||||
_cache = list.toSet();
|
||||
return _cache!;
|
||||
} catch (e) {
|
||||
Log.w('DailySentenceViewedService.getViewedIds 失败: $e');
|
||||
_cache = <String>{};
|
||||
return _cache!;
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记每日拾句为已查看
|
||||
///
|
||||
/// [id] 每日拾句 id(格式如 `hitokoto_123` 或 `chengyu_456`)
|
||||
static Future<void> markViewed(String id) async {
|
||||
if (id.isEmpty) return;
|
||||
final viewed = getViewedIds();
|
||||
if (viewed.contains(id)) return; // 已存在,无需重复写入
|
||||
|
||||
viewed.add(id);
|
||||
|
||||
// 限制记录数量:保留最新的 _maxRecords 条
|
||||
if (viewed.length > _maxRecords) {
|
||||
final list = viewed.toList()..sort();
|
||||
final toRemove = list.take(viewed.length - _maxRecords).toSet();
|
||||
viewed.removeAll(toRemove);
|
||||
}
|
||||
|
||||
_cache = viewed;
|
||||
try {
|
||||
await KvStorage.setStringList(_storageKey, viewed.toList());
|
||||
} catch (e) {
|
||||
Log.e('DailySentenceViewedService.markViewed 写入失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量标记已查看
|
||||
static Future<void> markViewedBatch(Iterable<String> ids) async {
|
||||
final viewed = getViewedIds();
|
||||
var changed = false;
|
||||
for (final id in ids) {
|
||||
if (id.isNotEmpty && !viewed.contains(id)) {
|
||||
viewed.add(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
// 限制记录数量
|
||||
if (viewed.length > _maxRecords) {
|
||||
final list = viewed.toList()..sort();
|
||||
final toRemove = list.take(viewed.length - _maxRecords).toSet();
|
||||
viewed.removeAll(toRemove);
|
||||
}
|
||||
|
||||
_cache = viewed;
|
||||
try {
|
||||
await KvStorage.setStringList(_storageKey, viewed.toList());
|
||||
} catch (e) {
|
||||
Log.e('DailySentenceViewedService.markViewedBatch 写入失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查指定 id 是否已查看
|
||||
static bool isViewed(String id) {
|
||||
if (id.isEmpty) return true; // 空 id 视为已查看
|
||||
return getViewedIds().contains(id);
|
||||
}
|
||||
|
||||
/// 清除所有已查看记录(用于调试/重置)
|
||||
static Future<void> clear() async {
|
||||
_cache = <String>{};
|
||||
try {
|
||||
await KvStorage.remove(_storageKey);
|
||||
} catch (e) {
|
||||
Log.e('DailySentenceViewedService.clear 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置内存缓存(用于测试)
|
||||
static void resetCache() {
|
||||
_cache = null;
|
||||
}
|
||||
}
|
||||
63
lib/core/services/desktop/desktop_service_registry.dart
Normal file
63
lib/core/services/desktop/desktop_service_registry.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 桌面端服务注册表
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 按平台注入桌面端服务实现(托盘/窗口特效)
|
||||
/// 上次更新: 初始创建,提供 init() 方法在 main.dart 中调用
|
||||
/// ============================================================
|
||||
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
|
||||
import 'desktop_tray_service.dart';
|
||||
import 'desktop_window_effect_service.dart';
|
||||
import 'implementations/tray_manager_tray_service.dart';
|
||||
import 'implementations/macos_window_effect_service.dart';
|
||||
import 'implementations/windows_acrylic_service.dart';
|
||||
|
||||
/// 桌面端服务注册表
|
||||
///
|
||||
/// 在 `main.dart` 中调用 `DesktopServiceRegistry.init()` 完成服务注入。
|
||||
/// 根据 `pu.isDesktop` / `pu.isMacOS` / `pu.isWindows` 自动选择实现。
|
||||
class DesktopServiceRegistry {
|
||||
DesktopServiceRegistry._();
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
/// 初始化桌面端服务注册表
|
||||
///
|
||||
/// 在 `main.dart` 的 `main()` 函数中,桌面端窗口初始化之前调用。
|
||||
static void init() {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
// 1. 注入托盘服务
|
||||
if (pu.isDesktop) {
|
||||
DesktopTrayService.instance = TrayManagerTrayService();
|
||||
Log.i('DesktopServiceRegistry: 注入 TrayManagerTrayService');
|
||||
} else {
|
||||
// 移动端/鸿蒙端使用默认 StubDesktopTrayService
|
||||
Log.i('DesktopServiceRegistry: 使用 StubDesktopTrayService');
|
||||
}
|
||||
|
||||
// 2. 注入窗口特效服务
|
||||
if (pu.isMacOS) {
|
||||
DesktopWindowEffectService.instance = MacosWindowEffectService();
|
||||
Log.i('DesktopServiceRegistry: 注入 MacosWindowEffectService');
|
||||
} else if (pu.isWindows) {
|
||||
DesktopWindowEffectService.instance = WindowsAcrylicService();
|
||||
Log.i('DesktopServiceRegistry: 注入 WindowsAcrylicService');
|
||||
} else {
|
||||
// Linux/iOS/Android/鸿蒙端使用默认 StubWindowEffectService
|
||||
Log.i('DesktopServiceRegistry: 使用 StubWindowEffectService');
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否已初始化
|
||||
static bool get isInitialized => _initialized;
|
||||
|
||||
/// 重置(用于测试)
|
||||
static void reset() {
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
284
lib/core/services/desktop/desktop_tray_menu_builder.dart
Normal file
284
lib/core/services/desktop/desktop_tray_menu_builder.dart
Normal file
@@ -0,0 +1,284 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 桌面端托盘菜单构建器
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 构建 4 组分隔线分组的托盘右键菜单(主操作/快速访问/模式切换/系统)
|
||||
/// 上次更新: 初始创建,实现 TrayMenuCallbacks + TrayMenuLabels + DesktopTrayMenuBuilder
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../features/home/presentation/providers/readlater/readlater_entry.dart';
|
||||
import '../desktop/desktop_tray_service.dart';
|
||||
|
||||
/// 托盘菜单项回调集合
|
||||
///
|
||||
/// 所有回调均为无参无返回值([VoidCallback]),
|
||||
/// 打开稍后阅读条目除外(接收 entryId)。
|
||||
class TrayMenuCallbacks {
|
||||
/// 新建笔记
|
||||
final VoidCallback onNewNote;
|
||||
|
||||
/// 新建灵感
|
||||
final VoidCallback onNewInspiration;
|
||||
|
||||
/// 打开稍后阅读页面
|
||||
final VoidCallback onOpenReadLater;
|
||||
|
||||
/// 切换工作台模式
|
||||
final VoidCallback onToggleWorkbench;
|
||||
|
||||
/// 切换深色/浅色模式
|
||||
final VoidCallback onToggleDarkMode;
|
||||
|
||||
/// 显示主窗口(从托盘恢复)
|
||||
final VoidCallback onShowMainWindow;
|
||||
|
||||
/// 打开偏好设置
|
||||
final VoidCallback onOpenSettings;
|
||||
|
||||
/// 退出应用
|
||||
final VoidCallback onExit;
|
||||
|
||||
/// 打开指定稍后阅读条目
|
||||
final void Function(String entryId) onOpenReadLaterEntry;
|
||||
|
||||
const TrayMenuCallbacks({
|
||||
required this.onNewNote,
|
||||
required this.onNewInspiration,
|
||||
required this.onOpenReadLater,
|
||||
required this.onToggleWorkbench,
|
||||
required this.onToggleDarkMode,
|
||||
required this.onShowMainWindow,
|
||||
required this.onOpenSettings,
|
||||
required this.onExit,
|
||||
required this.onOpenReadLaterEntry,
|
||||
});
|
||||
}
|
||||
|
||||
/// 托盘菜单标签(支持 i18n)
|
||||
///
|
||||
/// 默认提供中文标签,可通过 [TrayMenuLabels.fromTranslations] 从翻译表生成。
|
||||
/// 托盘菜单由原生渲染,无法直接使用 Flutter i18n,需提前获取文本。
|
||||
class TrayMenuLabels {
|
||||
// 第 1 组:主操作
|
||||
final String newNote;
|
||||
final String newInspiration;
|
||||
final String openReadLater;
|
||||
|
||||
// 第 2 组:快速访问
|
||||
final String recentRead;
|
||||
final String noRecentRead;
|
||||
|
||||
// 第 3 组:模式切换
|
||||
final String workbenchMode;
|
||||
final String darkMode;
|
||||
|
||||
// 第 4 组:系统
|
||||
final String showMainWindow;
|
||||
final String preferences;
|
||||
final String exitApp;
|
||||
|
||||
// Tooltip
|
||||
final String tooltip;
|
||||
final String tooltipWithUnread;
|
||||
|
||||
const TrayMenuLabels({
|
||||
required this.newNote,
|
||||
required this.newInspiration,
|
||||
required this.openReadLater,
|
||||
required this.recentRead,
|
||||
required this.noRecentRead,
|
||||
required this.workbenchMode,
|
||||
required this.darkMode,
|
||||
required this.showMainWindow,
|
||||
required this.preferences,
|
||||
required this.exitApp,
|
||||
required this.tooltip,
|
||||
required this.tooltipWithUnread,
|
||||
});
|
||||
|
||||
/// 默认中文标签
|
||||
static const TrayMenuLabels zhCN = TrayMenuLabels(
|
||||
newNote: '新建笔记',
|
||||
newInspiration: '新建灵感',
|
||||
openReadLater: '打开稍后阅读',
|
||||
recentRead: '最近阅读',
|
||||
noRecentRead: '暂无最近阅读',
|
||||
workbenchMode: '工作台模式',
|
||||
darkMode: '深色模式',
|
||||
showMainWindow: '显示主窗口',
|
||||
preferences: '偏好设置',
|
||||
exitApp: '退出闲言',
|
||||
tooltip: '闲言',
|
||||
tooltipWithUnread: '闲言 — {count} 条未读',
|
||||
);
|
||||
|
||||
/// 英文标签
|
||||
static const TrayMenuLabels enUS = TrayMenuLabels(
|
||||
newNote: 'New Note',
|
||||
newInspiration: 'New Inspiration',
|
||||
openReadLater: 'Open Read Later',
|
||||
recentRead: 'Recent',
|
||||
noRecentRead: 'No recent items',
|
||||
workbenchMode: 'Workbench Mode',
|
||||
darkMode: 'Dark Mode',
|
||||
showMainWindow: 'Show Main Window',
|
||||
preferences: 'Preferences',
|
||||
exitApp: 'Quit Xianyan',
|
||||
tooltip: 'Xianyan',
|
||||
tooltipWithUnread: 'Xianyan — {count} unread',
|
||||
);
|
||||
|
||||
/// 根据语言 ID 获取标签
|
||||
factory TrayMenuLabels.forLanguage(String languageId) {
|
||||
switch (languageId) {
|
||||
case 'en':
|
||||
return enUS;
|
||||
case 'zh_CN':
|
||||
case 'zh_TW':
|
||||
case 'system':
|
||||
default:
|
||||
return zhCN;
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化带未读数的 Tooltip
|
||||
String formatTooltip(int unreadCount) {
|
||||
if (unreadCount <= 0) return tooltip;
|
||||
return tooltipWithUnread.replaceAll('{count}', unreadCount.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// 桌面端托盘菜单构建器
|
||||
///
|
||||
/// 构建 4 组分隔线分组的托盘右键菜单:
|
||||
/// 1. 主操作:新建笔记/灵感、打开稍后阅读
|
||||
/// 2. 快速访问:最近阅读子菜单(前 5 条)
|
||||
/// 3. 模式切换:工作台模式、深色模式
|
||||
/// 4. 系统:显示主窗口、偏好设置、退出
|
||||
class DesktopTrayMenuBuilder {
|
||||
DesktopTrayMenuBuilder._();
|
||||
|
||||
/// 最近阅读子菜单最大条目数
|
||||
static const int maxRecentReadItems = 5;
|
||||
|
||||
/// 标题最大长度(超出截断)
|
||||
static const int maxTitleLength = 24;
|
||||
|
||||
/// 构建托盘菜单
|
||||
///
|
||||
/// [readLaterEntries] 稍后阅读条目列表(取前 5 条作为最近阅读)
|
||||
/// [isDark] 当前是否深色模式
|
||||
/// [isWorkbenchMode] 是否工作台模式
|
||||
/// [labels] 菜单标签
|
||||
/// [callbacks] 菜单项回调
|
||||
static List<TrayMenuItem> build({
|
||||
required List<ReadLaterEntry> readLaterEntries,
|
||||
required bool isDark,
|
||||
required bool isWorkbenchMode,
|
||||
required TrayMenuLabels labels,
|
||||
required TrayMenuCallbacks callbacks,
|
||||
}) {
|
||||
return [
|
||||
// ============================================================
|
||||
// 第 1 组:主操作
|
||||
// ============================================================
|
||||
TrayMenuItem(
|
||||
label: labels.newNote,
|
||||
shortcut: 'Cmd+N',
|
||||
onTap: callbacks.onNewNote,
|
||||
),
|
||||
TrayMenuItem(
|
||||
label: labels.newInspiration,
|
||||
shortcut: 'Cmd+I',
|
||||
onTap: callbacks.onNewInspiration,
|
||||
),
|
||||
TrayMenuItem(
|
||||
label: labels.openReadLater,
|
||||
shortcut: 'Cmd+R',
|
||||
onTap: callbacks.onOpenReadLater,
|
||||
),
|
||||
const TrayMenuItem.separator(),
|
||||
|
||||
// ============================================================
|
||||
// 第 2 组:快速访问(最近阅读子菜单)
|
||||
// ============================================================
|
||||
TrayMenuItem(
|
||||
label: labels.recentRead,
|
||||
submenu: _buildRecentReadSubmenu(
|
||||
entries: readLaterEntries,
|
||||
labels: labels,
|
||||
callbacks: callbacks,
|
||||
),
|
||||
),
|
||||
const TrayMenuItem.separator(),
|
||||
|
||||
// ============================================================
|
||||
// 第 3 组:模式切换
|
||||
// ============================================================
|
||||
TrayMenuItem(
|
||||
label: labels.workbenchMode,
|
||||
checked: isWorkbenchMode,
|
||||
onTap: callbacks.onToggleWorkbench,
|
||||
),
|
||||
TrayMenuItem(
|
||||
label: labels.darkMode,
|
||||
checked: isDark,
|
||||
onTap: callbacks.onToggleDarkMode,
|
||||
),
|
||||
const TrayMenuItem.separator(),
|
||||
|
||||
// ============================================================
|
||||
// 第 4 组:系统
|
||||
// ============================================================
|
||||
TrayMenuItem(
|
||||
label: labels.showMainWindow,
|
||||
onTap: callbacks.onShowMainWindow,
|
||||
),
|
||||
TrayMenuItem(
|
||||
label: labels.preferences,
|
||||
shortcut: 'Cmd+,',
|
||||
onTap: callbacks.onOpenSettings,
|
||||
),
|
||||
TrayMenuItem(
|
||||
label: labels.exitApp,
|
||||
shortcut: 'Cmd+Q',
|
||||
onTap: callbacks.onExit,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 构建最近阅读子菜单
|
||||
static List<TrayMenuItem> _buildRecentReadSubmenu({
|
||||
required List<ReadLaterEntry> entries,
|
||||
required TrayMenuLabels labels,
|
||||
required TrayMenuCallbacks callbacks,
|
||||
}) {
|
||||
if (entries.isEmpty) {
|
||||
return [
|
||||
TrayMenuItem(
|
||||
label: labels.noRecentRead,
|
||||
disabled: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// 取前 maxRecentReadItems 条
|
||||
final recent = entries.take(maxRecentReadItems).toList();
|
||||
|
||||
return recent.map((entry) {
|
||||
return TrayMenuItem(
|
||||
label: _truncateTitle(entry.title, maxTitleLength),
|
||||
onTap: () => callbacks.onOpenReadLaterEntry(entry.id),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 截断标题(超出长度添加省略号)
|
||||
static String _truncateTitle(String title, int maxLen) {
|
||||
if (title.isEmpty) return '(无标题)';
|
||||
if (title.length <= maxLen) return title;
|
||||
return '${title.substring(0, maxLen)}…';
|
||||
}
|
||||
}
|
||||
210
lib/core/services/desktop/desktop_tray_service.dart
Normal file
210
lib/core/services/desktop/desktop_tray_service.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 桌面端系统托盘服务抽象
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 定义跨平台系统托盘服务接口(图标/Tooltip/菜单/未读角标/事件)
|
||||
/// 上次更新: 初始创建,定义 DesktopTrayService 抽象 + TrayMenuItem 模型
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 桌面端系统托盘服务抽象
|
||||
///
|
||||
/// 跨平台系统托盘能力统一接口,支持 macOS / Windows / Linux。
|
||||
/// iOS / Android / 鸿蒙端使用 [StubDesktopTrayService] 返回 no-op 实现。
|
||||
abstract class DesktopTrayService {
|
||||
/// 单例实例(由 [DesktopServiceRegistry] 注入)
|
||||
static DesktopTrayService? _instance;
|
||||
|
||||
/// 获取单例实例
|
||||
static DesktopTrayService get instance {
|
||||
_instance ??= _createInstance();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 设置单例实例(用于测试注入)
|
||||
static set instance(DesktopTrayService service) {
|
||||
_instance = service;
|
||||
}
|
||||
|
||||
/// 创建实例(由子类覆盖)
|
||||
DesktopTrayService createInstance();
|
||||
|
||||
static DesktopTrayService _createInstance() {
|
||||
// 由 desktop_service_registry.dart 在初始化时注入
|
||||
return _instance ??= StubDesktopTrayService();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 生命周期
|
||||
// ============================================================
|
||||
|
||||
/// 初始化托盘(图标、Tooltip、菜单)
|
||||
Future<void> init();
|
||||
|
||||
/// 销毁托盘(应用退出时调用)
|
||||
Future<void> destroy();
|
||||
|
||||
// ============================================================
|
||||
// 托盘属性
|
||||
// ============================================================
|
||||
|
||||
/// 更新托盘图标(根据主题切换浅色/深色图标)
|
||||
Future<void> setIcon({required bool isDark});
|
||||
|
||||
/// 更新 Tooltip(鼠标悬停提示)
|
||||
Future<void> setToolTip(String tip);
|
||||
|
||||
/// 更新未读角标(0 表示隐藏)
|
||||
///
|
||||
/// macOS: 通过 `setTitle` 显示数字角标
|
||||
/// Windows: 程序化叠加角标图层
|
||||
Future<void> setUnreadBadge(int count);
|
||||
|
||||
/// 更新右键菜单
|
||||
Future<void> setMenu(List<TrayMenuItem> items);
|
||||
|
||||
/// 弹出上下文菜单
|
||||
///
|
||||
/// macOS 上 tray_manager 不会自动弹出菜单,需要手动调用此方法。
|
||||
/// Windows/Linux 上通常由系统自动弹出,此方法为 no-op。
|
||||
Future<void> popUpContextMenu();
|
||||
|
||||
// ============================================================
|
||||
// 事件流
|
||||
// ============================================================
|
||||
|
||||
/// 托盘事件流(单击/双击/右键)
|
||||
Stream<TrayEvent> get events;
|
||||
|
||||
// ============================================================
|
||||
// 平台能力
|
||||
// ============================================================
|
||||
|
||||
/// 是否支持托盘(平台判断)
|
||||
bool get isSupported;
|
||||
|
||||
/// 托盘是否已初始化
|
||||
bool get isInitialized;
|
||||
}
|
||||
|
||||
/// 托盘事件类型
|
||||
enum TrayEventKind {
|
||||
/// 单击(左键)
|
||||
click,
|
||||
|
||||
/// 双击(左键)
|
||||
doubleClick,
|
||||
|
||||
/// 右键单击
|
||||
rightClick,
|
||||
}
|
||||
|
||||
/// 托盘事件
|
||||
class TrayEvent {
|
||||
final TrayEventKind kind;
|
||||
|
||||
const TrayEvent({required this.kind});
|
||||
|
||||
@override
|
||||
String toString() => 'TrayEvent(kind: $kind)';
|
||||
}
|
||||
|
||||
/// 托盘菜单项
|
||||
///
|
||||
/// 支持:
|
||||
/// - 普通菜单项(label + onTap + shortcut + checked)
|
||||
/// - 分隔线(isSeparator = true)
|
||||
/// - 子菜单(submenu 非空)
|
||||
class TrayMenuItem {
|
||||
/// 菜单项标签(分隔线时为空)
|
||||
final String label;
|
||||
|
||||
/// 点击回调(分隔线/子菜单父项时为 null)
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 子菜单(非空时为子菜单父项,点击不触发 onTap)
|
||||
final List<TrayMenuItem>? submenu;
|
||||
|
||||
/// 是否为分隔线
|
||||
final bool isSeparator;
|
||||
|
||||
/// 是否勾选(用于切换状态显示,如"静默模式 ✓")
|
||||
final bool checked;
|
||||
|
||||
/// 快捷键提示文本(如 "Cmd+N",仅显示用,不实际绑定)
|
||||
final String? shortcut;
|
||||
|
||||
/// 是否禁用
|
||||
final bool disabled;
|
||||
|
||||
const TrayMenuItem({
|
||||
required this.label,
|
||||
this.onTap,
|
||||
this.submenu,
|
||||
this.isSeparator = false,
|
||||
this.checked = false,
|
||||
this.shortcut,
|
||||
this.disabled = false,
|
||||
});
|
||||
|
||||
/// 创建分隔线
|
||||
const TrayMenuItem.separator()
|
||||
: label = '',
|
||||
onTap = null,
|
||||
submenu = null,
|
||||
isSeparator = true,
|
||||
checked = false,
|
||||
shortcut = null,
|
||||
disabled = false;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TrayMenuItem(label: $label, isSeparator: $isSeparator, checked: $checked, hasSubmenu: ${submenu != null})';
|
||||
}
|
||||
|
||||
/// Stub 实现(iOS / Android / 鸿蒙端)
|
||||
///
|
||||
/// 所有方法 no-op,[isSupported] 返回 false。
|
||||
class StubDesktopTrayService implements DesktopTrayService {
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
DesktopTrayService createInstance() => StubDesktopTrayService();
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> destroy() async {
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setIcon({required bool isDark}) async {}
|
||||
|
||||
@override
|
||||
Future<void> setToolTip(String tip) async {}
|
||||
|
||||
@override
|
||||
Future<void> setUnreadBadge(int count) async {}
|
||||
|
||||
@override
|
||||
Future<void> setMenu(List<TrayMenuItem> items) async {}
|
||||
|
||||
@override
|
||||
Future<void> popUpContextMenu() async {}
|
||||
|
||||
@override
|
||||
Stream<TrayEvent> get events => const Stream<TrayEvent>.empty();
|
||||
|
||||
@override
|
||||
bool get isSupported => false;
|
||||
|
||||
@override
|
||||
bool get isInitialized => _initialized;
|
||||
}
|
||||
77
lib/core/services/desktop/desktop_window_effect_service.dart
Normal file
77
lib/core/services/desktop/desktop_window_effect_service.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 桌面端窗口特效服务抽象
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 定义跨平台窗口特效接口(毛玻璃/亚克力/Mica/标题栏融合)
|
||||
/// 上次更新: 初始创建,定义 DesktopWindowEffectService 抽象
|
||||
/// ============================================================
|
||||
|
||||
/// 桌面端窗口特效服务抽象
|
||||
///
|
||||
/// 跨平台窗口特效能力统一接口:
|
||||
/// - macOS: 侧边栏毛玻璃 + 标题栏融合(基于 macos_window_utils)
|
||||
/// - Windows: Win11 Mica + Win10 Acrylic(基于 flutter_acrylic)
|
||||
/// - Linux/iOS/Android/鸿蒙: Stub 实现,无特效
|
||||
abstract class DesktopWindowEffectService {
|
||||
/// 单例实例
|
||||
static DesktopWindowEffectService? _instance;
|
||||
|
||||
static DesktopWindowEffectService get instance {
|
||||
_instance ??= StubWindowEffectService();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static set instance(DesktopWindowEffectService service) {
|
||||
_instance = service;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 初始化
|
||||
// ============================================================
|
||||
|
||||
/// 初始化窗口特效(在 window_manager.ensureInitialized 之前调用)
|
||||
///
|
||||
/// flutter_acrylic 的 `Window.initialize()` 必须在 window_manager 之前调用。
|
||||
Future<void> initialize();
|
||||
|
||||
// ============================================================
|
||||
// 特效应用
|
||||
// ============================================================
|
||||
|
||||
/// 应用窗口特效
|
||||
///
|
||||
/// [isDark] 当前是否深色主题
|
||||
/// [sidebarBlur] 是否启用侧边栏毛玻璃(仅 macOS 生效)
|
||||
Future<void> applyEffect({
|
||||
required bool isDark,
|
||||
bool sidebarBlur = true,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 平台能力
|
||||
// ============================================================
|
||||
|
||||
/// 是否支持窗口特效
|
||||
bool get isSupported;
|
||||
|
||||
/// 当前特效名称(用于调试/日志)
|
||||
String get effectName;
|
||||
}
|
||||
|
||||
/// Stub 实现(iOS / Android / 鸿蒙 / Linux)
|
||||
class StubWindowEffectService implements DesktopWindowEffectService {
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<void> applyEffect({
|
||||
required bool isDark,
|
||||
bool sidebarBlur = true,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
bool get isSupported => false;
|
||||
|
||||
@override
|
||||
String get effectName => 'none';
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — macOS 窗口特效服务实现
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 基于 macos_window_utils 实现 macOS 窗口特效(标题栏融合/侧边栏毛玻璃)
|
||||
/// 上次更新: 初始创建,实现 DesktopWindowEffectService 接口
|
||||
/// ============================================================
|
||||
|
||||
import 'package:macos_window_utils/macos_window_utils.dart';
|
||||
|
||||
import '../desktop_window_effect_service.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
|
||||
/// macOS 窗口特效服务实现
|
||||
///
|
||||
/// 基于 macos_window_utils 提供:
|
||||
/// - 标题栏融合(titlebarAppearsTransparent + fullSizeContentView)
|
||||
/// - 侧边栏毛玻璃(NSVisualEffectViewMaterial.sidebar)
|
||||
/// - 主题跟随(overrideMacOSBrightness)
|
||||
class MacosWindowEffectService implements DesktopWindowEffectService {
|
||||
MacosWindowEffectService._();
|
||||
|
||||
static final MacosWindowEffectService _instance =
|
||||
MacosWindowEffectService._();
|
||||
|
||||
factory MacosWindowEffectService() => _instance;
|
||||
|
||||
bool _initialized = false;
|
||||
int? _sidebarVisualEffectSubviewId;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
if (!pu.isMacOS) return;
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
await WindowManipulator.initialize();
|
||||
_initialized = true;
|
||||
Log.i('MacosWindowEffectService 初始化完成');
|
||||
} catch (e) {
|
||||
Log.e('MacosWindowEffectService.initialize 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyEffect({
|
||||
required bool isDark,
|
||||
bool sidebarBlur = true,
|
||||
}) async {
|
||||
if (!pu.isMacOS || !_initialized) return;
|
||||
|
||||
try {
|
||||
// 1. 标题栏融合:透明标题栏 + 全尺寸内容视图
|
||||
await WindowManipulator.makeTitlebarTransparent();
|
||||
await WindowManipulator.enableFullSizeContentView();
|
||||
await WindowManipulator.hideTitle();
|
||||
|
||||
// 2. 主题跟随:覆盖 macOS 亮度设置
|
||||
await WindowManipulator.overrideMacOSBrightness(dark: isDark);
|
||||
|
||||
// 3. 侧边栏毛玻璃
|
||||
if (sidebarBlur) {
|
||||
await _applySidebarBlur();
|
||||
}
|
||||
|
||||
// 4. 窗口背景色设为透明(让 NSVisualEffectView 透出)
|
||||
await WindowManipulator.setWindowBackgroundColorToClear();
|
||||
|
||||
Log.i('MacosWindowEffectService 特效已应用 (isDark=$isDark, sidebarBlur=$sidebarBlur)');
|
||||
} catch (e) {
|
||||
Log.e('MacosWindowEffectService.applyEffect 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用侧边栏毛玻璃
|
||||
///
|
||||
/// 使用 NSVisualEffectViewMaterial.sidebar 实现 macOS 原生侧边栏模糊效果。
|
||||
/// 最低支持 macOS 12(项目最低版本 13.0,完全兼容)。
|
||||
Future<void> _applySidebarBlur() async {
|
||||
try {
|
||||
// 设置主视觉效果视图状态为 active
|
||||
await WindowManipulator.setNSVisualEffectViewState(
|
||||
NSVisualEffectViewState.active,
|
||||
);
|
||||
|
||||
// 设置材质为 sidebar(macOS 12+ 支持)
|
||||
await WindowManipulator.setMaterial(NSVisualEffectViewMaterial.sidebar);
|
||||
|
||||
// 如果之前已添加子视图,先移除
|
||||
if (_sidebarVisualEffectSubviewId != null) {
|
||||
await WindowManipulator.removeVisualEffectSubview(
|
||||
_sidebarVisualEffectSubviewId!,
|
||||
);
|
||||
}
|
||||
|
||||
// 添加侧边栏视觉效果子视图
|
||||
_sidebarVisualEffectSubviewId = await WindowManipulator
|
||||
.addVisualEffectSubview(
|
||||
VisualEffectSubviewProperties(
|
||||
material: NSVisualEffectViewMaterial.sidebar,
|
||||
state: NSVisualEffectViewState.active,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
Log.e('MacosWindowEffectService._applySidebarBlur 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isSupported => pu.isMacOS;
|
||||
|
||||
@override
|
||||
String get effectName => 'macos_sidebar_blur';
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — tray_manager 系统托盘服务实现
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 基于 tray_manager 实现跨平台系统托盘(macOS/Win/Linux)
|
||||
/// 上次更新: 初始创建,实现 DesktopTrayService 接口
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:tray_manager/tray_manager.dart';
|
||||
|
||||
import '../desktop_tray_service.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
|
||||
/// 基于 tray_manager 的系统托盘服务实现
|
||||
///
|
||||
/// 支持 macOS / Windows / Linux 三端。
|
||||
/// iOS / Android / 鸿蒙端使用 [StubDesktopTrayService]。
|
||||
class TrayManagerTrayService
|
||||
implements DesktopTrayService, TrayListener {
|
||||
TrayManagerTrayService._();
|
||||
|
||||
static final TrayManagerTrayService _instance = TrayManagerTrayService._();
|
||||
|
||||
factory TrayManagerTrayService() => _instance;
|
||||
|
||||
@override
|
||||
DesktopTrayService createInstance() => _instance;
|
||||
|
||||
bool _initialized = false;
|
||||
final _eventController = StreamController<TrayEvent>.broadcast();
|
||||
|
||||
// 当前菜单项回调映射(id -> callback)
|
||||
final Map<int, VoidCallback> _callbackMap = {};
|
||||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
if (!pu.isDesktop) {
|
||||
Log.w('TrayManagerTrayService.init: 当前平台不支持托盘');
|
||||
return;
|
||||
}
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
trayManager.addListener(this);
|
||||
_initialized = true;
|
||||
Log.i('TrayManagerTrayService 初始化完成');
|
||||
} catch (e) {
|
||||
Log.e('TrayManagerTrayService.init 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> destroy() async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
trayManager.removeListener(this);
|
||||
await trayManager.destroy();
|
||||
_initialized = false;
|
||||
_callbackMap.clear();
|
||||
await _eventController.close();
|
||||
Log.i('TrayManagerTrayService 已销毁');
|
||||
} catch (e) {
|
||||
Log.e('TrayManagerTrayService.destroy 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setIcon({required bool isDark}) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
// macOS 使用 isTemplate 让系统自动反色
|
||||
// Windows/Linux 需要明暗两套图标
|
||||
final iconPath = isDark
|
||||
? 'assets/images/tray_icon_dark.png'
|
||||
: 'assets/images/tray_icon_light.png';
|
||||
|
||||
if (pu.isMacOS) {
|
||||
// macOS: isTemplate=true 时系统自动处理深浅色
|
||||
await trayManager.setIcon(
|
||||
'assets/images/tray_icon_light.png',
|
||||
isTemplate: true,
|
||||
);
|
||||
} else {
|
||||
// Windows/Linux: 明暗两套图标
|
||||
await trayManager.setIcon(iconPath);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('TrayManagerTrayService.setIcon 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setToolTip(String tip) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await trayManager.setToolTip(tip);
|
||||
} catch (e) {
|
||||
Log.e('TrayManagerTrayService.setToolTip 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setUnreadBadge(int count) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
// macOS: setTitle 显示数字角标(标题显示在图标旁)
|
||||
// Windows/Linux: tray_manager 不原生支持角标,仅更新 Tooltip
|
||||
if (pu.isMacOS) {
|
||||
await trayManager.setTitle(count > 0 ? count.toString() : '');
|
||||
}
|
||||
// 统一更新 Tooltip 包含未读数
|
||||
final tip = count > 0 ? '闲言 — $count 条未读' : '闲言';
|
||||
await trayManager.setToolTip(tip);
|
||||
} catch (e) {
|
||||
Log.e('TrayManagerTrayService.setUnreadBadge 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setMenu(List<TrayMenuItem> items) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
_callbackMap.clear();
|
||||
final menuItems = <MenuItem>[];
|
||||
for (final item in items) {
|
||||
menuItems.add(_convertMenuItem(item));
|
||||
}
|
||||
final menu = Menu(items: menuItems);
|
||||
await trayManager.setContextMenu(menu);
|
||||
} catch (e, st) {
|
||||
Log.e('TrayManagerTrayService.setMenu 失败: $e', e, st);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> popUpContextMenu() async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
await trayManager.popUpContextMenu();
|
||||
} catch (e, st) {
|
||||
Log.e('TrayManagerTrayService.popUpContextMenu 失败: $e', e, st);
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 TrayMenuItem 转换为 menu_base 的 MenuItem
|
||||
MenuItem _convertMenuItem(TrayMenuItem item) {
|
||||
if (item.isSeparator) {
|
||||
return MenuItem.separator();
|
||||
}
|
||||
|
||||
final menuItem = MenuItem(
|
||||
label: item.label,
|
||||
disabled: item.disabled,
|
||||
onClick: (menuItem) {
|
||||
item.onTap?.call();
|
||||
},
|
||||
);
|
||||
|
||||
if (item.checked) {
|
||||
menuItem.type = 'checkbox';
|
||||
menuItem.checked = true;
|
||||
}
|
||||
|
||||
if (item.submenu != null && item.submenu!.isNotEmpty) {
|
||||
menuItem.type = 'submenu';
|
||||
menuItem.submenu = Menu(
|
||||
items: item.submenu!.map(_convertMenuItem).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<TrayEvent> get events => _eventController.stream;
|
||||
|
||||
@override
|
||||
bool get isSupported => pu.isDesktop;
|
||||
|
||||
@override
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
// ============================================================
|
||||
// TrayListener 回调
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
void onTrayIconMouseDown() {
|
||||
_eventController.add(const TrayEvent(kind: TrayEventKind.click));
|
||||
}
|
||||
|
||||
@override
|
||||
void onTrayIconMouseUp() {}
|
||||
|
||||
@override
|
||||
void onTrayIconRightMouseDown() {
|
||||
_eventController.add(const TrayEvent(kind: TrayEventKind.rightClick));
|
||||
}
|
||||
|
||||
@override
|
||||
void onTrayIconRightMouseUp() {}
|
||||
|
||||
@override
|
||||
void onTrayMenuItemClick(MenuItem menuItem) {
|
||||
// 回调已在 _convertMenuItem 的 onClick 中处理
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — Windows 窗口特效服务实现
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 基于 flutter_acrylic 实现 Windows 窗口特效(Win11 Mica Alt/Mica/Win10 Acrylic)
|
||||
/// 上次更新: 新增 Mica Alt 特效支持(Win11 build >= 22621),自动降级 Mica Alt → Mica → Acrylic
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_acrylic/flutter_acrylic.dart';
|
||||
|
||||
import '../desktop_window_effect_service.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
import 'package:xianyan/core/utils/logger.dart';
|
||||
|
||||
/// Windows 窗口特效服务实现
|
||||
///
|
||||
/// 基于 flutter_acrylic 提供:
|
||||
/// - Win11 22621+: Mica Alt(云母变体,对壁纸色调更敏感,标题栏区域同样应用特效)
|
||||
/// - Win11 22000+: Mica(云母,跟随系统主题)
|
||||
/// - Win10 1809+: Acrylic(亚克力半透明)
|
||||
/// - Win10 早期: 降级为纯色背景
|
||||
///
|
||||
/// 实现说明:flutter_acrylic 1.1.4 的 WindowEffect 枚举未直接提供 micaAlt,
|
||||
/// 此处使用 [WindowEffect.tabbed](对应 DWM DWMSBT_TABBEDWINDOW)作为 Mica Alt
|
||||
/// 的等价实现——两者均为"比 Mica 更透明、对桌面壁纸色调更敏感"的云母变体,
|
||||
/// 视觉表现与 Win11 22621+ 的 Mica Alt 一致。
|
||||
class WindowsAcrylicService implements DesktopWindowEffectService {
|
||||
WindowsAcrylicService._();
|
||||
|
||||
static final WindowsAcrylicService _instance = WindowsAcrylicService._();
|
||||
|
||||
factory WindowsAcrylicService() => _instance;
|
||||
|
||||
bool _initialized = false;
|
||||
bool? _isWin11OrLater;
|
||||
bool? _isMicaAltSupportedCache;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
if (!pu.isWindows) return;
|
||||
if (_initialized) return;
|
||||
|
||||
try {
|
||||
await Window.initialize();
|
||||
_isWin11OrLater = await _detectWindows11OrLater();
|
||||
_isMicaAltSupportedCache = await _isMicaAltSupported();
|
||||
_initialized = true;
|
||||
Log.i('WindowsAcrylicService 初始化完成 '
|
||||
'(isWin11=$_isWin11OrLater, micaAlt=$_isMicaAltSupportedCache)');
|
||||
} catch (e) {
|
||||
Log.e('WindowsAcrylicService.initialize 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyEffect({
|
||||
required bool isDark,
|
||||
bool sidebarBlur = true,
|
||||
}) async {
|
||||
if (!pu.isWindows || !_initialized) return;
|
||||
|
||||
try {
|
||||
// 优先级:Mica Alt → Mica → Acrylic
|
||||
// Mica Alt(Win11 22621+):对壁纸色调更敏感,标题栏区域同样应用特效
|
||||
// 使用 WindowEffect.tabbed 作为 Mica Alt 的等价实现(见类说明)
|
||||
if (_isMicaAltSupportedCache == true) {
|
||||
await Window.setEffect(
|
||||
effect: WindowEffect.tabbed,
|
||||
color: isDark ? const Color(0xFF1C1C1C) : const Color(0xFFF3F3F3),
|
||||
dark: isDark,
|
||||
);
|
||||
Log.i('WindowsAcrylicService 应用 Mica Alt 特效 (isDark=$isDark)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isWin11OrLater == true) {
|
||||
// Win11: Mica 背景(跟随系统主题)
|
||||
await Window.setEffect(
|
||||
effect: WindowEffect.mica,
|
||||
dark: isDark,
|
||||
);
|
||||
Log.i('WindowsAcrylicService 应用 Mica 特效 (isDark=$isDark)');
|
||||
} else {
|
||||
// Win10: Acrylic 半透明
|
||||
await Window.setEffect(
|
||||
effect: WindowEffect.acrylic,
|
||||
dark: isDark,
|
||||
color: isDark
|
||||
? const Color(0xCC1F1F1F) // 深色半透明
|
||||
: const Color(0xCCF3F3F3), // 浅色半透明
|
||||
);
|
||||
Log.i('WindowsAcrylicService 应用 Acrylic 特效 (isDark=$isDark)');
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('WindowsAcrylicService.applyEffect 失败: $e');
|
||||
// 降级:禁用特效
|
||||
try {
|
||||
await Window.setEffect(
|
||||
effect: WindowEffect.disabled,
|
||||
dark: isDark,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测当前系统是否支持 Mica Alt(Win11 build >= 22621)
|
||||
///
|
||||
/// 使用 device_info_plus 读取精确的 Windows build 号。
|
||||
/// Mica Alt(此处以 WindowEffect.tabbed 等价实现)需要 Win11 22621 及以上。
|
||||
/// 结果会被缓存,避免重复读取设备信息。
|
||||
Future<bool> _isMicaAltSupported() async {
|
||||
if (_isMicaAltSupportedCache != null) return _isMicaAltSupportedCache!;
|
||||
try {
|
||||
final info = await DeviceInfoPlugin().windowsInfo;
|
||||
// device_info_plus 13.x 中 buildNumber 为 int 类型,无需解析
|
||||
final supported = info.buildNumber >= 22621;
|
||||
_isMicaAltSupportedCache = supported;
|
||||
return supported;
|
||||
} catch (e) {
|
||||
Log.w('WindowsAcrylicService._isMicaAltSupported 检测失败: $e');
|
||||
_isMicaAltSupportedCache = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测是否为 Windows 11 或更高版本
|
||||
///
|
||||
/// Windows 11 Build >= 22000
|
||||
Future<bool> _detectWindows11OrLater() async {
|
||||
try {
|
||||
final version = Platform.operatingSystemVersion;
|
||||
// 解析格式:"10.0.22000.1234" 或 "Windows 10 Pro 10.0.22000.1234"
|
||||
final match = RegExp(r'(\d+)\.(\d+)\.(\d+)').firstMatch(version);
|
||||
if (match == null) return false;
|
||||
|
||||
final major = int.parse(match.group(1)!);
|
||||
final minor = int.parse(match.group(2)!);
|
||||
final build = int.parse(match.group(3)!);
|
||||
|
||||
// Win11: major=10, minor=0, build>=22000
|
||||
// 注意:Win11 和 Win10 都报告 major=10,通过 build 号区分
|
||||
if (major == 10 && minor == 0 && build >= 22000) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
Log.w('WindowsAcrylicService._detectWindows11OrLater 解析失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isSupported => pu.isWindows;
|
||||
|
||||
@override
|
||||
String get effectName {
|
||||
if (_isMicaAltSupportedCache == true) return 'windows_mica_alt';
|
||||
return _isWin11OrLater == true ? 'windows_mica' : 'windows_acrylic';
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — macOS平台统一服务
|
||||
/// 创建时间: 2026-06-02
|
||||
/// 更新时间: 2026-06-02
|
||||
/// 作用: 集中管理所有macOS原生MethodChannel交互(主题同步/窗口管理/工具栏样式)
|
||||
/// 上次更新: 整合MacosTitleBarService,新增窗口管理能力
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 集中管理所有macOS原生MethodChannel交互(主题同步/窗口管理/Touch Bar/共享/Dock徽章/菜单栏金句/Spotlight)
|
||||
/// 上次更新: 新增 Touch Bar、NSSharingService、NSDockTile、NSStatusItem、CoreSpotlight 五项原生能力
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -13,7 +13,11 @@ import 'package:xianyan/core/utils/logger.dart';
|
||||
class MacosPlatformService {
|
||||
MacosPlatformService._();
|
||||
|
||||
static const _channel = MethodChannel('com.xianyan.macos');
|
||||
/// 窗口级通道(MainFlutterWindow 注册)
|
||||
static const _channel = MethodChannel('apps.xy.xianyan/macos');
|
||||
|
||||
/// 应用级通道(AppDelegate 注册)
|
||||
static const _appChannel = MethodChannel('apps.xy.xianyan/macos.app');
|
||||
|
||||
// ============================================================
|
||||
// 主题同步(原 MacosTitleBarService)
|
||||
@@ -106,6 +110,99 @@ class MacosPlatformService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Touch Bar 支持
|
||||
// ============================================================
|
||||
|
||||
/// 设置 Touch Bar 按钮项
|
||||
///
|
||||
/// [items] 按钮列表,每项包含 {label: "加粗", action: "bold"}
|
||||
/// 点击按钮时通过 touchBarAction 事件回调
|
||||
static Future<void> setTouchBarItems(List<Map<String, String>> items) async {
|
||||
if (!pu.isMacOS) return;
|
||||
try {
|
||||
await _channel.invokeMethod('setTouchBarItems', {'items': items});
|
||||
} catch (e) {
|
||||
Log.w('MacosPlatformService.setTouchBarItems失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NSSharingService 共享
|
||||
// ============================================================
|
||||
|
||||
/// 显示系统共享面板,支持 text/url/image 三种内容
|
||||
static Future<void> showShareSheet({
|
||||
String? text,
|
||||
String? url,
|
||||
Uint8List? imageBytes,
|
||||
}) async {
|
||||
if (!pu.isMacOS) return;
|
||||
try {
|
||||
final args = <String, dynamic>{};
|
||||
if (text != null) args['text'] = text;
|
||||
if (url != null) args['url'] = url;
|
||||
if (imageBytes != null) args['imageBytes'] = imageBytes;
|
||||
await _channel.invokeMethod('showShareSheet', args);
|
||||
} catch (e) {
|
||||
Log.w('MacosPlatformService.showShareSheet失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dock 徽章(NSDockTile)
|
||||
// ============================================================
|
||||
|
||||
/// 设置 Dock 图标徽章数字,count <= 0 时清除
|
||||
static Future<void> setDockBadge(int count) async {
|
||||
if (!pu.isMacOS) return;
|
||||
try {
|
||||
await _appChannel.invokeMethod('setDockBadge', {'count': count});
|
||||
} catch (e) {
|
||||
Log.w('MacosPlatformService.setDockBadge失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 菜单栏金句(NSStatusItem)
|
||||
// ============================================================
|
||||
|
||||
/// 更新菜单栏金句,超过 30 字符截断显示,点击复制完整内容
|
||||
static Future<void> updateStatusBarSentence(String sentence) async {
|
||||
if (!pu.isMacOS) return;
|
||||
try {
|
||||
await _appChannel.invokeMethod('updateStatusBarSentence', {'sentence': sentence});
|
||||
} catch (e) {
|
||||
Log.w('MacosPlatformService.updateStatusBarSentence失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Spotlight 索引(CoreSpotlight)
|
||||
// ============================================================
|
||||
|
||||
/// 索引条目到 Spotlight 搜索
|
||||
///
|
||||
/// [items] 条目列表,每项包含 {id, title, content, type}
|
||||
static Future<void> indexSpotlightItems(List<Map<String, dynamic>> items) async {
|
||||
if (!pu.isMacOS) return;
|
||||
try {
|
||||
await _appChannel.invokeMethod('indexSpotlightItems', {'items': items});
|
||||
} catch (e) {
|
||||
Log.w('MacosPlatformService.indexSpotlightItems失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除所有 Spotlight 索引
|
||||
static Future<void> clearSpotlightIndex() async {
|
||||
if (!pu.isMacOS) return;
|
||||
try {
|
||||
await _appChannel.invokeMethod('clearSpotlightIndex');
|
||||
} catch (e) {
|
||||
Log.w('MacosPlatformService.clearSpotlightIndex失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 内部工具
|
||||
// ============================================================
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — Windows平台统一服务
|
||||
/// 创建时间: 2026-06-16
|
||||
/// 更新时间: 2026-06-16
|
||||
/// 作用: 集中管理所有Windows原生MethodChannel交互(标题栏主题同步)
|
||||
/// 上次更新: 初始创建,支持标题栏深色模式切换
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 集中管理所有Windows原生MethodChannel交互
|
||||
/// 上次更新: 补齐 6 个 MethodChannel 方法(setWindowTitle/setFullscreen/isFullscreen/setMinSize/performHapticFeedback/getSystemAppearance)
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -13,7 +13,7 @@ import 'package:xianyan/core/utils/logger.dart';
|
||||
class WindowsPlatformService {
|
||||
WindowsPlatformService._();
|
||||
|
||||
static const _channel = MethodChannel('com.xianyan.windows');
|
||||
static const _channel = MethodChannel('apps.xy.xianyan/windows');
|
||||
|
||||
// ============================================================
|
||||
// 主题同步
|
||||
@@ -33,6 +33,60 @@ class WindowsPlatformService {
|
||||
_invoke('setDarkMode', {'isDark': isDark});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 窗口管理(6 个新增方法)
|
||||
// ============================================================
|
||||
|
||||
/// 设置窗口标题
|
||||
static void setWindowTitle(String title) {
|
||||
if (!pu.isWindows) return;
|
||||
_invoke('setWindowTitle', {'title': title});
|
||||
}
|
||||
|
||||
/// 进入/退出全屏模式
|
||||
static void setFullscreen(bool fullscreen) {
|
||||
if (!pu.isWindows) return;
|
||||
_invoke('setFullscreen', {'fullscreen': fullscreen});
|
||||
}
|
||||
|
||||
/// 查询当前是否处于全屏模式
|
||||
static Future<bool> isFullscreen() async {
|
||||
if (!pu.isWindows) return false;
|
||||
try {
|
||||
final result = await _channel.invokeMethod<bool>('isFullscreen');
|
||||
return result ?? false;
|
||||
} catch (e) {
|
||||
Log.w('WindowsPlatformService.isFullscreen失败: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置窗口最小尺寸(逻辑像素)
|
||||
static void setMinSize(int width, int height) {
|
||||
if (!pu.isWindows) return;
|
||||
_invoke('setMinSize', {'width': width, 'height': height});
|
||||
}
|
||||
|
||||
/// 执行触觉反馈
|
||||
///
|
||||
/// [type] 0=light, 1=medium, 2=heavy, 3=selection
|
||||
static void performHapticFeedback(int type) {
|
||||
if (!pu.isWindows) return;
|
||||
_invoke('performHapticFeedback', {'type': type});
|
||||
}
|
||||
|
||||
/// 获取系统外观模式("light" 或 "dark")
|
||||
static Future<String> getSystemAppearance() async {
|
||||
if (!pu.isWindows) return 'light';
|
||||
try {
|
||||
final result = await _channel.invokeMethod<String>('getSystemAppearance');
|
||||
return result ?? 'light';
|
||||
} catch (e) {
|
||||
Log.w('WindowsPlatformService.getSystemAppearance失败: $e');
|
||||
return 'light';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 内部工具
|
||||
// ============================================================
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 深度链接服务
|
||||
/// 创建时间: 2026-04-28
|
||||
/// 更新时间: 2026-05-27
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 使用 app_links 统一处理深度链接,支持冷启动和热恢复
|
||||
/// 上次更新: 重构为使用 AppRouter.resolveDeepLinkUri 统一路径映射,消除重复逻辑
|
||||
/// 上次更新: 新增 note/{id} 和 sentence/{id} 预解析,支持笔记编辑和句子详情跳转
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
@@ -63,10 +63,45 @@ class DeepLinkService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 预解析:处理需要特殊路由映射的 xianyan:// scheme
|
||||
///
|
||||
/// - `xianyan://note/{id}` → `/notes/edit?id={id}`(笔记编辑页用 query param)
|
||||
/// - `xianyan://note` → `/notes`(笔记列表页)
|
||||
/// - `xianyan://sentence/{id}` → `/home`(句子详情需 HomeSentence 对象,
|
||||
/// 暂导航到首页,后续可扩展为通过 ID 加载句子后弹出详情 Sheet)
|
||||
///
|
||||
/// 返回 null 表示无需预解析,交给 AppRouter.resolveDeepLinkUri 统一处理
|
||||
static String? _preResolve(Uri uri) {
|
||||
if (uri.scheme != 'xianyan') return null;
|
||||
|
||||
final host = uri.host;
|
||||
final segments = uri.pathSegments;
|
||||
|
||||
switch (host) {
|
||||
case 'note':
|
||||
// 笔记编辑页 /notes/edit 接收 query param id
|
||||
final noteId = segments.isNotEmpty ? segments.first : null;
|
||||
if (noteId != null && noteId.isNotEmpty) {
|
||||
Log.i('🔗 [DeepLink] note/$noteId → ${AppRoutes.noteEdit}?id=$noteId');
|
||||
return '${AppRoutes.noteEdit}?id=$noteId';
|
||||
}
|
||||
return AppRoutes.noteList;
|
||||
case 'sentence':
|
||||
// 句子详情 Sheet 需要 HomeSentence 对象,暂导航到首页
|
||||
final sentenceId = segments.isNotEmpty ? segments.first : '';
|
||||
Log.i('🔗 [DeepLink] sentence/$sentenceId → 首页(句子详情需加载后展示)');
|
||||
return AppRoutes.home;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理单个深度链接 URI
|
||||
/// 使用 AppRouter.resolveDeepLinkUri 统一路径映射
|
||||
/// 先通过 _preResolve 处理需要特殊路由的 scheme(note/{id}, sentence/{id}),
|
||||
/// 其余委托给 AppRouter.resolveDeepLinkUri 统一路径映射
|
||||
static void _handleLink(Uri uri) {
|
||||
final resolved = AppRouter.resolveDeepLinkUri(uri);
|
||||
// 1. 预解析:处理需要特殊路由的 scheme
|
||||
final resolved = _preResolve(uri) ?? AppRouter.resolveDeepLinkUri(uri);
|
||||
if (resolved == null) {
|
||||
Log.w('🔗 [DeepLink] 无法解析: $uri');
|
||||
return;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 统一通知中心
|
||||
/// 创建时间: 2026-05-22
|
||||
/// 更新时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 合并 NotificationScheduler + DailyNotifyService,统一管理所有本地通知调度
|
||||
/// 上次更新: 新增推送计数器(pushCount/clickCount),每次调度/点击时+1
|
||||
/// 上次更新: 类型安全修复(int vs num): 节气日期 year/month/day 使用 SafeJson.parseInt
|
||||
/// ============================================================
|
||||
|
||||
import 'package:xianyan/core/utils/safe_json.dart';
|
||||
import 'local_notification_service.dart';
|
||||
import '../../storage/kv_storage.dart';
|
||||
import '../../utils/logger.dart';
|
||||
@@ -306,9 +307,9 @@ class NotificationCenter {
|
||||
if (nextTerm == null) return;
|
||||
|
||||
final scheduledTime = DateTime(
|
||||
nextTerm['year'] as int,
|
||||
nextTerm['month'] as int,
|
||||
nextTerm['day'] as int,
|
||||
SafeJson.parseInt(nextTerm['year']),
|
||||
SafeJson.parseInt(nextTerm['month']),
|
||||
SafeJson.parseInt(nextTerm['day']),
|
||||
8,
|
||||
);
|
||||
|
||||
@@ -360,9 +361,9 @@ class NotificationCenter {
|
||||
final terms = _solarTerms2026;
|
||||
for (final term in terms) {
|
||||
final date = DateTime(
|
||||
term['year'] as int,
|
||||
term['month'] as int,
|
||||
term['day'] as int,
|
||||
SafeJson.parseInt(term['year']),
|
||||
SafeJson.parseInt(term['month']),
|
||||
SafeJson.parseInt(term['day']),
|
||||
);
|
||||
if (date.isAfter(now)) return term;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user