feat: 新增工作台模式、系统托盘,修复多平台兼容性问题

1. 新增工作台三栏布局模式,适配宽屏设备
2. 添加跨平台系统托盘支持,新增托盘图标资源
3. 修复工作台模式下导航返回异常问题
4. 统一JSON类型安全解析,替换硬类型转换
5. 增加macOS深度链接支持,统一渠道分发信息
6. 优化部分页面生命周期和状态加载逻辑
7. 移除废弃的nearby_connections依赖
This commit is contained in:
Developer
2026-06-19 06:43:55 +08:00
parent 6a02a313b2
commit 83720002e6
194 changed files with 11716 additions and 3120 deletions

View File

@@ -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({

View File

@@ -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,
);

View File

@@ -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),
),
],
),
);
}
}

View File

@@ -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);
}
}

View File

@@ -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),
],
);
}
}

View 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;
}

View 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(),
};
}

View File

@@ -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? ?? '',
);

View File

@@ -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.xhitCacheOnErrorExcept→hitCacheOnNetworkFailureNullable<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),
);
}

View File

@@ -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);
}
// === 工作台交互增强 setterP0-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);
}
// === 侧边栏折叠 + 分屏记忆 setter2026-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);
}

View File

@@ -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)

View File

@@ -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);
}
/// 工作台模式感知的 popWidgetRef 版本)
///
/// - 宽屏工作台模式 → 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);
}
/// 工作台模式感知的 canPopWidgetRef 版本)
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();
}
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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] = {

View File

@@ -45,6 +45,12 @@ class BackgroundTaskService {
return;
}
if (pu.isMacOS) {
Log.i('BackgroundTaskService: macOS端 workmanager 不支持,跳过后台任务初始化');
_initialized = true;
return;
}
try {
await Workmanager().initialize(
callbackDispatcher,

View File

@@ -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 而非 runAppFunctionCatcher2 会在内部调用 runApp
// 但不会创建新的 Zone避免 Zone mismatch 警告

View File

@@ -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),

View 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;
}
}

View 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;
}
}

View 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)}';
}
}

View 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;
}

View 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';
}

View File

@@ -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,
);
// 设置材质为 sidebarmacOS 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';
}

View File

@@ -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 中处理
}
}

View File

@@ -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 AltWin11 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 AltWin11 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';
}
}

View File

@@ -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');
}
}
// ============================================================
// 内部工具
// ============================================================

View File

@@ -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';
}
}
// ============================================================
// 内部工具
// ============================================================

View File

@@ -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 处理需要特殊路由的 schemenote/{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;

View File

@@ -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;
}

View File

@@ -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 =

View File

@@ -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);
// ============================================================
// 全局清理
// ============================================================

View File

@@ -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('&nbsp;', ' ')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'");
// 压缩多余的空白和换行(保留单个换行)
text = text.replaceAll(RegExp(r'[ \t]+'), ' ');
text = text.replaceAll(RegExp(r'\n{3,}'), '\n\n');
text = text.trim();
return text.isEmpty ? null : text;
}
}