feat: 新增工作台模式、系统托盘,修复多平台兼容性问题
1. 新增工作台三栏布局模式,适配宽屏设备 2. 添加跨平台系统托盘支持,新增托盘图标资源 3. 修复工作台模式下导航返回异常问题 4. 统一JSON类型安全解析,替换硬类型转换 5. 增加macOS深度链接支持,统一渠道分发信息 6. 优化部分页面生命周期和状态加载逻辑 7. 移除废弃的nearby_connections依赖
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 自适应分屏组件
|
||||
/// 创建时间: 2026-05-29
|
||||
/// 更新时间: 2026-05-29
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 宽屏时左右分屏布局,支持可拖拽分割线、手势隔离、动画过渡
|
||||
/// 上次更新: 完全移除AnimationController,使用flutter_animate target声明式控制动画方向
|
||||
/// 上次更新: 断点常量对齐设计规则 768/1024/1280,支持工作台三栏模式
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
@@ -14,8 +14,30 @@ import '../providers/split_view_provider.dart';
|
||||
import 'panel_cache.dart';
|
||||
import 'split_divider.dart';
|
||||
|
||||
/// 分屏断点:宽度 >= 900px 进入分屏模式
|
||||
const double kSplitViewBreakpoint = 900.0;
|
||||
// ============================================================
|
||||
// 响应式断点常量 — 对齐 .trae/rules/design-rules.md
|
||||
// ============================================================
|
||||
|
||||
/// 紧凑模式断点:宽度 >= 768px 从单栏切换到双栏
|
||||
const double kCompactBreakpoint = 768.0;
|
||||
|
||||
/// 中等模式断点:宽度 >= 1024px 从双栏切换到三栏
|
||||
const double kMediumBreakpoint = 1024.0;
|
||||
|
||||
/// 展开模式断点:宽度 >= 1280px 三栏完整显示(中栏更宽)
|
||||
const double kExpandedBreakpoint = 1280.0;
|
||||
|
||||
/// @deprecated 旧断点,保留向后兼容,实际指向 kCompactBreakpoint
|
||||
const double kSplitViewBreakpoint = kCompactBreakpoint;
|
||||
|
||||
/// 判断当前宽度是否处于工作台模式(双栏或三栏)
|
||||
bool isWorkbenchWidth(double width) => width >= kCompactBreakpoint;
|
||||
|
||||
/// 判断当前宽度是否处于三栏模式
|
||||
bool isTripleColumnWidth(double width) => width >= kMediumBreakpoint;
|
||||
|
||||
/// 判断当前宽度是否处于三栏完整模式(中栏更宽)
|
||||
bool isExpandedTripleColumnWidth(double width) => width >= kExpandedBreakpoint;
|
||||
|
||||
class AdaptiveSplitView extends ConsumerStatefulWidget {
|
||||
const AdaptiveSplitView({
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 面板书签功能
|
||||
/// 创建时间: 2026-05-29
|
||||
/// 更新时间: 2026-05-29
|
||||
/// 作用: 收藏常用面板组合,一键切换
|
||||
/// 上次更新: 初始创建
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../storage/kv_storage.dart';
|
||||
|
||||
/// 面板书签数据模型
|
||||
class PanelBookmark {
|
||||
const PanelBookmark({
|
||||
required this.name,
|
||||
required this.tabIndex,
|
||||
required this.panelId,
|
||||
this.panelArgs,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final int tabIndex;
|
||||
final String panelId;
|
||||
final Map<String, dynamic>? panelArgs;
|
||||
|
||||
/// 序列化为JSON
|
||||
Map<String, dynamic> toJson() => {
|
||||
'name': name,
|
||||
'tabIndex': tabIndex,
|
||||
'panelId': panelId,
|
||||
'panelArgs': panelArgs,
|
||||
};
|
||||
|
||||
/// 从JSON反序列化
|
||||
factory PanelBookmark.fromJson(Map<String, dynamic> json) => PanelBookmark(
|
||||
name: json['name'] as String,
|
||||
tabIndex: json['tabIndex'] as int,
|
||||
panelId: json['panelId'] as String,
|
||||
panelArgs: json['panelArgs'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
/// 面板书签状态管理Notifier
|
||||
class PanelBookmarkNotifier extends Notifier<List<PanelBookmark>> {
|
||||
static const _key = 'panel_bookmarks';
|
||||
|
||||
@override
|
||||
List<PanelBookmark> build() {
|
||||
final raw = KvStorage.getString(_key);
|
||||
if (raw == null) return [];
|
||||
try {
|
||||
final list = jsonDecode(raw) as List;
|
||||
return list
|
||||
.map((e) => PanelBookmark.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加书签
|
||||
void addBookmark(PanelBookmark bookmark) {
|
||||
state = [...state, bookmark];
|
||||
_save();
|
||||
}
|
||||
|
||||
/// 移除书签
|
||||
void removeBookmark(int index) {
|
||||
if (index < 0 || index >= state.length) return;
|
||||
state = [...state]..removeAt(index);
|
||||
_save();
|
||||
}
|
||||
|
||||
/// 持久化到KvStorage
|
||||
void _save() {
|
||||
KvStorage.setString(
|
||||
_key,
|
||||
jsonEncode(state.map((e) => e.toJson()).toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 面板书签Provider
|
||||
final panelBookmarkProvider =
|
||||
NotifierProvider<PanelBookmarkNotifier, List<PanelBookmark>>(
|
||||
PanelBookmarkNotifier.new,
|
||||
);
|
||||
@@ -1,67 +0,0 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 右侧面板注册表
|
||||
/// 创建时间: 2026-05-29
|
||||
/// 更新时间: 2026-05-29
|
||||
/// 作用: 管理右侧面板的注册与构建,各Tab页面通过注册表提供面板内容
|
||||
/// 上次更新: 移除dartx依赖,使用Dart内置安全集合访问
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef RightPanelBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
Map<String, dynamic>? args,
|
||||
);
|
||||
|
||||
class RightPanelRegistry {
|
||||
RightPanelRegistry._();
|
||||
|
||||
static final Map<String, RightPanelBuilder> _builders = {};
|
||||
|
||||
static void register(String panelId, RightPanelBuilder builder) {
|
||||
_builders[panelId] = builder;
|
||||
}
|
||||
|
||||
static void registerAll(Map<String, RightPanelBuilder> entries) {
|
||||
_builders.addAll(entries);
|
||||
}
|
||||
|
||||
static Widget build(String panelId, BuildContext context, {Map<String, dynamic>? args}) {
|
||||
final builder = _builders[panelId];
|
||||
if (builder == null) {
|
||||
return _buildPlaceholder(context, panelId);
|
||||
}
|
||||
return builder(context, args);
|
||||
}
|
||||
|
||||
static bool hasPanel(String panelId) => _builders.containsKey(panelId);
|
||||
|
||||
static List<String> get registeredIds => _builders.keys.toList();
|
||||
|
||||
static String? getPanelIdAtIndex(int index) {
|
||||
final ids = registeredIds;
|
||||
return (index >= 0 && index < ids.length) ? ids[index] : null;
|
||||
}
|
||||
|
||||
static RightPanelBuilder? getBuilderAtIndex(int index) {
|
||||
final id = getPanelIdAtIndex(index);
|
||||
if (id == null) return null;
|
||||
return _builders[id];
|
||||
}
|
||||
|
||||
static Widget _buildPlaceholder(BuildContext context, String panelId) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('🚧', style: TextStyle(fontSize: 48)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'面板 "$panelId" 尚未注册',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 分屏导航 Mixin
|
||||
/// 创建时间: 2026-06-10
|
||||
/// 更新时间: 2026-06-10
|
||||
/// 作用: 提供宽屏面板/窄屏路由双模式导航逻辑
|
||||
/// 上次更新: 从 profile_page.dart 抽取为 Mixin
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 统一走 context.appPush,由 app_nav_extension 自动处理工作台模式
|
||||
/// 上次更新: 移除旧面板ID机制,统一使用嵌套Navigator右栏(显示完整原始页面)
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/split_view_provider.dart';
|
||||
import 'adaptive_split_view.dart';
|
||||
import '../router/app_nav_extension.dart';
|
||||
|
||||
/// 分屏导航 Mixin — 宽屏面板 / 窄屏路由双模式导航
|
||||
/// 分屏导航 Mixin — 统一导航入口
|
||||
///
|
||||
/// 工作台模式判断已下沉到 `context.appPush`,无需在调用方判断
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// class _MyPageState extends ConsumerState<MyPage>
|
||||
@@ -22,32 +20,16 @@ import '../router/app_nav_extension.dart';
|
||||
/// ```
|
||||
mixin SplitViewNavigationMixin<T extends ConsumerStatefulWidget>
|
||||
on ConsumerState<T> {
|
||||
/// 判断当前是否为宽屏分屏模式
|
||||
bool isWidescreen() {
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final splitState = ref.read(splitViewProvider);
|
||||
return screenWidth >= kSplitViewBreakpoint && splitState.splitViewEnabled;
|
||||
}
|
||||
|
||||
/// 在宽屏模式下打开右侧面板
|
||||
void openSettingsPanel(String panelId, {Map<String, dynamic>? args}) {
|
||||
if (isWidescreen()) {
|
||||
ref
|
||||
.read(splitViewProvider.notifier)
|
||||
.setProfileRightPanel(panelId, args: args);
|
||||
}
|
||||
}
|
||||
|
||||
/// 智能导航:宽屏打开面板,窄屏跳转路由
|
||||
/// 智能导航:统一走 context.appPush
|
||||
///
|
||||
/// 工作台模式(宽屏+开启)下自动 push 到右栏嵌套 Navigator
|
||||
/// 窄屏或全屏路由走 GoRouter rootNavigator push
|
||||
void navigateOrPanel(
|
||||
String route,
|
||||
String panelId, {
|
||||
Map<String, dynamic>? args,
|
||||
String? title,
|
||||
}) {
|
||||
if (isWidescreen()) {
|
||||
openSettingsPanel(panelId, args: args);
|
||||
} else {
|
||||
context.appPush(route);
|
||||
}
|
||||
context.appPush(route, extra: args, title: title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 三栏布局组件
|
||||
/// 创建时间: 2026-05-29
|
||||
/// 更新时间: 2026-05-29
|
||||
/// 作用: 超宽屏(>=1400px)三栏布局:左侧列表+中间详情+右侧辅助面板
|
||||
/// 上次更新: 初始创建
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'split_divider.dart';
|
||||
|
||||
class TripleColumnView extends ConsumerStatefulWidget {
|
||||
const TripleColumnView({
|
||||
required this.leftPanel,
|
||||
required this.centerPanel,
|
||||
required this.rightPanel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget leftPanel;
|
||||
final Widget centerPanel;
|
||||
final Widget rightPanel;
|
||||
|
||||
@override
|
||||
ConsumerState<TripleColumnView> createState() => _TripleColumnViewState();
|
||||
}
|
||||
|
||||
class _TripleColumnViewState extends ConsumerState<TripleColumnView> {
|
||||
double _leftRatio = 0.25;
|
||||
double _centerRatio = 0.40;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final leftWidth = width * _leftRatio;
|
||||
final centerWidth = width * _centerRatio;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(width: leftWidth, child: widget.leftPanel),
|
||||
SplitDivider(
|
||||
currentPosition: _leftRatio,
|
||||
onPositionChanged: (newRatio) {
|
||||
setState(() {
|
||||
final delta = newRatio - _leftRatio;
|
||||
_leftRatio = newRatio.clamp(0.15, 0.35);
|
||||
_centerRatio = (_centerRatio - delta).clamp(0.25, 0.55);
|
||||
});
|
||||
},
|
||||
minPosition: 0.15,
|
||||
maxPosition: 0.35,
|
||||
),
|
||||
SizedBox(width: centerWidth, child: widget.centerPanel),
|
||||
SplitDivider(
|
||||
currentPosition: _leftRatio + _centerRatio,
|
||||
onPositionChanged: (newRatio) {
|
||||
setState(() {
|
||||
final combinedRatio = newRatio;
|
||||
_centerRatio =
|
||||
(combinedRatio - _leftRatio).clamp(0.25, 0.55);
|
||||
});
|
||||
},
|
||||
minPosition: _leftRatio + 0.25,
|
||||
maxPosition: _leftRatio + 0.55,
|
||||
),
|
||||
Expanded(child: widget.rightPanel),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
240
lib/core/layout/workbench/right_panel_navigator.dart
Normal file
240
lib/core/layout/workbench/right_panel_navigator.dart
Normal file
@@ -0,0 +1,240 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 右栏页面栈管理器
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 工作台模式下右栏的嵌套 Navigator,显示完整原始页面(非阉割版)
|
||||
/// 上次更新: pop 方法改为 canPop 检查,与 canPop 判断保持一致,避免根级页面被意外弹空
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:xianyan/core/router/route_def.dart';
|
||||
import 'package:xianyan/core/router/route_registry.dart';
|
||||
|
||||
/// 右栏页面栈条目
|
||||
@immutable
|
||||
class RightPanelEntry {
|
||||
const RightPanelEntry({
|
||||
required this.route,
|
||||
required this.title,
|
||||
this.extra,
|
||||
this.builder,
|
||||
});
|
||||
|
||||
/// 路由路径
|
||||
final String route;
|
||||
|
||||
/// 页面标题(显示在右栏顶栏)
|
||||
final String title;
|
||||
|
||||
/// 传递给页面的参数
|
||||
final Object? extra;
|
||||
|
||||
/// 页面构建器(优先使用,若为空则从 RouteDef 查找)
|
||||
final WidgetBuilder? builder;
|
||||
}
|
||||
|
||||
/// 右栏页面栈状态
|
||||
@immutable
|
||||
class RightPanelStackState {
|
||||
const RightPanelStackState({
|
||||
this.entries = const [],
|
||||
this.currentTab = 0,
|
||||
});
|
||||
|
||||
/// 页面栈(栈底在前,栈顶在后)
|
||||
final List<RightPanelEntry> entries;
|
||||
|
||||
/// 当前所属 Tab
|
||||
final int currentTab;
|
||||
|
||||
/// 栈深
|
||||
int get depth => entries.length;
|
||||
|
||||
/// 栈顶条目
|
||||
RightPanelEntry? get top => entries.isEmpty ? null : entries.last;
|
||||
|
||||
/// 是否可以返回
|
||||
bool get canPop => entries.length > 1;
|
||||
|
||||
RightPanelStackState copyWith({
|
||||
List<RightPanelEntry>? entries,
|
||||
int? currentTab,
|
||||
}) {
|
||||
return RightPanelStackState(
|
||||
entries: entries ?? this.entries,
|
||||
currentTab: currentTab ?? this.currentTab,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 右栏页面栈管理器(每 Tab 独立栈)
|
||||
class RightPanelStackNotifier
|
||||
extends Notifier<Map<int, RightPanelStackState>> {
|
||||
@override
|
||||
Map<int, RightPanelStackState> build() {
|
||||
// 3 个 Tab 各自独立的栈状态
|
||||
return {
|
||||
0: const RightPanelStackState(currentTab: 0),
|
||||
1: const RightPanelStackState(currentTab: 1),
|
||||
2: const RightPanelStackState(currentTab: 2),
|
||||
};
|
||||
}
|
||||
|
||||
/// 获取指定 Tab 的栈状态
|
||||
RightPanelStackState getStack(int tabIndex) {
|
||||
return state[tabIndex] ?? RightPanelStackState(currentTab: tabIndex);
|
||||
}
|
||||
|
||||
/// 获取当前 Tab 的栈状态
|
||||
RightPanelStackState getCurrentStack(int currentTab) {
|
||||
return getStack(currentTab);
|
||||
}
|
||||
|
||||
/// push 一个新页面到指定 Tab 的右栏栈
|
||||
void push(int tabIndex, RightPanelEntry entry) {
|
||||
final currentStack = getStack(tabIndex);
|
||||
final newEntries = [...currentStack.entries, entry];
|
||||
state = {
|
||||
...state,
|
||||
tabIndex: currentStack.copyWith(entries: newEntries),
|
||||
};
|
||||
}
|
||||
|
||||
/// 从指定 Tab 的右栏栈 pop 一个页面
|
||||
/// 与 canPop 判断保持一致:栈深 <= 1 时不执行 pop,避免根级页面被意外弹空
|
||||
void pop(int tabIndex) {
|
||||
final currentStack = getStack(tabIndex);
|
||||
if (!currentStack.canPop) return;
|
||||
final newEntries = [...currentStack.entries]..removeLast();
|
||||
state = {
|
||||
...state,
|
||||
tabIndex: currentStack.copyWith(entries: newEntries),
|
||||
};
|
||||
}
|
||||
|
||||
/// 清空指定 Tab 的右栏栈
|
||||
void clear(int tabIndex) {
|
||||
final currentStack = getStack(tabIndex);
|
||||
state = {
|
||||
...state,
|
||||
tabIndex: currentStack.copyWith(entries: []),
|
||||
};
|
||||
}
|
||||
|
||||
/// 重置指定 Tab 的栈为单个页面
|
||||
void resetTo(int tabIndex, RightPanelEntry entry) {
|
||||
final currentStack = getStack(tabIndex);
|
||||
state = {
|
||||
...state,
|
||||
tabIndex: currentStack.copyWith(entries: [entry]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 右栏页面栈 Provider
|
||||
final rightPanelStackProvider =
|
||||
NotifierProvider<RightPanelStackNotifier, Map<int, RightPanelStackState>>(
|
||||
RightPanelStackNotifier.new,
|
||||
);
|
||||
|
||||
/// 获取指定 Tab 当前右栏栈顶路由的 Provider(供一级页面判断选中状态)
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// final activeRoute = ref.watch(activeRightPanelRouteProvider(currentTab));
|
||||
/// // 如果 activeRoute == '/favorites' 则高亮收藏按钮
|
||||
/// ```
|
||||
final activeRightPanelRouteProvider = Provider.family<String?, int>((ref, tab) {
|
||||
final stackMap = ref.watch(rightPanelStackProvider);
|
||||
final stack = stackMap[tab];
|
||||
return stack?.top?.route;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 右栏页面构建辅助
|
||||
// ============================================================
|
||||
|
||||
/// 根据路由路径构建完整原始页面
|
||||
Widget? buildPageFromRoute(String route, {Object? extra}) {
|
||||
final def = findRouteDef(route);
|
||||
if (def == null) return null;
|
||||
|
||||
// 解析查询参数和路径参数
|
||||
final parsed = _parseRouteParams(route, def.path);
|
||||
|
||||
// 优先使用 builder
|
||||
if (def.builder != null) {
|
||||
final ctx = RouteContext(
|
||||
path: route,
|
||||
pathParams: parsed.pathParams,
|
||||
queryParams: parsed.queryParams,
|
||||
extra: extra,
|
||||
);
|
||||
return def.builder!(ctx);
|
||||
}
|
||||
|
||||
// 其次使用 page
|
||||
if (def.page != null) {
|
||||
return def.page!();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 解析路由路径中的查询参数和路径参数
|
||||
_ParsedRoute _parseRouteParams(String route, String pattern) {
|
||||
// 剥离查询参数
|
||||
final queryIndex = route.indexOf('?');
|
||||
final cleanPath = queryIndex >= 0 ? route.substring(0, queryIndex) : route;
|
||||
final queryString = queryIndex >= 0 ? route.substring(queryIndex + 1) : '';
|
||||
|
||||
// 解析查询参数
|
||||
final queryParams = <String, String>{};
|
||||
if (queryString.isNotEmpty) {
|
||||
for (final pair in queryString.split('&')) {
|
||||
final eq = pair.indexOf('=');
|
||||
if (eq >= 0) {
|
||||
queryParams[Uri.decodeQueryComponent(pair.substring(0, eq))] =
|
||||
Uri.decodeQueryComponent(pair.substring(eq + 1));
|
||||
} else {
|
||||
queryParams[Uri.decodeQueryComponent(pair)] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析路径参数(如 /chat_flow/:id → /chat_flow/abc)
|
||||
final pathParams = <String, String>{};
|
||||
final patternSegs = pattern.split('/');
|
||||
final pathSegs = cleanPath.split('/');
|
||||
if (patternSegs.length == pathSegs.length) {
|
||||
for (var i = 0; i < patternSegs.length; i++) {
|
||||
final p = patternSegs[i];
|
||||
if (p.startsWith(':')) {
|
||||
pathParams[p.substring(1)] = pathSegs[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _ParsedRoute(pathParams: pathParams, queryParams: queryParams);
|
||||
}
|
||||
|
||||
class _ParsedRoute {
|
||||
const _ParsedRoute({required this.pathParams, required this.queryParams});
|
||||
final Map<String, String> pathParams;
|
||||
final Map<String, String> queryParams;
|
||||
}
|
||||
|
||||
/// 从路由路径推断页面标题
|
||||
String inferRouteTitle(String route) {
|
||||
final def = findRouteDef(route);
|
||||
if (def != null && def.name.isNotEmpty) {
|
||||
// 将 name 转为可读标题
|
||||
final parts = def.name.split('-');
|
||||
return parts
|
||||
.map((p) => p.isEmpty ? '' : '${p[0].toUpperCase()}${p.substring(1)}')
|
||||
.join(' ');
|
||||
}
|
||||
return route;
|
||||
}
|
||||
419
lib/core/layout/workbench/workbench_layout.dart
Normal file
419
lib/core/layout/workbench/workbench_layout.dart
Normal file
@@ -0,0 +1,419 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — PC 工作台布局组件
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 微信PC式三栏工作台布局(导航栏+中栏列表+右栏详情)
|
||||
/// 上次更新: 右栏返回双按钮(pop+clear);拖拽clamp合并取min;双栏注释修正;构建失败移除无效条目改用canPop判断
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../providers/split_view_provider.dart';
|
||||
import '../../storage/kv_storage.dart';
|
||||
import '../adaptive_split_view.dart';
|
||||
import 'right_panel_navigator.dart';
|
||||
|
||||
/// 工作台布局组件
|
||||
///
|
||||
/// 布局结构:
|
||||
/// ┌──────┬────────────┬──────────────────────────┐
|
||||
/// │ 导航 │ 内容列表 │ 内容详情 │
|
||||
/// │ 72px │ 380px可调 │ 自适应 │
|
||||
/// └──────┴────────────┴──────────────────────────┘
|
||||
class WorkbenchLayout extends ConsumerStatefulWidget {
|
||||
const WorkbenchLayout({
|
||||
required this.navigationShell,
|
||||
required this.navBar,
|
||||
required this.defaultRightPanel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// StatefulNavigationShell,提供 3 个 Tab 分支
|
||||
final Widget navigationShell;
|
||||
|
||||
/// 导航栏组件(AdaptiveNavBar)
|
||||
final Widget navBar;
|
||||
|
||||
/// 右栏默认内容(未选中二级页面时显示,通常为 OverviewDashboard)
|
||||
final Widget defaultRightPanel;
|
||||
|
||||
@override
|
||||
ConsumerState<WorkbenchLayout> createState() => _WorkbenchLayoutState();
|
||||
}
|
||||
|
||||
class _WorkbenchLayoutState extends ConsumerState<WorkbenchLayout> {
|
||||
double? _middleWidth;
|
||||
bool _isDragging = false;
|
||||
|
||||
static const double _minMiddleWidth = 320.0;
|
||||
static const double _maxMiddleWidth = 600.0;
|
||||
static const double _defaultMiddleWidth = 380.0;
|
||||
static const double _dividerWidth = 6.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_restoreMiddleWidth();
|
||||
}
|
||||
|
||||
/// 从 KvStorage 恢复中栏宽度
|
||||
///
|
||||
/// 延迟到首帧后执行,避免在 initState 中触发 setState。
|
||||
/// 读取失败或未设置时使用默认值 _defaultMiddleWidth。
|
||||
void _restoreMiddleWidth() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final saved = KvStorage.getDouble(
|
||||
StorageKeys.nsSettingsWorkbenchMiddleWidth,
|
||||
);
|
||||
if (saved != null && saved >= _minMiddleWidth && saved <= _maxMiddleWidth) {
|
||||
setState(() => _middleWidth = saved);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 拖拽分割条
|
||||
// ============================================================
|
||||
|
||||
void _onDragStart(DragStartDetails details) {
|
||||
setState(() => _isDragging = true);
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails details, double screenWidth) {
|
||||
final navWidth = _navBarWidth();
|
||||
final newWidth = (_middleWidth ?? _defaultMiddleWidth) + details.delta.dx;
|
||||
// 综合屏幕可用宽度与硬编码上限,取较小值作为有效最大宽度
|
||||
final screenMax = screenWidth - navWidth - 400 - _dividerWidth;
|
||||
final effectiveMax = screenMax < _maxMiddleWidth ? screenMax : _maxMiddleWidth;
|
||||
// 保护下限:极端小屏时 screenMax 可能 < _minMiddleWidth,避免 clamp 抛 ArgumentError
|
||||
final safeMax = effectiveMax < _minMiddleWidth ? _minMiddleWidth : effectiveMax;
|
||||
final finalWidth = newWidth.clamp(_minMiddleWidth, safeMax);
|
||||
setState(() => _middleWidth = finalWidth);
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails details) {
|
||||
setState(() => _isDragging = false);
|
||||
// 持久化中栏宽度
|
||||
_persistMiddleWidth();
|
||||
}
|
||||
|
||||
/// 持久化中栏宽度到 KvStorage
|
||||
///
|
||||
/// 拖拽结束时调用,异步写入,失败时静默忽略(下次启动使用默认值)。
|
||||
void _persistMiddleWidth() {
|
||||
final width = _middleWidth ?? _defaultMiddleWidth;
|
||||
KvStorage.setDouble(
|
||||
StorageKeys.nsSettingsWorkbenchMiddleWidth,
|
||||
width,
|
||||
);
|
||||
}
|
||||
|
||||
double _navBarWidth() {
|
||||
final splitState = ref.read(splitViewProvider);
|
||||
final isVertical = splitState.navBarPosition == NavBarPosition.left ||
|
||||
splitState.navBarPosition == NavBarPosition.right;
|
||||
if (!isVertical) return 0.0;
|
||||
// 折叠态固定 48px,展开态使用持久化的宽度
|
||||
return splitState.navBarCollapsed ? 48.0 : splitState.navBarWidth;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 构建
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final splitState = ref.watch(splitViewProvider);
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final navBarPosition = splitState.navBarPosition;
|
||||
final currentTab = splitState.currentTab;
|
||||
|
||||
// 判断布局模式
|
||||
final isTripleColumn = isTripleColumnWidth(screenWidth);
|
||||
final isWorkbench = isWorkbenchWidth(screenWidth) &&
|
||||
splitState.workbenchEnabled;
|
||||
|
||||
if (!isWorkbench) {
|
||||
// 非工作台模式:返回 null,由 AppShell 走窄屏布局
|
||||
return widget.navigationShell;
|
||||
}
|
||||
|
||||
// 构建三栏内容
|
||||
final Widget middlePanel = _buildMiddlePanel();
|
||||
final Widget rightPanel = _buildRightPanel(currentTab);
|
||||
final Widget divider = _buildDivider(screenWidth);
|
||||
|
||||
// 构建内容区(中栏 + 分割条 + 右栏)
|
||||
Widget contentArea;
|
||||
if (isTripleColumn) {
|
||||
contentArea = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _middleWidth ?? _defaultMiddleWidth,
|
||||
child: middlePanel,
|
||||
),
|
||||
divider,
|
||||
Expanded(child: rightPanel),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// 双栏模式:中栏紧凑显示(宽度*0.75)+ 分割条 + 右栏
|
||||
contentArea = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: (_middleWidth ?? _defaultMiddleWidth) * 0.75,
|
||||
child: middlePanel,
|
||||
),
|
||||
divider,
|
||||
Expanded(child: rightPanel),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 根据导航栏位置组合布局
|
||||
return _assembleLayout(
|
||||
navBarPosition: navBarPosition,
|
||||
navBar: widget.navBar,
|
||||
contentArea: contentArea,
|
||||
);
|
||||
}
|
||||
|
||||
/// 组装导航栏 + 内容区
|
||||
Widget _assembleLayout({
|
||||
required NavBarPosition navBarPosition,
|
||||
required Widget navBar,
|
||||
required Widget contentArea,
|
||||
}) {
|
||||
final isVertical = navBarPosition == NavBarPosition.left ||
|
||||
navBarPosition == NavBarPosition.right;
|
||||
final isStart = navBarPosition == NavBarPosition.left ||
|
||||
navBarPosition == NavBarPosition.top;
|
||||
|
||||
if (isVertical) {
|
||||
// 垂直导航栏(左/右):动态宽度(支持折叠态 48px / 展开态可调)
|
||||
final navWidth = _navBarWidth();
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (isStart)
|
||||
SizedBox(width: navWidth, child: navBar),
|
||||
Expanded(child: contentArea),
|
||||
if (!isStart)
|
||||
SizedBox(width: navWidth, child: navBar),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 水平导航栏(顶/底):固定高度 52px
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
if (isStart)
|
||||
SizedBox(height: 52, child: navBar),
|
||||
Expanded(child: contentArea),
|
||||
if (!isStart)
|
||||
SizedBox(height: 52, child: navBar),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建中栏(一级页面列表)
|
||||
Widget _buildMiddlePanel() {
|
||||
// 中栏显示当前 Tab 的一级页面(HomePage/DiscoverPage/ProfilePage)
|
||||
// 通过 navigationShell 提供
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLowest,
|
||||
child: widget.navigationShell,
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建右栏(二级/三级页面详情)
|
||||
Widget _buildRightPanel(int currentTab) {
|
||||
// 关键:watch 状态本身(Map<int, RightPanelStackState>),而非 notifier
|
||||
// 这样状态变化时才会触发重建
|
||||
final stackMap = ref.watch(rightPanelStackProvider);
|
||||
final stackState = stackMap[currentTab] ??
|
||||
RightPanelStackState(currentTab: currentTab);
|
||||
final entries = stackState.entries;
|
||||
|
||||
// 栈为空:显示默认面板(概览仪表盘)
|
||||
if (entries.isEmpty) {
|
||||
return widget.defaultRightPanel;
|
||||
}
|
||||
|
||||
// 栈非空:显示栈顶页面(完整原始页面)
|
||||
final topEntry = entries.last;
|
||||
final pageWidget = topEntry.builder != null
|
||||
? topEntry.builder!(context)
|
||||
: buildPageFromRoute(topEntry.route, extra: topEntry.extra);
|
||||
|
||||
if (pageWidget == null) {
|
||||
// 页面构建失败:移除无效条目,避免脏栈残留
|
||||
// canPop 时 pop 栈顶(保留下层有效条目);栈深<=1 时 clear(无效条目即根级,清空回仪表盘)
|
||||
// 使用 PostFrameCallback 延迟到 build 结束后修改状态,防止 build-during-build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final notifier = ref.read(rightPanelStackProvider.notifier);
|
||||
if (notifier.getStack(currentTab).canPop) {
|
||||
notifier.pop(currentTab);
|
||||
} else {
|
||||
notifier.clear(currentTab);
|
||||
}
|
||||
});
|
||||
return widget.defaultRightPanel;
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 右栏顶栏(返回按钮 + 标题)
|
||||
_buildRightTopBar(stackState),
|
||||
// 右栏内容(完整原始页面)
|
||||
Expanded(
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey('${topEntry.route}_${entries.length}'),
|
||||
child: pageWidget,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建右栏顶栏
|
||||
Widget _buildRightTopBar(RightPanelStackState stackState) {
|
||||
final ext = Theme.of(context);
|
||||
final canPop = stackState.canPop;
|
||||
final title = stackState.top?.title ?? '';
|
||||
|
||||
return Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: ext.colorScheme.surfaceContainerLowest.withValues(alpha: 0.72),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: ext.colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮组(栈深>1 时显示)
|
||||
if (canPop) ...[
|
||||
// 返回上一页(标准 pop)
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(40, 40),
|
||||
onPressed: _onRightPanelPop,
|
||||
child: Icon(
|
||||
CupertinoIcons.back,
|
||||
color: ext.colorScheme.primary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
// 回仪表盘(清空栈,快捷回根)
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(40, 40),
|
||||
onPressed: _onRightPanelBackToRoot,
|
||||
child: Icon(
|
||||
CupertinoIcons.house_fill,
|
||||
color: ext.colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
] else
|
||||
const SizedBox(width: 40),
|
||||
// 标题
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ext.colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// 右侧操作按钮(预留)
|
||||
const SizedBox(width: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建分割条
|
||||
Widget _buildDivider(double screenWidth) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeColumn,
|
||||
child: GestureDetector(
|
||||
onHorizontalDragStart: _onDragStart,
|
||||
onHorizontalDragUpdate: (details) => _onDragUpdate(details, screenWidth),
|
||||
onHorizontalDragEnd: _onDragEnd,
|
||||
child: Container(
|
||||
width: _dividerWidth,
|
||||
color: _isDragging
|
||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.5)
|
||||
: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 右栏返回上一页 — pop 栈顶条目,逐页返回
|
||||
/// 符合 iOS UINavigationController 标准返回语义
|
||||
void _onRightPanelPop() {
|
||||
final currentTab = ref.read(splitViewProvider).currentTab;
|
||||
ref.read(rightPanelStackProvider.notifier).pop(currentTab);
|
||||
}
|
||||
|
||||
/// 右栏回仪表盘 — 清空栈,直接回到默认仪表盘
|
||||
/// 用于栈深较大时的快捷返回根页面
|
||||
void _onRightPanelBackToRoot() {
|
||||
final currentTab = ref.read(splitViewProvider).currentTab;
|
||||
ref.read(rightPanelStackProvider.notifier).clear(currentTab);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工作台快捷键 Intent
|
||||
// ============================================================
|
||||
|
||||
/// 右栏返回 Intent
|
||||
class RightPanelBackIntent extends Intent {
|
||||
const RightPanelBackIntent();
|
||||
}
|
||||
|
||||
/// 关闭右栏当前页面 Intent
|
||||
class CloseRightPanelIntent extends Intent {
|
||||
const CloseRightPanelIntent();
|
||||
}
|
||||
|
||||
/// 工作台快捷键映射
|
||||
Map<LogicalKeySet, Intent> workbenchShortcuts({
|
||||
bool isMacos = false,
|
||||
bool isDesktop = false,
|
||||
}) {
|
||||
if (!isDesktop) return {};
|
||||
|
||||
final ctrl = isMacos
|
||||
? LogicalKeyboardKey.meta
|
||||
: LogicalKeyboardKey.control;
|
||||
|
||||
return {
|
||||
// Ctrl/Cmd + ← 右栏后退
|
||||
LogicalKeySet(ctrl, LogicalKeyboardKey.arrowLeft):
|
||||
const RightPanelBackIntent(),
|
||||
// Esc 右栏返回
|
||||
LogicalKeySet(LogicalKeyboardKey.escape):
|
||||
const RightPanelBackIntent(),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 用户数据模型(核心层)
|
||||
/// 创建时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 用户信息数据模型,对应后端 tool_user 表
|
||||
/// 上次更新: 从 features/auth/models/ 提取至 core/models/,修复架构分层违规
|
||||
/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0
|
||||
/// ============================================================
|
||||
|
||||
import 'package:xianyan/core/utils/safe_json.dart';
|
||||
|
||||
class UserModel {
|
||||
const UserModel({
|
||||
required this.id,
|
||||
@@ -133,30 +135,61 @@ class UserModel {
|
||||
/// 从JSON解析sec_question,兼容顶层和extra嵌套两种路径
|
||||
/// 服务端UserCenter接口返回: extra.sec_question.question_id
|
||||
/// 服务端changeSecQuestion接口返回: sec_question (顶层int)
|
||||
///
|
||||
/// 安全类型转换:PHP/JSON 返回的整数常为 num 类型(如 1.0),
|
||||
/// 不能用 `is int` 严格判断,必须用 `(x as num?)?.toInt()`。
|
||||
static int _parseSecQuestion(Map<String, dynamic> json) {
|
||||
// 顶层路径:sec_question 可能是 int / num / String
|
||||
final topLevel = json['sec_question'];
|
||||
if (topLevel is int && topLevel > 0) return topLevel;
|
||||
final topInt = _toSafeInt(topLevel);
|
||||
if (topInt > 0) return topInt;
|
||||
|
||||
// 嵌套路径:extra.sec_question
|
||||
final extra = json['extra'];
|
||||
if (extra is Map<String, dynamic>) {
|
||||
final nested = extra['sec_question'];
|
||||
if (nested is int && nested > 0) return nested;
|
||||
// 嵌套可能是 int / num / String / Map
|
||||
final nestedInt = _toSafeInt(nested);
|
||||
if (nestedInt > 0) return nestedInt;
|
||||
if (nested is Map<String, dynamic>) {
|
||||
return nested['question_id'] as int? ?? 0;
|
||||
final qid = _toSafeInt(nested['question_id']);
|
||||
if (qid > 0) return qid;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 安全将动态值转为 int
|
||||
/// 支持 int / double / num / String / 含数字的字符串
|
||||
static int _toSafeInt(dynamic value) {
|
||||
if (value == null) return 0;
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) {
|
||||
return int.tryParse(value.trim()) ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 从JSON解析sec_question_text,兼容顶层和extra嵌套两种路径
|
||||
static String _parseSecQuestionText(Map<String, dynamic> json) {
|
||||
final topLevel = json['sec_question_text'];
|
||||
if (topLevel is String && topLevel.isNotEmpty) return topLevel;
|
||||
// 兼容顶层 sec_question_text 为非 String 类型(如 num)的情况
|
||||
if (topLevel != null && topLevel.toString().isNotEmpty) {
|
||||
final str = topLevel.toString();
|
||||
if (str.isNotEmpty && str != 'null') return str;
|
||||
}
|
||||
final extra = json['extra'];
|
||||
if (extra is Map<String, dynamic>) {
|
||||
final nested = extra['sec_question'];
|
||||
if (nested is Map<String, dynamic>) {
|
||||
final text = nested['question_text'];
|
||||
if (text is String && text.isNotEmpty) return text;
|
||||
if (text != null && text.toString().isNotEmpty) {
|
||||
final str = text.toString();
|
||||
if (str != 'null') return str;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
@@ -192,7 +225,7 @@ class UserModel {
|
||||
json['verification'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
isOnline: json['is_online'] as int? ?? 0,
|
||||
isOnline: SafeJson.parseInt(json['is_online']),
|
||||
vip: json['vip'] != null
|
||||
? UserVip.fromJson(json['vip'] as Map<String, dynamic>)
|
||||
: null,
|
||||
@@ -335,7 +368,7 @@ class UserTitle {
|
||||
|
||||
factory UserTitle.fromJson(Map<String, dynamic> json) {
|
||||
return UserTitle(
|
||||
id: json['id'] as int? ?? 1,
|
||||
id: SafeJson.parseInt(json['id'], 1),
|
||||
name: json['name'] as String? ?? '新手',
|
||||
icon: json['icon'] as String? ?? '',
|
||||
color: json['color'] as String? ?? '#999999',
|
||||
@@ -392,8 +425,8 @@ class UserVip {
|
||||
factory UserVip.fromJson(Map<String, dynamic> json) {
|
||||
return UserVip(
|
||||
isVip: json['is_vip'] as bool? ?? false,
|
||||
startTime: json['start_time'] as int? ?? 0,
|
||||
endTime: json['end_time'] as int? ?? 0,
|
||||
startTime: SafeJson.parseInt(json['start_time']),
|
||||
endTime: SafeJson.parseInt(json['end_time']),
|
||||
startDate: json['start_date'] as String? ?? '',
|
||||
endDate: json['end_date'] as String? ?? '',
|
||||
);
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — Dio HTTP 缓存配置
|
||||
/// 创建时间: 2026-05-27
|
||||
/// 更新时间: 2026-06-15
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 配置 dio_cache_interceptor 缓存策略
|
||||
/// GET 请求默认缓存5分钟,特定接口可自定义
|
||||
/// 排除需要实时数据的接口(登录、签到等)
|
||||
/// 双层缓存: L1内存(快速) + L2 Hive持久化(重启不丢失)
|
||||
/// 上次更新: 升级dio_cache_interceptor 4.x,hitCacheOnErrorExcept→hitCacheOnNetworkFailure,Nullable<Duration>→Duration?,CacheResponse添加statusCode
|
||||
/// 上次更新: 修复JSON类型安全问题,使用SafeJson.parseInt替代as int? ?? 0
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:xianyan/core/utils/safe_json.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
import '../storage/hive_safe_access.dart';
|
||||
@@ -89,9 +90,9 @@ class HiveCacheStore extends CacheStore {
|
||||
final map = Map<String, dynamic>.from(raw);
|
||||
return CacheResponse(
|
||||
cacheControl: CacheControl(
|
||||
maxAge: map['cc_maxAge'] as int? ?? -1,
|
||||
maxStale: map['cc_maxStale'] as int? ?? -1,
|
||||
minFresh: map['cc_minFresh'] as int? ?? -1,
|
||||
maxAge: SafeJson.parseInt(map['cc_maxAge'], -1),
|
||||
maxStale: SafeJson.parseInt(map['cc_maxStale'], -1),
|
||||
minFresh: SafeJson.parseInt(map['cc_minFresh'], -1),
|
||||
mustRevalidate: map['cc_mustRevalidate'] as bool? ?? false,
|
||||
privacy: map['cc_privacy'] as String?,
|
||||
noCache: map['cc_noCache'] as bool? ?? false,
|
||||
@@ -118,11 +119,11 @@ class HiveCacheStore extends CacheStore {
|
||||
maxStale: map['maxStale'] != null
|
||||
? DateTime.parse(map['maxStale'] as String)
|
||||
: null,
|
||||
priority: CachePriority.values[map['priority'] as int? ?? 1],
|
||||
priority: CachePriority.values[SafeJson.parseInt(map['priority'], 1)],
|
||||
requestDate: DateTime.parse(map['requestDate'] as String),
|
||||
responseDate: DateTime.parse(map['responseDate'] as String),
|
||||
url: map['url'] as String,
|
||||
statusCode: map['statusCode'] as int? ?? 200,
|
||||
statusCode: SafeJson.parseInt(map['statusCode'], 200),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 宽屏分屏状态管理
|
||||
/// 创建时间: 2026-05-29
|
||||
/// 更新时间: 2026-05-31
|
||||
/// 作用: 管理分屏比例、右侧面板内容、导航栏位置、分屏开关
|
||||
/// 上次更新: @Default 和 build fallback 统一引用 DefaultSettings
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 管理分屏比例、右侧面板内容、导航栏位置、分屏开关、工作台模式
|
||||
/// 上次更新: 新增工作台交互增强字段(专注阅读/右栏分屏/拖拽出窗/右栏标签页/中栏拖拽排序/毛玻璃/空状态动画)
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:xianyan/core/storage/kv_storage.dart';
|
||||
@@ -63,9 +65,21 @@ class SplitRatioOption {
|
||||
const String _kSplitRatio = 'split_view_ratio';
|
||||
const String _kNavBarPosition = 'nav_bar_position';
|
||||
const String _kSplitViewEnabled = 'split_view_enabled';
|
||||
const String _kWorkbenchEnabled = 'workbench_enabled';
|
||||
const String _kFocusReadingMode = 'workbench_focus_reading';
|
||||
const String _kRightPanelSplit = 'workbench_right_panel_split';
|
||||
const String _kPopOutWindow = 'workbench_pop_out_window';
|
||||
const String _kRightPanelTabs = 'workbench_right_panel_tabs';
|
||||
const String _kMiddlePanelDragSort = 'workbench_middle_drag_sort';
|
||||
const String _kWorkbenchBlurBackground = 'workbench_blur_bg';
|
||||
const String _kEmptyStateAnimation = 'workbench_empty_anim';
|
||||
const String _kNavBarCollapsed = 'nav_bar_collapsed';
|
||||
const String _kNavBarWidth = 'workbench_nav_bar_width';
|
||||
const String _kTabSplitRatios = 'tab_split_ratios';
|
||||
|
||||
/// 三栏布局断点:宽度 >= 1400px 进入三栏模式
|
||||
const double kTripleColumnBreakpoint = 1400.0;
|
||||
/// @deprecated 旧三栏断点,保留向后兼容,实际指向 kMediumBreakpoint (1024.0)
|
||||
/// 新断点常量定义在 adaptive_split_view.dart
|
||||
const double kTripleColumnBreakpoint = 1024.0;
|
||||
|
||||
/// 分屏视图状态(freezed不可变数据类)
|
||||
@freezed
|
||||
@@ -77,12 +91,39 @@ sealed class SplitViewState with _$SplitViewState {
|
||||
Map<String, dynamic>? rightPanelArgs,
|
||||
@Default(NavBarPosition.left) NavBarPosition navBarPosition,
|
||||
@Default(false) bool splitViewEnabled,
|
||||
/// 工作台模式开关(宽屏自动开启,用户可手动关闭)
|
||||
@Default(true) bool workbenchEnabled,
|
||||
String? homeRightPanel,
|
||||
String? discoverRightPanel,
|
||||
String? profileRightPanel,
|
||||
@Default(0) int currentTab,
|
||||
String? thirdPanelContent,
|
||||
Map<String, dynamic>? thirdPanelArgs,
|
||||
|
||||
// === 工作台交互增强(P0-8 新增)===
|
||||
/// 专注阅读模式:隐藏中栏+导航栏,右栏全宽沉浸
|
||||
@Default(false) bool focusReadingMode,
|
||||
/// 右栏分屏:超宽屏(≥1920px)时右栏再分屏
|
||||
@Default(false) bool rightPanelSplit,
|
||||
/// 拖拽出窗(占位,需多窗口支持)
|
||||
@Default(false) bool popOutWindow,
|
||||
/// 右栏标签页(占位,复用 RightPanelStackNotifier 扩展)
|
||||
@Default(false) bool rightPanelTabs,
|
||||
/// 中栏拖拽排序
|
||||
@Default(false) bool middlePanelDragSort,
|
||||
/// 工作台毛玻璃背景(仅桌面端)
|
||||
@Default(true) bool workbenchBlurBackground,
|
||||
/// 空状态动画
|
||||
@Default(true) bool emptyStateAnimation,
|
||||
|
||||
// === 侧边栏折叠 + 分屏记忆(2026-06-18 新增)===
|
||||
/// 导航栏折叠状态(折叠时仅显示图标 48px)
|
||||
@Default(false) bool navBarCollapsed,
|
||||
/// 导航栏宽度(展开态可拖拽调节,默认 72.0)
|
||||
@Default(72.0) double navBarWidth,
|
||||
/// 各 Tab 独立分屏比例(key=tabIndex, value=ratio)
|
||||
/// 切换 Tab 时恢复该 Tab 上次的分屏比例
|
||||
@Default({}) Map<int, double> tabSplitRatios,
|
||||
}) = _SplitViewState;
|
||||
|
||||
String? get activeRightPanel => switch (currentTab) {
|
||||
@@ -99,6 +140,21 @@ class SplitViewNotifier extends Notifier<SplitViewState> {
|
||||
SplitViewState build() {
|
||||
final savedIndex = KvStorage.getInt(_kNavBarPosition) ?? 0;
|
||||
const positions = NavBarPosition.values;
|
||||
|
||||
// 读取分屏记忆(JSON 序列化的 Map<int, double>)
|
||||
final tabRatiosJson = KvStorage.getString(_kTabSplitRatios);
|
||||
Map<int, double> tabSplitRatios = {};
|
||||
if (tabRatiosJson != null && tabRatiosJson.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(tabRatiosJson) as Map<String, dynamic>;
|
||||
tabSplitRatios = decoded.map(
|
||||
(k, v) => MapEntry(int.parse(k), (v as num).toDouble()),
|
||||
);
|
||||
} catch (_) {
|
||||
// 解析失败使用空 Map
|
||||
}
|
||||
}
|
||||
|
||||
return SplitViewState(
|
||||
splitRatio: KvStorage.getDouble(_kSplitRatio) ?? 0.4,
|
||||
navBarPosition: (savedIndex >= 0 && savedIndex < positions.length)
|
||||
@@ -107,6 +163,18 @@ class SplitViewNotifier extends Notifier<SplitViewState> {
|
||||
splitViewEnabled:
|
||||
KvStorage.getBool(_kSplitViewEnabled) ??
|
||||
DefaultSettings.splitViewEnabled,
|
||||
workbenchEnabled:
|
||||
KvStorage.getBool(_kWorkbenchEnabled) ?? true,
|
||||
focusReadingMode: KvStorage.getBool(_kFocusReadingMode) ?? false,
|
||||
rightPanelSplit: KvStorage.getBool(_kRightPanelSplit) ?? false,
|
||||
popOutWindow: KvStorage.getBool(_kPopOutWindow) ?? false,
|
||||
rightPanelTabs: KvStorage.getBool(_kRightPanelTabs) ?? false,
|
||||
middlePanelDragSort: KvStorage.getBool(_kMiddlePanelDragSort) ?? false,
|
||||
workbenchBlurBackground: KvStorage.getBool(_kWorkbenchBlurBackground) ?? true,
|
||||
emptyStateAnimation: KvStorage.getBool(_kEmptyStateAnimation) ?? true,
|
||||
navBarCollapsed: KvStorage.getBool(_kNavBarCollapsed) ?? false,
|
||||
navBarWidth: KvStorage.getDouble(_kNavBarWidth) ?? 72.0,
|
||||
tabSplitRatios: tabSplitRatios,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,6 +197,113 @@ class SplitViewNotifier extends Notifier<SplitViewState> {
|
||||
state = state.copyWith(splitViewEnabled: enabled);
|
||||
}
|
||||
|
||||
/// 设置工作台模式开关(宽屏自动开启,用户可手动关闭)
|
||||
void setWorkbenchEnabled(bool enabled) {
|
||||
KvStorage.setBool(_kWorkbenchEnabled, enabled);
|
||||
state = state.copyWith(workbenchEnabled: enabled);
|
||||
}
|
||||
|
||||
// === 工作台交互增强 setter(P0-8 新增)===
|
||||
|
||||
/// 设置专注阅读模式
|
||||
void setFocusReadingMode(bool enabled) {
|
||||
KvStorage.setBool(_kFocusReadingMode, enabled);
|
||||
state = state.copyWith(focusReadingMode: enabled);
|
||||
}
|
||||
|
||||
/// 设置右栏分屏
|
||||
void setRightPanelSplit(bool enabled) {
|
||||
KvStorage.setBool(_kRightPanelSplit, enabled);
|
||||
state = state.copyWith(rightPanelSplit: enabled);
|
||||
}
|
||||
|
||||
/// 设置拖拽出窗(占位)
|
||||
void setPopOutWindow(bool enabled) {
|
||||
KvStorage.setBool(_kPopOutWindow, enabled);
|
||||
state = state.copyWith(popOutWindow: enabled);
|
||||
}
|
||||
|
||||
/// 设置右栏标签页(占位)
|
||||
void setRightPanelTabs(bool enabled) {
|
||||
KvStorage.setBool(_kRightPanelTabs, enabled);
|
||||
state = state.copyWith(rightPanelTabs: enabled);
|
||||
}
|
||||
|
||||
/// 设置中栏拖拽排序
|
||||
void setMiddlePanelDragSort(bool enabled) {
|
||||
KvStorage.setBool(_kMiddlePanelDragSort, enabled);
|
||||
state = state.copyWith(middlePanelDragSort: enabled);
|
||||
}
|
||||
|
||||
/// 设置工作台毛玻璃背景
|
||||
void setWorkbenchBlurBackground(bool enabled) {
|
||||
KvStorage.setBool(_kWorkbenchBlurBackground, enabled);
|
||||
state = state.copyWith(workbenchBlurBackground: enabled);
|
||||
}
|
||||
|
||||
/// 设置空状态动画
|
||||
void setEmptyStateAnimation(bool enabled) {
|
||||
KvStorage.setBool(_kEmptyStateAnimation, enabled);
|
||||
state = state.copyWith(emptyStateAnimation: enabled);
|
||||
}
|
||||
|
||||
// === 侧边栏折叠 + 分屏记忆 setter(2026-06-18 新增)===
|
||||
|
||||
/// 设置导航栏折叠状态
|
||||
/// 折叠时仅显示图标(48px),展开时恢复原宽度
|
||||
void setNavBarCollapsed(bool collapsed) {
|
||||
KvStorage.setBool(_kNavBarCollapsed, collapsed);
|
||||
state = state.copyWith(navBarCollapsed: collapsed);
|
||||
}
|
||||
|
||||
/// 切换导航栏折叠状态
|
||||
void toggleNavBarCollapsed() {
|
||||
setNavBarCollapsed(!state.navBarCollapsed);
|
||||
}
|
||||
|
||||
/// 设置导航栏宽度(展开态可拖拽调节)
|
||||
/// 范围限制:48.0(最小折叠态)~ 240.0(最大展开态)
|
||||
void setNavBarWidth(double width) {
|
||||
final clamped = width.clamp(48.0, 240.0);
|
||||
KvStorage.setDouble(_kNavBarWidth, clamped);
|
||||
state = state.copyWith(navBarWidth: clamped);
|
||||
}
|
||||
|
||||
/// 保存当前 Tab 的分屏比例到记忆
|
||||
void saveCurrentTabSplitRatio() {
|
||||
final tab = state.currentTab;
|
||||
final ratio = state.splitRatio;
|
||||
final newRatios = Map<int, double>.from(state.tabSplitRatios);
|
||||
newRatios[tab] = ratio;
|
||||
// JSON 序列化(key 必须是 String)
|
||||
final jsonMap = newRatios.map((k, v) => MapEntry(k.toString(), v));
|
||||
KvStorage.setString(_kTabSplitRatios, jsonEncode(jsonMap));
|
||||
state = state.copyWith(tabSplitRatios: newRatios);
|
||||
}
|
||||
|
||||
/// 切换 Tab 并恢复该 Tab 上次的分屏比例
|
||||
void setCurrentTabWithMemory(int index) {
|
||||
// 先保存当前 Tab 的比例
|
||||
final currentTab = state.currentTab;
|
||||
final currentRatio = state.splitRatio;
|
||||
final newRatios = Map<int, double>.from(state.tabSplitRatios);
|
||||
newRatios[currentTab] = currentRatio;
|
||||
|
||||
// 恢复目标 Tab 的比例(如果有记忆)
|
||||
final restoredRatio = newRatios[index] ?? 0.4;
|
||||
|
||||
// 持久化
|
||||
final jsonMap = newRatios.map((k, v) => MapEntry(k.toString(), v));
|
||||
KvStorage.setString(_kTabSplitRatios, jsonEncode(jsonMap));
|
||||
KvStorage.setDouble(_kSplitRatio, restoredRatio);
|
||||
|
||||
state = state.copyWith(
|
||||
currentTab: index,
|
||||
splitRatio: restoredRatio,
|
||||
tabSplitRatios: newRatios,
|
||||
);
|
||||
}
|
||||
|
||||
void setHomeRightPanel(String? panelId, {Map<String, dynamic>? args}) {
|
||||
state = state.copyWith(homeRightPanel: panelId, rightPanelArgs: args);
|
||||
}
|
||||
|
||||
@@ -25,12 +25,23 @@ mixin _$SplitViewState {
|
||||
Map<String, dynamic>? get rightPanelArgs;
|
||||
NavBarPosition get navBarPosition;
|
||||
bool get splitViewEnabled;
|
||||
bool get workbenchEnabled;
|
||||
String? get homeRightPanel;
|
||||
String? get discoverRightPanel;
|
||||
String? get profileRightPanel;
|
||||
int get currentTab;
|
||||
String? get thirdPanelContent;
|
||||
Map<String, dynamic>? get thirdPanelArgs;
|
||||
bool get focusReadingMode;
|
||||
bool get rightPanelSplit;
|
||||
bool get popOutWindow;
|
||||
bool get rightPanelTabs;
|
||||
bool get middlePanelDragSort;
|
||||
bool get workbenchBlurBackground;
|
||||
bool get emptyStateAnimation;
|
||||
bool get navBarCollapsed;
|
||||
double get navBarWidth;
|
||||
Map<int, double> get tabSplitRatios;
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
@@ -53,6 +64,8 @@ mixin _$SplitViewState {
|
||||
other.navBarPosition == navBarPosition) &&
|
||||
(identical(other.splitViewEnabled, splitViewEnabled) ||
|
||||
other.splitViewEnabled == splitViewEnabled) &&
|
||||
(identical(other.workbenchEnabled, workbenchEnabled) ||
|
||||
other.workbenchEnabled == workbenchEnabled) &&
|
||||
(identical(other.homeRightPanel, homeRightPanel) ||
|
||||
other.homeRightPanel == homeRightPanel) &&
|
||||
(identical(other.discoverRightPanel, discoverRightPanel) ||
|
||||
@@ -64,27 +77,59 @@ mixin _$SplitViewState {
|
||||
(identical(other.thirdPanelContent, thirdPanelContent) ||
|
||||
other.thirdPanelContent == thirdPanelContent) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other.thirdPanelArgs, thirdPanelArgs));
|
||||
.equals(other.thirdPanelArgs, thirdPanelArgs) &&
|
||||
(identical(other.focusReadingMode, focusReadingMode) ||
|
||||
other.focusReadingMode == focusReadingMode) &&
|
||||
(identical(other.rightPanelSplit, rightPanelSplit) ||
|
||||
other.rightPanelSplit == rightPanelSplit) &&
|
||||
(identical(other.popOutWindow, popOutWindow) ||
|
||||
other.popOutWindow == popOutWindow) &&
|
||||
(identical(other.rightPanelTabs, rightPanelTabs) ||
|
||||
other.rightPanelTabs == rightPanelTabs) &&
|
||||
(identical(other.middlePanelDragSort, middlePanelDragSort) ||
|
||||
other.middlePanelDragSort == middlePanelDragSort) &&
|
||||
(identical(other.workbenchBlurBackground, workbenchBlurBackground) ||
|
||||
other.workbenchBlurBackground == workbenchBlurBackground) &&
|
||||
(identical(other.emptyStateAnimation, emptyStateAnimation) ||
|
||||
other.emptyStateAnimation == emptyStateAnimation) &&
|
||||
(identical(other.navBarCollapsed, navBarCollapsed) ||
|
||||
other.navBarCollapsed == navBarCollapsed) &&
|
||||
(identical(other.navBarWidth, navBarWidth) ||
|
||||
other.navBarWidth == navBarWidth) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other.tabSplitRatios, tabSplitRatios));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
int get hashCode => Object.hashAll([
|
||||
runtimeType,
|
||||
splitRatio,
|
||||
rightPanelContent,
|
||||
const DeepCollectionEquality().hash(rightPanelArgs),
|
||||
navBarPosition,
|
||||
splitViewEnabled,
|
||||
workbenchEnabled,
|
||||
homeRightPanel,
|
||||
discoverRightPanel,
|
||||
profileRightPanel,
|
||||
currentTab,
|
||||
thirdPanelContent,
|
||||
const DeepCollectionEquality().hash(thirdPanelArgs));
|
||||
const DeepCollectionEquality().hash(thirdPanelArgs),
|
||||
focusReadingMode,
|
||||
rightPanelSplit,
|
||||
popOutWindow,
|
||||
rightPanelTabs,
|
||||
middlePanelDragSort,
|
||||
workbenchBlurBackground,
|
||||
emptyStateAnimation,
|
||||
navBarCollapsed,
|
||||
navBarWidth,
|
||||
const DeepCollectionEquality().hash(tabSplitRatios),
|
||||
]);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SplitViewState(splitRatio: $splitRatio, rightPanelContent: $rightPanelContent, rightPanelArgs: $rightPanelArgs, navBarPosition: $navBarPosition, splitViewEnabled: $splitViewEnabled, homeRightPanel: $homeRightPanel, discoverRightPanel: $discoverRightPanel, profileRightPanel: $profileRightPanel, currentTab: $currentTab, thirdPanelContent: $thirdPanelContent, thirdPanelArgs: $thirdPanelArgs)';
|
||||
return 'SplitViewState(splitRatio: $splitRatio, rightPanelContent: $rightPanelContent, rightPanelArgs: $rightPanelArgs, navBarPosition: $navBarPosition, splitViewEnabled: $splitViewEnabled, workbenchEnabled: $workbenchEnabled, homeRightPanel: $homeRightPanel, discoverRightPanel: $discoverRightPanel, profileRightPanel: $profileRightPanel, currentTab: $currentTab, thirdPanelContent: $thirdPanelContent, thirdPanelArgs: $thirdPanelArgs, focusReadingMode: $focusReadingMode, rightPanelSplit: $rightPanelSplit, popOutWindow: $popOutWindow, rightPanelTabs: $rightPanelTabs, middlePanelDragSort: $middlePanelDragSort, workbenchBlurBackground: $workbenchBlurBackground, emptyStateAnimation: $emptyStateAnimation, navBarCollapsed: $navBarCollapsed, navBarWidth: $navBarWidth, tabSplitRatios: $tabSplitRatios)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,12 +145,23 @@ abstract mixin class $SplitViewStateCopyWith<$Res> {
|
||||
Map<String, dynamic>? rightPanelArgs,
|
||||
NavBarPosition navBarPosition,
|
||||
bool splitViewEnabled,
|
||||
bool workbenchEnabled,
|
||||
String? homeRightPanel,
|
||||
String? discoverRightPanel,
|
||||
String? profileRightPanel,
|
||||
int currentTab,
|
||||
String? thirdPanelContent,
|
||||
Map<String, dynamic>? thirdPanelArgs});
|
||||
Map<String, dynamic>? thirdPanelArgs,
|
||||
bool focusReadingMode,
|
||||
bool rightPanelSplit,
|
||||
bool popOutWindow,
|
||||
bool rightPanelTabs,
|
||||
bool middlePanelDragSort,
|
||||
bool workbenchBlurBackground,
|
||||
bool emptyStateAnimation,
|
||||
bool navBarCollapsed,
|
||||
double navBarWidth,
|
||||
Map<int, double> tabSplitRatios});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@@ -124,12 +180,23 @@ class _$SplitViewStateCopyWithImpl<$Res>
|
||||
Object? rightPanelArgs = _undefined,
|
||||
Object? navBarPosition = _undefined,
|
||||
Object? splitViewEnabled = _undefined,
|
||||
Object? workbenchEnabled = _undefined,
|
||||
Object? homeRightPanel = _undefined,
|
||||
Object? discoverRightPanel = _undefined,
|
||||
Object? profileRightPanel = _undefined,
|
||||
Object? currentTab = _undefined,
|
||||
Object? thirdPanelContent = _undefined,
|
||||
Object? thirdPanelArgs = _undefined,
|
||||
Object? focusReadingMode = _undefined,
|
||||
Object? rightPanelSplit = _undefined,
|
||||
Object? popOutWindow = _undefined,
|
||||
Object? rightPanelTabs = _undefined,
|
||||
Object? middlePanelDragSort = _undefined,
|
||||
Object? workbenchBlurBackground = _undefined,
|
||||
Object? emptyStateAnimation = _undefined,
|
||||
Object? navBarCollapsed = _undefined,
|
||||
Object? navBarWidth = _undefined,
|
||||
Object? tabSplitRatios = _undefined,
|
||||
}) {
|
||||
return _then(_SplitViewState(
|
||||
splitRatio: identical(splitRatio, _undefined)
|
||||
@@ -147,6 +214,9 @@ class _$SplitViewStateCopyWithImpl<$Res>
|
||||
splitViewEnabled: identical(splitViewEnabled, _undefined)
|
||||
? _self.splitViewEnabled
|
||||
: splitViewEnabled as bool,
|
||||
workbenchEnabled: identical(workbenchEnabled, _undefined)
|
||||
? _self.workbenchEnabled
|
||||
: workbenchEnabled as bool,
|
||||
homeRightPanel: identical(homeRightPanel, _undefined)
|
||||
? _self.homeRightPanel
|
||||
: homeRightPanel as String?,
|
||||
@@ -165,6 +235,36 @@ class _$SplitViewStateCopyWithImpl<$Res>
|
||||
thirdPanelArgs: identical(thirdPanelArgs, _undefined)
|
||||
? _self.thirdPanelArgs
|
||||
: thirdPanelArgs as Map<String, dynamic>?,
|
||||
focusReadingMode: identical(focusReadingMode, _undefined)
|
||||
? _self.focusReadingMode
|
||||
: focusReadingMode as bool,
|
||||
rightPanelSplit: identical(rightPanelSplit, _undefined)
|
||||
? _self.rightPanelSplit
|
||||
: rightPanelSplit as bool,
|
||||
popOutWindow: identical(popOutWindow, _undefined)
|
||||
? _self.popOutWindow
|
||||
: popOutWindow as bool,
|
||||
rightPanelTabs: identical(rightPanelTabs, _undefined)
|
||||
? _self.rightPanelTabs
|
||||
: rightPanelTabs as bool,
|
||||
middlePanelDragSort: identical(middlePanelDragSort, _undefined)
|
||||
? _self.middlePanelDragSort
|
||||
: middlePanelDragSort as bool,
|
||||
workbenchBlurBackground: identical(workbenchBlurBackground, _undefined)
|
||||
? _self.workbenchBlurBackground
|
||||
: workbenchBlurBackground as bool,
|
||||
emptyStateAnimation: identical(emptyStateAnimation, _undefined)
|
||||
? _self.emptyStateAnimation
|
||||
: emptyStateAnimation as bool,
|
||||
navBarCollapsed: identical(navBarCollapsed, _undefined)
|
||||
? _self.navBarCollapsed
|
||||
: navBarCollapsed as bool,
|
||||
navBarWidth: identical(navBarWidth, _undefined)
|
||||
? _self.navBarWidth
|
||||
: navBarWidth as double,
|
||||
tabSplitRatios: identical(tabSplitRatios, _undefined)
|
||||
? _self.tabSplitRatios
|
||||
: tabSplitRatios as Map<int, double>,
|
||||
) as SplitViewState);
|
||||
}
|
||||
}
|
||||
@@ -177,12 +277,23 @@ class _SplitViewState extends SplitViewState {
|
||||
this.rightPanelArgs,
|
||||
this.navBarPosition = NavBarPosition.left,
|
||||
this.splitViewEnabled = false,
|
||||
this.workbenchEnabled = true,
|
||||
this.homeRightPanel,
|
||||
this.discoverRightPanel,
|
||||
this.profileRightPanel,
|
||||
this.currentTab = 0,
|
||||
this.thirdPanelContent,
|
||||
this.thirdPanelArgs})
|
||||
this.thirdPanelArgs,
|
||||
this.focusReadingMode = false,
|
||||
this.rightPanelSplit = false,
|
||||
this.popOutWindow = false,
|
||||
this.rightPanelTabs = false,
|
||||
this.middlePanelDragSort = false,
|
||||
this.workbenchBlurBackground = true,
|
||||
this.emptyStateAnimation = true,
|
||||
this.navBarCollapsed = false,
|
||||
this.navBarWidth = 72.0,
|
||||
this.tabSplitRatios = const {}})
|
||||
: super._();
|
||||
|
||||
@override
|
||||
@@ -196,6 +307,8 @@ class _SplitViewState extends SplitViewState {
|
||||
@override
|
||||
final bool splitViewEnabled;
|
||||
@override
|
||||
final bool workbenchEnabled;
|
||||
@override
|
||||
final String? homeRightPanel;
|
||||
@override
|
||||
final String? discoverRightPanel;
|
||||
@@ -207,6 +320,26 @@ class _SplitViewState extends SplitViewState {
|
||||
final String? thirdPanelContent;
|
||||
@override
|
||||
final Map<String, dynamic>? thirdPanelArgs;
|
||||
@override
|
||||
final bool focusReadingMode;
|
||||
@override
|
||||
final bool rightPanelSplit;
|
||||
@override
|
||||
final bool popOutWindow;
|
||||
@override
|
||||
final bool rightPanelTabs;
|
||||
@override
|
||||
final bool middlePanelDragSort;
|
||||
@override
|
||||
final bool workbenchBlurBackground;
|
||||
@override
|
||||
final bool emptyStateAnimation;
|
||||
@override
|
||||
final bool navBarCollapsed;
|
||||
@override
|
||||
final double navBarWidth;
|
||||
@override
|
||||
final Map<int, double> tabSplitRatios;
|
||||
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 跨平台导航扩展
|
||||
// 创建时间: 2026-05-18
|
||||
// 更新时间: 2026-06-01
|
||||
// 更新时间: 2026-06-19
|
||||
// 作用: 统一导航API,鸿蒙端使用OhosNavBridge,其他端使用GoRouter;自动记录最近路由
|
||||
// 上次更新: appGo()也记录最近路由,确保所有导航方式都留下记录
|
||||
// 上次更新: 新增 appPop/appCanPop 工作台感知方法,修复右栏页面 Navigator.pop 导致根栈被弹空的白屏问题
|
||||
// ============================================================
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -12,12 +12,56 @@ import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
import 'package:xianyan/core/router/ohos_nav_bridge.dart';
|
||||
import 'package:xianyan/features/settings/providers/general_settings_provider.dart';
|
||||
import 'package:xianyan/core/services/recent_route_service.dart';
|
||||
import 'package:xianyan/core/router/route_registry.dart';
|
||||
import 'package:xianyan/core/layout/workbench/right_panel_navigator.dart';
|
||||
import 'package:xianyan/core/layout/adaptive_split_view.dart' show kCompactBreakpoint;
|
||||
import 'package:xianyan/core/providers/split_view_provider.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 工作台模式导航辅助
|
||||
///
|
||||
/// 在宽屏工作台模式下,将非全屏路由重定向到右栏嵌套 Navigator
|
||||
class WorkbenchNavHelper {
|
||||
/// 判断当前是否处于工作台模式
|
||||
///
|
||||
/// 所有平台(桌面/移动/Web)在宽度 >= 768px 且工作台开关开启时自动启用
|
||||
/// 仅鸿蒙端因独立布局不走工作台模式
|
||||
static bool isWorkbenchMode(BuildContext context, WidgetRef ref) {
|
||||
// 鸿蒙端不走工作台模式(独立布局)
|
||||
if (pu.isOhos) return false;
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final splitState = ref.read(splitViewProvider);
|
||||
return screenWidth >= kCompactBreakpoint && splitState.workbenchEnabled;
|
||||
}
|
||||
|
||||
/// 判断路由是否应该进入右栏(非全屏路由)
|
||||
static bool shouldOpenInRightPanel(String route) {
|
||||
return !isFullScreenRoute(route);
|
||||
}
|
||||
|
||||
/// 在右栏 push 一个页面
|
||||
static void pushToRightPanel(
|
||||
WidgetRef ref,
|
||||
int currentTab,
|
||||
String route, {
|
||||
Object? extra,
|
||||
String? title,
|
||||
}) {
|
||||
final entry = RightPanelEntry(
|
||||
route: route,
|
||||
title: title ?? inferRouteTitle(route),
|
||||
extra: extra,
|
||||
);
|
||||
ref.read(rightPanelStackProvider.notifier).push(currentTab, entry);
|
||||
}
|
||||
}
|
||||
|
||||
extension AppNavExtension on BuildContext {
|
||||
Future<T?> appPush<T extends Object?>(
|
||||
String route, {
|
||||
Object? extra,
|
||||
PageTransitionMode? transitionMode,
|
||||
String? title,
|
||||
}) {
|
||||
RecentRouteService.addRecentRoute(route);
|
||||
if (pu.isOhos) {
|
||||
@@ -28,6 +72,29 @@ extension AppNavExtension on BuildContext {
|
||||
transitionMode: transitionMode,
|
||||
);
|
||||
}
|
||||
|
||||
// 工作台模式判断:宽屏 + 非全屏路由 → push 到右栏嵌套 Navigator
|
||||
if (!pu.isOhos) {
|
||||
final screenWidth = MediaQuery.sizeOf(this).width;
|
||||
if (screenWidth >= kCompactBreakpoint) {
|
||||
// 通过 ProviderScope 获取 ProviderContainer
|
||||
final container = ProviderScope.containerOf(this);
|
||||
final splitState = container.read(splitViewProvider);
|
||||
if (splitState.workbenchEnabled && !isFullScreenRoute(route)) {
|
||||
// 工作台模式:push 到右栏嵌套 Navigator
|
||||
final entry = RightPanelEntry(
|
||||
route: route,
|
||||
title: title ?? inferRouteTitle(route),
|
||||
extra: extra,
|
||||
);
|
||||
container
|
||||
.read(rightPanelStackProvider.notifier)
|
||||
.push(splitState.currentTab, entry);
|
||||
return Future<T?>.value();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return push<T>(route, extra: extra);
|
||||
}
|
||||
|
||||
@@ -48,4 +115,157 @@ extension AppNavExtension on BuildContext {
|
||||
}
|
||||
go(route);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工作台模式感知的 pop / canPop
|
||||
// ============================================================
|
||||
|
||||
/// 工作台模式感知的 pop
|
||||
///
|
||||
/// - 宽屏工作台模式 → pop 右栏嵌套栈
|
||||
/// - 窄屏或非工作台 → 走 Navigator.pop(兼容 go_router)
|
||||
///
|
||||
/// 修复问题:右栏页面直接调用 `Navigator.pop(context)` 会操作根 GoRouter
|
||||
/// Navigator,导致栈被弹空出现白屏断言错误。
|
||||
void appPop<T extends Object?>([T? result]) {
|
||||
if (pu.isOhos) {
|
||||
Navigator.pop(this, result);
|
||||
return;
|
||||
}
|
||||
|
||||
// 工作台模式判断:宽屏 + 工作台开关开启 → pop 右栏栈
|
||||
final screenWidth = MediaQuery.sizeOf(this).width;
|
||||
if (screenWidth >= kCompactBreakpoint) {
|
||||
final container = ProviderScope.containerOf(this);
|
||||
final splitState = container.read(splitViewProvider);
|
||||
if (splitState.workbenchEnabled) {
|
||||
// 工作台模式:pop 右栏栈顶页面
|
||||
container
|
||||
.read(rightPanelStackProvider.notifier)
|
||||
.pop(splitState.currentTab);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 正常模式:使用 Navigator.pop
|
||||
Navigator.pop(this, result);
|
||||
}
|
||||
|
||||
/// 工作台模式感知的 canPop
|
||||
///
|
||||
/// - 宽屏工作台模式 → 检查右栏嵌套栈是否非空
|
||||
/// - 窄屏或非工作台 → 走 Navigator.canPop
|
||||
bool appCanPop() {
|
||||
if (pu.isOhos) {
|
||||
return Navigator.of(this).canPop();
|
||||
}
|
||||
|
||||
// 工作台模式判断
|
||||
final screenWidth = MediaQuery.sizeOf(this).width;
|
||||
if (screenWidth >= kCompactBreakpoint) {
|
||||
final container = ProviderScope.containerOf(this);
|
||||
final splitState = container.read(splitViewProvider);
|
||||
if (splitState.workbenchEnabled) {
|
||||
// 工作台模式:检查右栏栈是否有页面
|
||||
final stackMap = container.read(rightPanelStackProvider);
|
||||
final stack = stackMap[splitState.currentTab];
|
||||
return stack != null && stack.entries.isNotEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
// 正常模式
|
||||
return Navigator.of(this).canPop();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ConsumerContext 扩展 — 工作台模式感知的导航
|
||||
// ============================================================
|
||||
|
||||
/// 工作台模式感知的导航扩展
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// ref.appPush(context, '/sentence/123', extra: {...});
|
||||
/// ```
|
||||
extension WorkbenchNavExtension on WidgetRef {
|
||||
/// 工作台模式感知的 push
|
||||
///
|
||||
/// - 宽屏工作台模式 + 非全屏路由 → push 到右栏嵌套 Navigator
|
||||
/// - 窄屏 或 全屏路由 → 走 GoRouter rootNavigator push
|
||||
Future<T?> appPush<T extends Object?>(
|
||||
BuildContext context,
|
||||
String route, {
|
||||
Object? extra,
|
||||
PageTransitionMode? transitionMode,
|
||||
String? title,
|
||||
}) {
|
||||
RecentRouteService.addRecentRoute(route);
|
||||
|
||||
// 鸿蒙端走 OhosNavBridge
|
||||
if (pu.isOhos) {
|
||||
return OhosNavBridge.push<T>(
|
||||
context,
|
||||
route,
|
||||
extra: extra,
|
||||
transitionMode: transitionMode,
|
||||
);
|
||||
}
|
||||
|
||||
// 工作台模式判断
|
||||
final isWorkbench = WorkbenchNavHelper.isWorkbenchMode(context, this);
|
||||
final shouldRightPanel = WorkbenchNavHelper.shouldOpenInRightPanel(route);
|
||||
|
||||
if (isWorkbench && shouldRightPanel) {
|
||||
// 宽屏工作台模式:push 到右栏嵌套 Navigator
|
||||
final currentTab = read(splitViewProvider).currentTab;
|
||||
WorkbenchNavHelper.pushToRightPanel(
|
||||
this,
|
||||
currentTab,
|
||||
route,
|
||||
extra: extra,
|
||||
title: title,
|
||||
);
|
||||
return Future<T?>.value();
|
||||
}
|
||||
|
||||
// 窄屏或全屏路由:走 GoRouter push
|
||||
return context.push<T>(route, extra: extra);
|
||||
}
|
||||
|
||||
/// 工作台模式感知的 pop(WidgetRef 版本)
|
||||
///
|
||||
/// - 宽屏工作台模式 → pop 右栏嵌套栈
|
||||
/// - 窄屏或非工作台 → 走 Navigator.pop
|
||||
void appPop<T extends Object?>(BuildContext context, [T? result]) {
|
||||
if (pu.isOhos) {
|
||||
Navigator.pop(context, result);
|
||||
return;
|
||||
}
|
||||
|
||||
final isWorkbench = WorkbenchNavHelper.isWorkbenchMode(context, this);
|
||||
if (isWorkbench) {
|
||||
final currentTab = read(splitViewProvider).currentTab;
|
||||
read(rightPanelStackProvider.notifier).pop(currentTab);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.pop(context, result);
|
||||
}
|
||||
|
||||
/// 工作台模式感知的 canPop(WidgetRef 版本)
|
||||
bool appCanPop(BuildContext context) {
|
||||
if (pu.isOhos) {
|
||||
return Navigator.of(context).canPop();
|
||||
}
|
||||
|
||||
if (WorkbenchNavHelper.isWorkbenchMode(context, this)) {
|
||||
final currentTab = read(splitViewProvider).currentTab;
|
||||
final stackMap = read(rightPanelStackProvider);
|
||||
final stack = stackMap[currentTab];
|
||||
return stack != null && stack.entries.isNotEmpty;
|
||||
}
|
||||
|
||||
return Navigator.of(context).canPop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ class AppRoutes {
|
||||
static const String widgetManagement = '/widget-management';
|
||||
static const String themeSettings = '/settings/theme';
|
||||
static const String generalSettings = '/settings/general';
|
||||
static const String workbenchSettings = '/settings/workbench';
|
||||
static const String languageSettings = '/settings/language';
|
||||
static const String accountSettings = '/settings/account';
|
||||
static const String passwordSettings = '/settings/password';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 路由定义核心类型
|
||||
// 创建时间: 2026-06-18
|
||||
// 更新时间: 2026-06-09
|
||||
// 更新时间: 2026-06-18
|
||||
// 作用: RouteDef/RouteContext/RouteModule/RouteTransition 统一路由定义类型
|
||||
// 上次更新: RouteDef 新增 deepLinkAliases 字段,支持配置驱动深度链接映射
|
||||
// 上次更新: RouteDef 新增 fullScreen 字段,用于工作台模式判断是否全屏覆盖
|
||||
// ============================================================
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -59,6 +59,10 @@ class RouteDef {
|
||||
this.redirectTo,
|
||||
this.children = const [],
|
||||
this.deepLinkAliases = const [],
|
||||
/// 是否全屏覆盖(编辑器/图片预览等需要大空间的页面)
|
||||
/// 工作台模式下,fullScreen=true 的路由仍走 rootNavigator 全屏 push
|
||||
/// fullScreen=false 的路由在宽屏下进入右栏嵌套 Navigator
|
||||
this.fullScreen = false,
|
||||
});
|
||||
|
||||
final String path;
|
||||
@@ -78,6 +82,9 @@ class RouteDef {
|
||||
/// 解析器会自动从这些别名构建映射表
|
||||
final List<String> deepLinkAliases;
|
||||
|
||||
/// 是否全屏覆盖路由(工作台模式下仍走 rootNavigator)
|
||||
final bool fullScreen;
|
||||
|
||||
bool get isSimple => page != null && builder == null;
|
||||
bool get isRedirect => redirectTo != null;
|
||||
bool get hasChildren => children.isNotEmpty;
|
||||
|
||||
@@ -130,6 +130,7 @@ import 'package:xianyan/features/settings/presentation/plugin/plugin_page.dart';
|
||||
import 'package:xianyan/features/settings/presentation/plugin/translate_plugin_page.dart';
|
||||
import 'package:xianyan/features/settings/presentation/plugin/tts_plugin_page.dart';
|
||||
import 'package:xianyan/features/settings/presentation/experimental_features_page.dart';
|
||||
import 'package:xianyan/features/settings/presentation/workbench/workbench_settings_page.dart';
|
||||
|
||||
import 'package:xianyan/features/weather/presentation/weather_page.dart';
|
||||
import 'package:xianyan/features/weather/presentation/weather_settings_page.dart';
|
||||
@@ -232,7 +233,7 @@ final List<RouteDef> routeRegistry = [
|
||||
name: 'read-later',
|
||||
module: RouteModule.user,
|
||||
page: () => const ReadLaterPage(),
|
||||
deepLinkAliases: ['/tool/readlater'],
|
||||
deepLinkAliases: ['/tool/readlater', 'xianyan://readlater'],
|
||||
),
|
||||
RouteDef(
|
||||
path: AppRoutes.readlaterSettings,
|
||||
@@ -1048,6 +1049,13 @@ final List<RouteDef> routeRegistry = [
|
||||
module: RouteModule.settings,
|
||||
page: () => const ExperimentalFeaturesPage(),
|
||||
),
|
||||
RouteDef(
|
||||
path: AppRoutes.workbenchSettings,
|
||||
name: 'workbench-settings',
|
||||
module: RouteModule.settings,
|
||||
page: () => const WorkbenchSettingsPage(),
|
||||
deepLinkAliases: ['xianyan://settings/workbench'],
|
||||
),
|
||||
|
||||
// ============================================================
|
||||
// Feature module
|
||||
@@ -1125,6 +1133,7 @@ final List<RouteDef> routeRegistry = [
|
||||
transition: RouteTransition.heroine,
|
||||
builder: (ctx) => EditorPage(initialText: ctx.getQueryParam('text')),
|
||||
deepLinkAliases: ['xianyan://editor', 'https://s2ss.com/editor'],
|
||||
fullScreen: true,
|
||||
),
|
||||
RouteDef(
|
||||
path: AppRoutes.editorPreview,
|
||||
@@ -1140,6 +1149,7 @@ final List<RouteDef> routeRegistry = [
|
||||
);
|
||||
},
|
||||
ohosBuilder: (_) => const EditorSubPlaceholder(title: '图片预览'),
|
||||
fullScreen: true,
|
||||
),
|
||||
RouteDef(
|
||||
path: AppRoutes.editorCrop,
|
||||
@@ -1150,6 +1160,7 @@ final List<RouteDef> routeRegistry = [
|
||||
return ImageCropPage(imageBytes: extra?.bytes ?? Uint8List(0));
|
||||
},
|
||||
ohosBuilder: (_) => const EditorSubPlaceholder(title: '图片裁剪'),
|
||||
fullScreen: true,
|
||||
),
|
||||
RouteDef(
|
||||
path: AppRoutes.editorDrafts,
|
||||
@@ -1169,6 +1180,7 @@ final List<RouteDef> routeRegistry = [
|
||||
);
|
||||
},
|
||||
ohosBuilder: (_) => const EditorSubPlaceholder(title: '图片画廊'),
|
||||
fullScreen: true,
|
||||
),
|
||||
RouteDef(
|
||||
path: AppRoutes.editor3dPreview,
|
||||
@@ -1180,6 +1192,7 @@ final List<RouteDef> routeRegistry = [
|
||||
return Model3DPreviewPage(model: model);
|
||||
},
|
||||
ohosBuilder: (_) => const EditorSubPlaceholder(title: '3D模型预览'),
|
||||
fullScreen: true,
|
||||
),
|
||||
|
||||
// ============================================================
|
||||
@@ -1200,3 +1213,75 @@ final List<RouteDef> routeRegistry = [
|
||||
builder: (_) => const AnonymousSubmitPage(),
|
||||
),
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// 路由查询辅助方法 — 供工作台模式判断全屏路由
|
||||
// ============================================================
|
||||
|
||||
/// 根据路径查找 RouteDef
|
||||
///
|
||||
/// 支持带查询参数的路径(自动剥离 ?xxx=yyy 后再匹配),
|
||||
/// 支持带路径参数的路径(如 /chat_flow/:id 形式的路由按前缀匹配)。
|
||||
RouteDef? findRouteDef(String path) {
|
||||
// 剥离查询参数
|
||||
final queryIndex = path.indexOf('?');
|
||||
final cleanPath = queryIndex >= 0 ? path.substring(0, queryIndex) : path;
|
||||
|
||||
// 精确匹配
|
||||
for (final def in routeRegistry) {
|
||||
if (def.path == cleanPath) return def;
|
||||
// 递归查找子路由
|
||||
final child = _findInChildren(def.children, cleanPath);
|
||||
if (child != null) return child;
|
||||
}
|
||||
|
||||
// 带路径参数的路由按前缀匹配(如 /chat_flow/:id → /chat_flow/xxx)
|
||||
for (final def in routeRegistry) {
|
||||
if (_matchParamRoute(def.path, cleanPath)) return def;
|
||||
final child = _findInChildrenParam(def.children, cleanPath);
|
||||
if (child != null) return child;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
RouteDef? _findInChildren(List<RouteDef> children, String path) {
|
||||
for (final def in children) {
|
||||
if (def.path == path) return def;
|
||||
final child = _findInChildren(def.children, path);
|
||||
if (child != null) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
RouteDef? _findInChildrenParam(List<RouteDef> children, String path) {
|
||||
for (final def in children) {
|
||||
if (_matchParamRoute(def.path, path)) return def;
|
||||
final child = _findInChildrenParam(def.children, path);
|
||||
if (child != null) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 判断注册路由是否为带路径参数的路由,且 path 匹配该模式
|
||||
///
|
||||
/// 示例:pattern='/chat_flow/:id', path='/chat_flow/abc' → true
|
||||
bool _matchParamRoute(String pattern, String path) {
|
||||
if (!pattern.contains(':')) return false;
|
||||
final patternSegs = pattern.split('/');
|
||||
final pathSegs = path.split('/');
|
||||
if (patternSegs.length != pathSegs.length) return false;
|
||||
for (var i = 0; i < patternSegs.length; i++) {
|
||||
final p = patternSegs[i];
|
||||
final s = pathSegs[i];
|
||||
if (p.startsWith(':')) continue; // 路径参数段,匹配任意
|
||||
if (p != s) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 判断路由是否为全屏覆盖路由(工作台模式下仍走 rootNavigator)
|
||||
bool isFullScreenRoute(String path) {
|
||||
final def = findRouteDef(path);
|
||||
return def?.fullScreen ?? false;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1499,10 +1499,17 @@ class AppDatabase extends _$AppDatabase {
|
||||
}, 'setFavoriteFlag');
|
||||
}
|
||||
|
||||
/// 设置收藏状态(按targetType+targetId匹配,兼容复合ID和数字ID两种格式)
|
||||
/// 设置收藏状态(按targetType+targetId匹配,兼容多种ID格式)
|
||||
///
|
||||
/// sentences表中ID可能是复合格式(如"feed_123")或纯数字格式(如"123"),
|
||||
/// 此方法同时尝试两种格式,确保本地DB收藏状态正确更新。
|
||||
/// sentences表中ID可能是以下格式:
|
||||
/// - 复合格式:`feed_123`、`hitokoto_456`、`poetry_789` 等
|
||||
/// - 纯数字格式:`123`
|
||||
/// - 其他前缀格式:`prefix_123`
|
||||
///
|
||||
/// 此方法同时尝试多种匹配策略,确保本地DB收藏状态正确更新:
|
||||
/// 1. 精确匹配 `${targetType}_$targetId`
|
||||
/// 2. 精确匹配 `$targetId`
|
||||
/// 3. 按 feedType 字段 + ID 以 `_$targetId` 结尾匹配(覆盖各种前缀)
|
||||
Future<void> setFavoriteFlagForTarget(
|
||||
String targetType,
|
||||
int targetId,
|
||||
@@ -1513,14 +1520,22 @@ class AppDatabase extends _$AppDatabase {
|
||||
isFavorite: Value(value),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
);
|
||||
// 尝试复合ID(如"feed_123")
|
||||
// 策略1:复合ID(如"feed_123")
|
||||
await (update(sentences)
|
||||
..where((t) => t.id.equals('${targetType}_$targetId')))
|
||||
.write(companion);
|
||||
// 同时尝试纯数字ID(如"123",Hitokoto等来源)
|
||||
// 策略2:纯数字ID(如"123",Hitokoto等来源)
|
||||
await (update(sentences)
|
||||
..where((t) => t.id.equals(targetId.toString())))
|
||||
.write(companion);
|
||||
// 策略3:按 feedType 字段 + ID 以 "_$targetId" 结尾匹配
|
||||
// 覆盖各种前缀格式(如 hitokoto_456、poetry_789 等)
|
||||
final suffix = '_$targetId';
|
||||
await (update(sentences)
|
||||
..where((t) =>
|
||||
t.feedType.equals(targetType) &
|
||||
t.id.like('%$suffix')))
|
||||
.write(companion);
|
||||
}, 'setFavoriteFlagForTarget');
|
||||
}
|
||||
|
||||
@@ -1754,6 +1769,19 @@ class AppDatabase extends _$AppDatabase {
|
||||
}, label: 'getSentenceFavoriteCount');
|
||||
}
|
||||
|
||||
/// 获取自指定时间以来新增的收藏数(按updatedAt过滤)
|
||||
/// 用于阅读目标的今日收藏进度统计
|
||||
Future<int> getFavoriteCountSince(DateTime since) {
|
||||
return _safeDbInt(() async {
|
||||
final query = selectOnly(sentences)
|
||||
..addColumns([sentences.id.count()])
|
||||
..where(sentences.isFavorite.equals(true) &
|
||||
sentences.updatedAt.isBiggerOrEqualValue(since));
|
||||
final rows = await query.getSingle();
|
||||
return rows.read(sentences.id.count()) ?? 0;
|
||||
}, label: 'getFavoriteCountSince');
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getHistoryCountByFeedType() {
|
||||
return _safeDbMap<String, int>(() async {
|
||||
final rows =
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 统一 KV 本地存储
|
||||
/// 创建时间: 2026-04-20
|
||||
/// 更新时间: 2026-05-30
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 基于 Hive 的统一 KV 存储,合并原 KvStorage(SP) + AppKVStore(Hive)
|
||||
/// 上次更新: StorageKeys 新增命名空间前缀系统
|
||||
/// 上次更新: 类型安全修复(int vs num): 工具使用计数使用 SafeJson.parseInt
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xianyan/core/utils/safe_json.dart';
|
||||
|
||||
import '../utils/logger.dart';
|
||||
import 'hive_safe_access.dart';
|
||||
@@ -98,6 +99,8 @@ class StorageKeys {
|
||||
static const String debugMode = 'debug_mode';
|
||||
static const String onboardingCompleted = 'onboarding_completed';
|
||||
static const String showOnboarding = 'show_onboarding';
|
||||
static const String knowNewFeatures = 'know_new_features';
|
||||
static const String lastSeenVersion = 'last_seen_version';
|
||||
static const String channelOrder = 'channel_order';
|
||||
|
||||
// ============================================================
|
||||
@@ -129,6 +132,10 @@ class StorageKeys {
|
||||
// — settings 命名空间 —
|
||||
static String get nsSettingsOnboarding => namespaced(_nsSettings, 'onboarding_completed');
|
||||
static String get nsSettingsShowOnboarding => namespaced(_nsSettings, 'show_onboarding');
|
||||
static String get nsSettingsWorkbenchMiddleWidth => namespaced(_nsSettings, 'workbench_middle_width');
|
||||
static String get nsSettingsWorkbenchNavBarWidth => namespaced(_nsSettings, 'workbench_nav_bar_width');
|
||||
static String get nsSettingsNavBarCollapsed => namespaced(_nsSettings, 'nav_bar_collapsed');
|
||||
static String get nsSettingsTabSplitRatios => namespaced(_nsSettings, 'tab_split_ratios');
|
||||
|
||||
// — device 命名空间 —
|
||||
static String get nsDeviceId => namespaced(_nsDevice, 'device_id');
|
||||
@@ -404,7 +411,7 @@ class KvStorage {
|
||||
final key = toolId;
|
||||
if (stats.containsKey(key)) {
|
||||
final entry = Map<String, dynamic>.from(stats[key] as Map);
|
||||
entry['count'] = (entry['count'] as int? ?? 0) + 1;
|
||||
entry['count'] = SafeJson.parseInt(entry['count']) + 1;
|
||||
entry['lastUsed'] = DateTime.now().toIso8601String();
|
||||
stats[key] = entry;
|
||||
} else {
|
||||
@@ -543,6 +550,17 @@ class KvStorage {
|
||||
static Future<bool> setShowOnboarding(bool value) =>
|
||||
setBool(StorageKeys.showOnboarding, value);
|
||||
|
||||
/// 是否开启了"了解新版本功能"
|
||||
static bool get knowNewFeatures =>
|
||||
getBool(StorageKeys.knowNewFeatures) ?? false;
|
||||
|
||||
/// 上次查看新功能的版本号
|
||||
static String? get lastSeenVersion =>
|
||||
getString(StorageKeys.lastSeenVersion);
|
||||
|
||||
static Future<bool> setLastSeenVersion(String version) =>
|
||||
setString(StorageKeys.lastSeenVersion, version);
|
||||
|
||||
// ============================================================
|
||||
// 全局清理
|
||||
// ============================================================
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 剪贴板桥接工具(含隐私协议守卫)
|
||||
/// 创建时间: 2026-05-17
|
||||
/// 更新时间: 2026-06-17
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 作用: 统一剪贴板读取入口,鸿蒙平台通过原生MethodChannel读取,
|
||||
/// 其他平台使用Flutter Clipboard;未同意隐私协议时禁止读取;
|
||||
/// 鸿蒙端拦截Flutter标准Clipboard通道,修复系统粘贴不工作
|
||||
/// 上次更新: 修复鸿蒙端粘贴功能不工作 — 拦截SystemChannels.platform
|
||||
/// 的Clipboard方法,路由到鸿蒙原生pasteboard API
|
||||
/// 鸿蒙端拦截Flutter标准Clipboard通道,修复系统粘贴不工作;
|
||||
/// 桌面端(macOS/Windows)支持富文本(HTML)读写
|
||||
/// 上次更新: 新增富文本(HTML)支持 — 新增 setRichText/getHtml/hasHtml
|
||||
/// 方法,桌面端通过 apps.xy.xianyan/clipboard 通道写入HTML,
|
||||
/// 移动端/Web 降级为纯文本(去除HTML标签)
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -19,8 +21,14 @@ class ClipboardBridge {
|
||||
|
||||
static const _channel = MethodChannel('plugins.flutter.io/clipboard_ohos');
|
||||
|
||||
/// 桌面端原生剪贴板通道(macOS/Windows),用于富文本(HTML)读写
|
||||
static const _desktopChannel = MethodChannel('apps.xy.xianyan/clipboard');
|
||||
|
||||
static bool get _isOhos => pu.isOhos;
|
||||
|
||||
/// 是否为支持HTML富文本原生读写的桌面平台(macOS/Windows)
|
||||
static bool get _supportsRichTextNative => pu.isMacOS || pu.isWindows;
|
||||
|
||||
/// 是否已安装鸿蒙端标准剪贴板拦截器
|
||||
static bool _ohosInterceptorInstalled = false;
|
||||
|
||||
@@ -114,4 +122,166 @@ class ClipboardBridge {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return data?.text?.isNotEmpty ?? false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 富文本(HTML)支持
|
||||
// ============================================================
|
||||
|
||||
/// 写入富文本(HTML)到剪贴板
|
||||
///
|
||||
/// - 桌面端(macOS/Windows):通过原生 MethodChannel('apps.xy.xianyan/clipboard')
|
||||
/// 写入 HTML 格式,同时附带纯文本以便不支持HTML的应用读取
|
||||
/// - 鸿蒙端:通过原生 _channel 写入纯文本(鸿蒙不支持HTML剪贴板)
|
||||
/// - 移动端(iOS/Android)/Web:降级为纯文本,html 参数通过简单正则
|
||||
/// 去除标签后作为 text 写入
|
||||
///
|
||||
/// [text] 纯文本内容(可选,若未提供则从 [html] 提取)
|
||||
/// [html] HTML 富文本内容(可选)
|
||||
static Future<void> setRichText({String? text, String? html}) async {
|
||||
// 计算最终写入的纯文本(用于降级路径和作为原生通道的text参数)
|
||||
final plainText = text ?? _htmlToPlainText(html);
|
||||
|
||||
// 鸿蒙端:通过原生通道写入纯文本(鸿蒙不支持HTML剪贴板)
|
||||
if (_isOhos) {
|
||||
try {
|
||||
if (plainText != null && plainText.isNotEmpty) {
|
||||
await _channel.invokeMethod<void>(
|
||||
'Clipboard.setData',
|
||||
{'text': plainText},
|
||||
);
|
||||
}
|
||||
return;
|
||||
} on MissingPluginException {
|
||||
// 原生通道未注册,降级到Flutter标准Clipboard
|
||||
} on PlatformException {
|
||||
// 权限或其他错误,降级到Flutter标准Clipboard
|
||||
}
|
||||
}
|
||||
|
||||
// 桌面端(macOS/Windows):通过原生通道写入HTML富文本
|
||||
if (_supportsRichTextNative) {
|
||||
try {
|
||||
await _desktopChannel.invokeMethod<void>('setRichText', {
|
||||
'text': plainText ?? '',
|
||||
'html': html ?? '',
|
||||
});
|
||||
return;
|
||||
} on MissingPluginException {
|
||||
// 原生端未注册通道(主线程后续处理),降级为纯文本
|
||||
Log.w('ClipboardBridge: 桌面端富文本通道未注册,降级为纯文本写入');
|
||||
} on PlatformException catch (e) {
|
||||
Log.w('ClipboardBridge: 桌面端富文本写入失败: $e,降级为纯文本写入');
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端(iOS/Android)/Web/降级路径:仅写入纯文本
|
||||
if (plainText != null && plainText.isNotEmpty) {
|
||||
await Clipboard.setData(ClipboardData(text: plainText));
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取剪贴板 HTML 内容(仅桌面端有效)
|
||||
///
|
||||
/// - 桌面端(macOS/Windows):通过原生 MethodChannel('apps.xy.xianyan/clipboard')
|
||||
/// 读取 HTML 内容
|
||||
/// - 鸿蒙端/移动端(iOS/Android)/Web:不支持HTML读取,返回 null
|
||||
///
|
||||
/// 未同意隐私协议时返回 null
|
||||
static Future<String?> getHtml() async {
|
||||
if (!_agreementAccepted) {
|
||||
Log.w('ClipboardBridge: 隐私协议未同意,禁止读取剪贴板');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 鸿蒙端:不支持HTML读取
|
||||
if (_isOhos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 桌面端:通过原生通道读取HTML
|
||||
if (_supportsRichTextNative) {
|
||||
try {
|
||||
final result = await _desktopChannel.invokeMethod<String>('getHtml');
|
||||
return result;
|
||||
} on MissingPluginException {
|
||||
// 原生端未注册通道,无法读取HTML
|
||||
Log.w('ClipboardBridge: 桌面端富文本通道未注册,无法读取HTML');
|
||||
} on PlatformException catch (e) {
|
||||
Log.w('ClipboardBridge: 桌面端HTML读取失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端/Web:不支持HTML读取
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 检查剪贴板是否有 HTML 内容
|
||||
///
|
||||
/// - 桌面端(macOS/Windows):通过原生通道检查HTML是否存在
|
||||
/// - 鸿蒙端/移动端/Web:不支持HTML,返回 false
|
||||
///
|
||||
/// 未同意隐私协议时返回 false
|
||||
static Future<bool> hasHtml() async {
|
||||
if (!_agreementAccepted) {
|
||||
Log.w('ClipboardBridge: 隐私协议未同意,禁止检查剪贴板');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 鸿蒙端:不支持HTML
|
||||
if (_isOhos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 桌面端:通过原生通道检查
|
||||
if (_supportsRichTextNative) {
|
||||
try {
|
||||
final html = await _desktopChannel.invokeMethod<String>('getHtml');
|
||||
return html != null && html.isNotEmpty;
|
||||
} on MissingPluginException {
|
||||
// 原生端未注册通道
|
||||
} on PlatformException {
|
||||
// 读取失败
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 内部工具方法
|
||||
// ============================================================
|
||||
|
||||
/// 将 HTML 转换为纯文本(简单去除标签 + 常见实体反转义)
|
||||
///
|
||||
/// 用于移动端/Web 降级路径,将 html 参数转为可读纯文本。
|
||||
/// 注意:此为简单实现,不处理复杂HTML结构(如表格、列表缩进)。
|
||||
static String? _htmlToPlainText(String? html) {
|
||||
if (html == null || html.isEmpty) return null;
|
||||
|
||||
// 块级标签转换为换行
|
||||
var text = html
|
||||
.replaceAll(RegExp(r'(?i)<br\s*/?>'), '\n')
|
||||
.replaceAll(RegExp(r'(?i)</(p|div|h[1-6]|li|tr)>'), '\n')
|
||||
.replaceAll(RegExp(r'(?i)</(ul|ol)>'), '\n');
|
||||
|
||||
// 去除所有HTML标签
|
||||
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
|
||||
|
||||
// 反转义常见HTML实体
|
||||
text = text
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'");
|
||||
|
||||
// 压缩多余的空白和换行(保留单个换行)
|
||||
text = text.replaceAll(RegExp(r'[ \t]+'), ' ');
|
||||
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||||
text = text.trim();
|
||||
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user