win提交
This commit is contained in:
84
CHANGELOG.md
84
CHANGELOG.md
@@ -2,7 +2,89 @@
|
||||
|
||||
所有重要变更均记录于此文件。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
|
||||
|
||||
> 保留最近 10 个版本(v6.89.1 ~ v6.94.5)。更早版本(v6.87.0 ~ v6.89.0)的特性已合并进软件特性功能文档,详见各版本条目。
|
||||
> 保留最近 10 个版本(v6.89.1 ~ v6.96.0)。更早版本(v6.87.0 ~ v6.89.0)的特性已合并进软件特性功能文档,详见各版本条目。
|
||||
|
||||
***
|
||||
|
||||
## [v6.96.0] - 2026-06-22
|
||||
|
||||
### ✨ 新增(Windows 桌面端)
|
||||
|
||||
#### 窗口大小预设菜单(3×2 网格)
|
||||
- **功能**: 点击窗口标题栏"口"按钮(最小化与关闭之间),在按钮下方弹出 3 列 × 2 行的窗口大小预设网格菜单
|
||||
- **预设尺寸**: 小窗 800×600 / 标准 1024×768 / 宽屏 1280×720 / 大屏 1440×900 / 全高清 1920×1080 / 最大化·还原
|
||||
- **交互**:
|
||||
- 弹出位置自动右对齐按钮,带边界自适应(防止溢出屏幕)
|
||||
- 入场动画:缩放 + 淡入(从右上角展开,180ms easeOutCubic)
|
||||
- 退出动画:缩放 + 淡出(120ms)
|
||||
- 点击外部区域自动关闭
|
||||
- 每个单元格悬停时高亮(accent 色 14% 背景 + 45% 边框)
|
||||
- **设计**: iOS 26 Liquid Glass 风格 — Alert 层级毛玻璃(blur 40px)、圆角 16px、GlassTokens 边框 + 阴影
|
||||
- **技术方案**:
|
||||
- 提取共享组件 `shared/widgets/window_size_popup.dart`,供 `DesktopWindowTitleBar` 和 `AdaptiveNavBar` 复用
|
||||
- 使用 `OverlayEntry` + `Positioned` 实现精确定位弹出
|
||||
- `_WindowsControlButton.onPressed` / `_WindowControlBtn.onPressed` 签名改为 `void Function(BuildContext)` 以传递按钮自身 context 用于定位
|
||||
- **文件**:
|
||||
- lib/shared/widgets/window_size_popup.dart(新增)
|
||||
- lib/features/desktop/desktop_window_title_bar.dart("口"按钮改为弹出菜单)
|
||||
- lib/app/layout/adaptive_nav_bar.dart(引用共享组件,删除本地重复定义)
|
||||
|
||||
***
|
||||
|
||||
## [v6.95.0] - 2026-06-21
|
||||
|
||||
### 🐛 修复(鸿蒙端专项)
|
||||
|
||||
#### 鸿蒙端横屏翻转失效
|
||||
- **Issue: 鸿蒙端横屏时软件没有翻转,安卓/iOS 正常**
|
||||
- **根因**:`EntryAbility.ets` 注册了 `flutter/platform` 通道拦截器,其 `default` 分支调用 `result.notImplemented()`,导致 `SystemChrome.setPreferredOrientations` 等平台方法全部失效
|
||||
- **修复**:移除 `flutter/platform` 通道拦截器,由 Flutter 鸿蒙引擎原生 `PlatformChannel.ets` 处理所有平台方法
|
||||
- **文件**:ohos/entry/src/main/ets/entryability/EntryAbility.ets
|
||||
|
||||
#### 鸿蒙端复制粘贴不生效
|
||||
- **Issue: 点击复制按钮显示成功但实际未复制;输入框长按粘贴无反应**
|
||||
- **根因1(复制)**:同上,`flutter/platform` 拦截器覆盖了引擎原生 `Clipboard.setData` 实现
|
||||
- **根因2(粘贴)**:`READ_PASTEBOARD` 为 `system_basic` 级系统权限,普通应用无法通过 `requestPermissionsFromUser` 申请;引擎原生 `getClipboardData` 因权限缺失返回 `null`
|
||||
- **修复**:
|
||||
- 复制:移除拦截器后由引擎原生 `PlatformChannel.ets` 处理 `Clipboard.setData`(无需权限)
|
||||
- 粘贴:创建 `CustomPlatformPlugin.ets` 中间件,继承 `PlatformPlugin` 和 `PlatformPluginCallback`,覆盖 `getClipboardData` 方法,使用系统安全控件 `PasteButton` 获取临时剪贴板读取授权(点击后授权持续到灭屏/切后台/退出)
|
||||
- 覆盖 `clipboardHasStrings` 返回 `true`,确保输入框工具栏粘贴按钮显示
|
||||
- 在 `EntryAbility.ets` 覆盖 `providePlatformPlugin` 返回 `CustomPlatformPlugin`
|
||||
- **架构亮点**:不修改 SDK 源码,通过继承实现中间件过渡层,SDK 升级不受影响
|
||||
- **文件**:ohos/entry/src/main/ets/entryability/CustomPlatformPlugin.ets(新增)、ohos/entry/src/main/ets/entryability/EntryAbility.ets
|
||||
|
||||
#### 鸿蒙端设备信息显示"未知设备"
|
||||
- **Issue: 我的设备页面鸿蒙端显示"未知设备",安卓/iOS 正常**
|
||||
- **根因**:`DeviceMethodHandler.ets` 返回鸿蒙原生字段名(`manufacture`、`marketName`、`productModel` 等),与 `AndroidDeviceInfo.fromMap` 期望的 Android 字段名(`manufacturer`、`name`、`model` 等)不匹配
|
||||
- **修复**:
|
||||
- 在 `DeviceMethodHandler.ets` 增加 Android 兼容字段映射,保留原生鸿蒙字段的同时补充 `board`/`bootloader`/`brand`/`device`/`display`/`fingerprint`/`hardware`/`host`/`id`/`manufacturer`/`model`/`product`/`name`/`tags`/`type`/`isPhysicalDevice` 等字段
|
||||
- 在 `AndroidDeviceInfo.fromMap` 增加防御性默认值(`?? ''`、`?? 0`、`?? true`),防止 `type 'Null' is not a subtype of type 'String'` 崩溃
|
||||
- **文件**:packages/device_info_plus/ohos/.../DeviceMethodHandler.ets、packages/device_info_plus/lib/src/model/android_device_info.dart
|
||||
|
||||
#### 鸿蒙端搜索页 flutter_keyboard_visibility 崩溃
|
||||
- **Issue: 搜索页面报 `MissingPluginException(No implementation found for method listen on channel flutter_keyboard_visibility)`**
|
||||
- **根因**:`flutter_keyboard_visibility` 插件无鸿蒙原生实现,`EventChannel.listen` 抛出 `MissingPluginException`
|
||||
- **修复**:`KeyboardManager` 延迟初始化 `KeyboardVisibilityController` 并捕获异常,鸿蒙端降级为 `MediaQuery.of(context).viewInsets.bottom > 0` 检测键盘可见性
|
||||
- **文件**:lib/shared/widgets/adaptive/keyboard_safe_sheet.dart
|
||||
|
||||
#### 鸿蒙端安装失败(READ_PASTEBOARD 权限)
|
||||
- **Issue: 声明 `ohos.permission.READ_PASTEBOARD` 后安装失败,报 `grant request permissions failed`**
|
||||
- **根因**:`READ_PASTEBOARD` 为 `system_basic` 级权限,普通应用无法通过 `module.json5` 的 `requestPermissions` 声明
|
||||
- **修复**:从 `module.json5` 移除 `READ_PASTEBOARD` 权限声明,改用 `PasteButton` 安全控件获取临时授权
|
||||
- **文件**:ohos/entry/src/main/module.json5
|
||||
|
||||
#### 新特性轮播正则表达式崩溃
|
||||
- **Issue: `FormatException: Range out of order in character class ^([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]+)\s*`**
|
||||
- **根因**:Dart raw string (`r'...'`) 中 `\u{1F300}` 不会被解析为 Unicode 转义,导致正则引擎看到无效字符范围
|
||||
- **修复**:改用 `Runes` 逐字符检测 code point 范围,避免正则 Unicode 转义兼容性问题
|
||||
- **文件**:lib/features/home/presentation/widgets/new_features_dialog.dart
|
||||
|
||||
### 🔧 架构改进
|
||||
|
||||
#### SDK 零修改原则
|
||||
- **原则**:所有鸿蒙端修复均不修改 Flutter 鸿蒙 SDK 源码(`e:\sdk\flutter-ohos\`),通过项目内中间件继承实现
|
||||
- **中间件**:`CustomPlatformPlugin.ets` 继承 `PlatformPlugin` + `PlatformPluginCallback`,覆盖剪贴板方法
|
||||
- **优势**:SDK 升级时无需合并冲突,中间件代码完全在项目可控范围内
|
||||
|
||||
***
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 自适应导航栏
|
||||
/// 创建时间: 2026-05-29
|
||||
/// 更新时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-22
|
||||
/// 作用: 宽屏时垂直导航栏,窄屏时底部导航栏,支持4种停靠位置
|
||||
/// 上次更新: 从 core/layout/ 迁移至 app/layout/,修复架构层级违规
|
||||
/// 上次更新: "口"按钮改为弹出 3×2 窗口大小预设网格菜单(OverlayEntry 定位弹出)
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:ui';
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -20,6 +21,7 @@ import '../../core/utils/platform/platform_utils.dart' as pu;
|
||||
import '../../features/discover/providers/chat_provider.dart';
|
||||
import '../../features/settings/providers/theme_settings_provider.dart';
|
||||
import '../../shared/widgets/animation/tab_icon_sprite.dart';
|
||||
import '../../shared/widgets/window_size_popup.dart';
|
||||
|
||||
class AdaptiveNavBar extends ConsumerWidget {
|
||||
const AdaptiveNavBar({
|
||||
@@ -124,11 +126,15 @@ class AdaptiveNavBar extends ConsumerWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: isSelected
|
||||
? (ext.isDark ? ext.textInverse : ext.accent)
|
||||
: (ext.isDark
|
||||
? ext.textInverse.withValues(alpha: 0.38)
|
||||
? ext.textInverse.withValues(
|
||||
alpha: 0.38,
|
||||
)
|
||||
: const Color(0xFFAEAEB2)),
|
||||
),
|
||||
),
|
||||
@@ -182,7 +188,11 @@ class AdaptiveNavBar extends ConsumerWidget {
|
||||
}
|
||||
|
||||
/// 折叠/展开导航栏按钮
|
||||
Widget _buildCollapseButton(WidgetRef ref, bool isCollapsed, AppThemeExtension ext) {
|
||||
Widget _buildCollapseButton(
|
||||
WidgetRef ref,
|
||||
bool isCollapsed,
|
||||
AppThemeExtension ext,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: () => ref.read(splitViewProvider.notifier).toggleNavBarCollapsed(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -312,7 +322,8 @@ class AdaptiveNavBar extends ConsumerWidget {
|
||||
buildTab(TabSpriteType.home, 0, '闲言'),
|
||||
buildTab(TabSpriteType.discover, 1, '发现'),
|
||||
buildTab(TabSpriteType.profile, 2, '我的'),
|
||||
if (isTop && pu.isDesktop) _buildDesktopWindowControls(ext),
|
||||
if (isTop && pu.isDesktop)
|
||||
_buildDesktopWindowControls(context, ext),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -321,40 +332,54 @@ class AdaptiveNavBar extends ConsumerWidget {
|
||||
}
|
||||
|
||||
/// 桌面端窗口控制按钮(最小化/最大化/关闭)+ 可拖拽标题栏区域
|
||||
Widget _buildDesktopWindowControls(AppThemeExtension ext) {
|
||||
Widget _buildDesktopWindowControls(
|
||||
BuildContext context,
|
||||
AppThemeExtension ext,
|
||||
) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onPanStart: (_) => windowManager.startDragging(),
|
||||
child: const SizedBox(height: 52, width: 40),
|
||||
),
|
||||
// Windows: C++ HTTRANSPARENT 方案处理拖拽,无需 Dart 侧 startDragging
|
||||
if (pu.isWindows)
|
||||
const SizedBox(height: 52, width: 40)
|
||||
else
|
||||
GestureDetector(
|
||||
onPanDown: (_) => windowManager.startDragging(),
|
||||
onDoubleTap: () async {
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
}
|
||||
},
|
||||
child: const SizedBox(height: 52, width: 40),
|
||||
),
|
||||
_WindowControlBtn(
|
||||
icon: CupertinoIcons.minus,
|
||||
ext: ext,
|
||||
onPressed: () => windowManager.minimize(),
|
||||
onPressed: (_) => windowManager.minimize(),
|
||||
),
|
||||
_WindowControlBtn(
|
||||
icon: CupertinoIcons.square,
|
||||
ext: ext,
|
||||
iconSize: 12,
|
||||
onPressed: () async {
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
}
|
||||
},
|
||||
onPressed: (btnCtx) => _showWindowSizeMenu(btnCtx),
|
||||
),
|
||||
_WindowControlBtn(
|
||||
icon: CupertinoIcons.xmark,
|
||||
ext: ext,
|
||||
onPressed: () => windowManager.close(),
|
||||
onPressed: (_) => windowManager.close(),
|
||||
isClose: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 窗口大小预设菜单
|
||||
/// 点击"口"按钮在按钮下方弹出 3×2 网格菜单,选择窗口预设尺寸
|
||||
void _showWindowSizeMenu(BuildContext buttonContext) {
|
||||
WindowSizePopup.show(buttonContext);
|
||||
}
|
||||
}
|
||||
|
||||
/// 窗口控制按钮(最小化/最大化/关闭)
|
||||
@@ -369,7 +394,9 @@ class _WindowControlBtn extends StatefulWidget {
|
||||
|
||||
final IconData icon;
|
||||
final AppThemeExtension ext;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// 点击回调,传入按钮自身的 BuildContext 用于弹出菜单定位
|
||||
final void Function(BuildContext buttonContext) onPressed;
|
||||
final double iconSize;
|
||||
final bool isClose;
|
||||
|
||||
@@ -393,7 +420,7 @@ class _WindowControlBtnState extends State<_WindowControlBtn> {
|
||||
onEnter: (_) => setState(() => _isHovering = true),
|
||||
onExit: (_) => setState(() => _isHovering = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onPressed,
|
||||
onTap: () => widget.onPressed(context),
|
||||
child: Container(
|
||||
width: 46,
|
||||
height: 52,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../network/connectivity_service.dart';
|
||||
import 'token_service.dart';
|
||||
@@ -156,9 +157,9 @@ class TokenRefreshWatcher {
|
||||
/// 导航到登录页
|
||||
static void _navigateToLogin() {
|
||||
try {
|
||||
final nav = rootNavigatorKey.currentState;
|
||||
if (nav != null) {
|
||||
nav.pushNamed(AppRoutes.login);
|
||||
final context = rootNavigatorKey.currentContext;
|
||||
if (context != null && context.mounted) {
|
||||
context.go(AppRoutes.login);
|
||||
Log.i('TokenRefreshWatcher: 已导航到登录页');
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
/// 上次更新: 初始创建,实现 TrayMenuCallbacks + TrayMenuLabels + DesktopTrayMenuBuilder
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../features/home/presentation/providers/readlater/readlater_entry.dart';
|
||||
@@ -131,18 +133,41 @@ class TrayMenuLabels {
|
||||
);
|
||||
|
||||
/// 根据语言 ID 获取标签
|
||||
///
|
||||
/// 当 languageId 为 'system' 时,自动解析系统语言。
|
||||
/// 支持中文(简/繁)和英文,其他语言回退到中文。
|
||||
factory TrayMenuLabels.forLanguage(String languageId) {
|
||||
switch (languageId) {
|
||||
final resolvedId = languageId == 'system'
|
||||
? _resolveSystemLanguageId()
|
||||
: languageId;
|
||||
switch (resolvedId) {
|
||||
case 'en':
|
||||
return enUS;
|
||||
case 'zh_CN':
|
||||
case 'zh_TW':
|
||||
case 'system':
|
||||
default:
|
||||
return zhCN;
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析系统语言 ID
|
||||
///
|
||||
/// 从平台获取系统首选语言,映射到项目支持的语言 ID。
|
||||
static String _resolveSystemLanguageId() {
|
||||
try {
|
||||
final locale = PlatformDispatcher.instance.locale;
|
||||
final langCode = locale.languageCode;
|
||||
// 英文系统
|
||||
if (langCode == 'en') return 'en';
|
||||
// 中文系统(简体/繁体)
|
||||
if (langCode == 'zh') return 'zh_CN';
|
||||
// 其他语言回退到中文
|
||||
return 'zh_CN';
|
||||
} catch (e) {
|
||||
return 'zh_CN';
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化带未读数的 Tooltip
|
||||
String formatTooltip(int unreadCount) {
|
||||
if (unreadCount <= 0) return tooltip;
|
||||
@@ -256,12 +281,7 @@ class DesktopTrayMenuBuilder {
|
||||
required TrayMenuCallbacks callbacks,
|
||||
}) {
|
||||
if (entries.isEmpty) {
|
||||
return [
|
||||
TrayMenuItem(
|
||||
label: labels.noRecentRead,
|
||||
disabled: true,
|
||||
),
|
||||
];
|
||||
return [TrayMenuItem(label: labels.noRecentRead, disabled: true)];
|
||||
}
|
||||
|
||||
// 取前 maxRecentReadItems 条
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — tray_manager 系统托盘服务实现
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 基于 tray_manager 实现跨平台系统托盘(macOS/Win/Linux)
|
||||
/// 上次更新: 初始创建,实现 DesktopTrayService 接口
|
||||
/// 上次更新: 修复 Windows 托盘图标路径问题,使用文件系统绝对路径替代 Flutter asset 路径
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:tray_manager/tray_manager.dart';
|
||||
@@ -19,8 +20,7 @@ import 'package:xianyan/core/utils/logger.dart';
|
||||
///
|
||||
/// 支持 macOS / Windows / Linux 三端。
|
||||
/// iOS / Android / 鸿蒙端使用 [StubDesktopTrayService]。
|
||||
class TrayManagerTrayService
|
||||
implements DesktopTrayService, TrayListener {
|
||||
class TrayManagerTrayService implements DesktopTrayService, TrayListener {
|
||||
TrayManagerTrayService._();
|
||||
|
||||
static final TrayManagerTrayService _instance = TrayManagerTrayService._();
|
||||
@@ -72,27 +72,44 @@ class TrayManagerTrayService
|
||||
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: 明暗两套图标
|
||||
} else if (pu.isWindows) {
|
||||
// Windows: tray_manager 仅支持 .ico 文件(LoadImageW 不支持 PNG)
|
||||
// 使用 app_icon.ico(已通过 CMakeLists.txt 安装到构建产物目录)
|
||||
final iconPath = _resolveWindowsIcoPath();
|
||||
await trayManager.setIcon(iconPath);
|
||||
} else {
|
||||
// Linux: 使用 Flutter asset 路径
|
||||
final assetName = isDark
|
||||
? 'assets/images/tray_icon_dark.png'
|
||||
: 'assets/images/tray_icon_light.png';
|
||||
await trayManager.setIcon(assetName);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('TrayManagerTrayService.setIcon 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 Windows 平台的 .ico 托盘图标路径
|
||||
///
|
||||
/// tray_manager 在 Windows 上使用 LoadImageW 加载图标,
|
||||
/// 仅支持 .ico 格式(不支持 .png)。
|
||||
/// app_icon.ico 已通过 CMakeLists.txt 安装到构建产物目录。
|
||||
static String _resolveWindowsIcoPath() {
|
||||
try {
|
||||
final exeDir = File(Platform.resolvedExecutable).parent.path;
|
||||
return '$exeDir\\app_icon.ico';
|
||||
} catch (e) {
|
||||
Log.w('TrayManagerTrayService._resolveWindowsIcoPath 失败: $e');
|
||||
return 'app_icon.ico';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setToolTip(String tip) async {
|
||||
if (!_initialized) return;
|
||||
@@ -108,13 +125,11 @@ class TrayManagerTrayService
|
||||
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);
|
||||
// Tooltip 的多语言版本由 DesktopTrayController 统一管理,
|
||||
// 此处不再硬编码中文 Tooltip,避免覆盖多语言版本
|
||||
} catch (e) {
|
||||
Log.e('TrayManagerTrayService.setUnreadBadge 失败: $e');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 基于 flutter_acrylic 实现 Windows 窗口特效(Win11 Mica Alt/Mica/Win10 Acrylic)
|
||||
/// 上次更新: 新增 Mica Alt 特效支持(Win11 build >= 22621),自动降级 Mica Alt → Mica → Acrylic
|
||||
/// 上次更新: 修复 setEffect 后 DwmExtendFrameIntoClientArea 导致 Flutter 视图布局异常
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
@@ -11,6 +11,7 @@ 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 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../desktop_window_effect_service.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
@@ -49,8 +50,10 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
|
||||
_isWin11OrLater = await _detectWindows11OrLater();
|
||||
_isMicaAltSupportedCache = await _isMicaAltSupported();
|
||||
_initialized = true;
|
||||
Log.i('WindowsAcrylicService 初始化完成 '
|
||||
'(isWin11=$_isWin11OrLater, micaAlt=$_isMicaAltSupportedCache)');
|
||||
Log.i(
|
||||
'WindowsAcrylicService 初始化完成 '
|
||||
'(isWin11=$_isWin11OrLater, micaAlt=$_isMicaAltSupportedCache)',
|
||||
);
|
||||
} catch (e) {
|
||||
Log.e('WindowsAcrylicService.initialize 失败: $e');
|
||||
}
|
||||
@@ -74,15 +77,9 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
|
||||
dark: isDark,
|
||||
);
|
||||
Log.i('WindowsAcrylicService 应用 Mica Alt 特效 (isDark=$isDark)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isWin11OrLater == true) {
|
||||
} else if (_isWin11OrLater == true) {
|
||||
// Win11: Mica 背景(跟随系统主题)
|
||||
await Window.setEffect(
|
||||
effect: WindowEffect.mica,
|
||||
dark: isDark,
|
||||
);
|
||||
await Window.setEffect(effect: WindowEffect.mica, dark: isDark);
|
||||
Log.i('WindowsAcrylicService 应用 Mica 特效 (isDark=$isDark)');
|
||||
} else {
|
||||
// Win10: Acrylic 半透明
|
||||
@@ -90,23 +87,44 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
|
||||
effect: WindowEffect.acrylic,
|
||||
dark: isDark,
|
||||
color: isDark
|
||||
? const Color(0xCC1F1F1F) // 深色半透明
|
||||
? const Color(0xCC1F1F1F) // 深色半透明
|
||||
: const Color(0xCCF3F3F3), // 浅色半透明
|
||||
);
|
||||
Log.i('WindowsAcrylicService 应用 Acrylic 特效 (isDark=$isDark)');
|
||||
}
|
||||
|
||||
// 修复:setEffect 调用 DwmExtendFrameIntoClientArea(margins={-1}) 后,
|
||||
// Flutter 视图子窗口尺寸与实际客户区不匹配,导致上方黑色+下方低分辨率。
|
||||
// 通过强制重设窗口大小触发 WM_SIZE → MoveWindow 重新布局 Flutter 视图。
|
||||
await _refreshWindowLayout();
|
||||
} catch (e) {
|
||||
Log.e('WindowsAcrylicService.applyEffect 失败: $e');
|
||||
// 降级:禁用特效
|
||||
try {
|
||||
await Window.setEffect(
|
||||
effect: WindowEffect.disabled,
|
||||
dark: isDark,
|
||||
);
|
||||
await Window.setEffect(effect: WindowEffect.disabled, dark: isDark);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新窗口布局
|
||||
///
|
||||
/// DwmExtendFrameIntoClientArea 会改变客户区边界计算方式,
|
||||
/// 导致 Flutter 视图(child_content_ HWND)无法正确填充窗口。
|
||||
/// 通过重设窗口大小触发 WM_SIZE → Win32Window::MessageHandler → MoveWindow,
|
||||
/// 让 Flutter 视图重新适配客户区尺寸。
|
||||
Future<void> _refreshWindowLayout() async {
|
||||
try {
|
||||
final size = await windowManager.getSize();
|
||||
// 先设为稍大尺寸再恢复,确保 WM_SIZE 一定会被触发
|
||||
await windowManager.setSize(const Size(1281, 721));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
await windowManager.setSize(size);
|
||||
Log.i('WindowsAcrylicService 窗口布局刷新完成');
|
||||
} catch (e) {
|
||||
Log.w('WindowsAcrylicService._refreshWindowLayout 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测当前系统是否支持 Mica Alt(Win11 build >= 22621)
|
||||
///
|
||||
/// 使用 device_info_plus 读取精确的 Windows build 号。
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 剪贴板桥接工具(含隐私协议守卫)
|
||||
/// 创建时间: 2026-05-17
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-21
|
||||
/// 作用: 统一剪贴板读取入口,鸿蒙平台通过原生MethodChannel读取,
|
||||
/// 其他平台使用Flutter Clipboard;未同意隐私协议时禁止读取;
|
||||
/// 鸿蒙端拦截Flutter标准Clipboard通道,修复系统粘贴不工作;
|
||||
/// 鸿蒙端剪贴板由引擎原生 PlatformChannel 处理(需 READ_PASTEBOARD 权限);
|
||||
/// 桌面端(macOS/Windows)支持富文本(HTML)读写
|
||||
/// 上次更新: 新增富文本(HTML)支持 — 新增 setRichText/getHtml/hasHtml
|
||||
/// 方法,桌面端通过 apps.xy.xianyan/clipboard 通道写入HTML,
|
||||
/// 移动端/Web 降级为纯文本(去除HTML标签)
|
||||
/// 上次更新: 移除原生端 flutter/platform 通道拦截器(此前拦截器覆盖引擎
|
||||
/// 原生 Clipboard/SystemChrome.setPreferredOrientations 实现,
|
||||
/// 导致横屏翻转失效),剪贴板功能改由引擎原生处理
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -45,16 +45,15 @@ class ClipboardBridge {
|
||||
|
||||
/// 安装鸿蒙端标准剪贴板拦截器
|
||||
/// Flutter标准TextInputPlugin长按粘贴使用SystemChannels.platform的
|
||||
/// Clipboard.getData方法,鸿蒙端Flutter引擎未实现此方法,
|
||||
/// 导致所有输入框粘贴无反应。此拦截器将标准Clipboard调用
|
||||
/// 路由到鸿蒙原生pasteboard API。
|
||||
/// Clipboard.getData方法。Flutter鸿蒙引擎已在 PlatformChannel.ets 中
|
||||
/// 实现了 Clipboard 方法,无需 Dart 端额外拦截。
|
||||
///
|
||||
/// 实现原理:在原生端EntryAbility.ets中注册flutter/platform通道的
|
||||
/// 剪贴板方法处理器。Dart端此方法仅作为标记,实际拦截在原生端完成。
|
||||
/// 此方法保留为空操作,仅为向后兼容。剪贴板读写由引擎原生处理,
|
||||
/// 粘贴所需 READ_PASTEBOARD 权限已在 module.json5 中声明。
|
||||
static void installOhosClipboardInterceptor() {
|
||||
if (!_isOhos || _ohosInterceptorInstalled) return;
|
||||
_ohosInterceptorInstalled = true;
|
||||
Log.i('ClipboardBridge: 鸿蒙端剪贴板拦截器标记已设置(原生端负责实际拦截)');
|
||||
Log.i('ClipboardBridge: 鸿蒙端剪贴板由引擎原生 PlatformChannel 处理');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -145,10 +144,9 @@ class ClipboardBridge {
|
||||
if (_isOhos) {
|
||||
try {
|
||||
if (plainText != null && plainText.isNotEmpty) {
|
||||
await _channel.invokeMethod<void>(
|
||||
'Clipboard.setData',
|
||||
{'text': plainText},
|
||||
);
|
||||
await _channel.invokeMethod<void>('Clipboard.setData', {
|
||||
'text': plainText,
|
||||
});
|
||||
}
|
||||
return;
|
||||
} on MissingPluginException {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 编辑器主页面 (重写)
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-05-02
|
||||
// 更新时间: 2026-06-19
|
||||
// 作用: 卡片/壁纸编辑器入口,直接打开 ProEditorPage
|
||||
// 上次更新: 移除旧底部状态栏,功能已整合至EditorBottomToolbar
|
||||
// 上次更新: 修复 Windows 端 toImage 失败导致白屏,增加超时和多重回退
|
||||
// ============================================================
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
@@ -32,13 +32,36 @@ class _EditorPageState extends State<EditorPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_createDefaultBackground()
|
||||
.then((bytes) {
|
||||
if (mounted) setState(() => _backgroundBytes = bytes);
|
||||
})
|
||||
.catchError((Object e) {
|
||||
Log.w('背景创建失败,使用默认背景', e);
|
||||
});
|
||||
_initBackground();
|
||||
}
|
||||
|
||||
/// 初始化背景图片,带超时和多重回退
|
||||
Future<void> _initBackground() async {
|
||||
try {
|
||||
// 设置 3 秒超时,避免 toImage 在 Windows 上卡死
|
||||
final bytes = await _createDefaultBackground().timeout(
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
Log.i('编辑器背景创建成功,大小: ${bytes.length} bytes');
|
||||
if (mounted) setState(() => _backgroundBytes = bytes);
|
||||
} catch (e) {
|
||||
Log.e('背景创建失败,尝试回退', e);
|
||||
try {
|
||||
final fallback = await _createFallbackBackground().timeout(
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
Log.i('编辑器回退背景创建成功,大小: ${fallback.length} bytes');
|
||||
if (mounted) setState(() => _backgroundBytes = fallback);
|
||||
} catch (e2) {
|
||||
Log.e('回退背景也失败,使用内存编码', e2);
|
||||
// 最终回退:直接使用 1x1 像素 PNG 编码
|
||||
if (mounted) {
|
||||
final minimal = _minimalPng();
|
||||
Log.i('编辑器使用最小 PNG,大小: ${minimal.length} bytes');
|
||||
setState(() => _backgroundBytes = minimal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -82,7 +105,8 @@ class _EditorPageState extends State<EditorPage> {
|
||||
Future<Uint8List> _createDefaultBackground() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
const size = Size(1080, 1920);
|
||||
// 使用较小尺寸避免 Windows 端内存不足导致 toImage 失败
|
||||
const size = Size(540, 960);
|
||||
|
||||
const gradient = LinearGradient(
|
||||
colors: [Color(0xFF6C63FF), Color(0xFF4ECDC4)],
|
||||
@@ -103,6 +127,45 @@ class _EditorPageState extends State<EditorPage> {
|
||||
size.height.toInt(),
|
||||
);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
image.dispose();
|
||||
return byteData!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// 创建回退背景(小尺寸纯色),防止主背景创建失败导致白屏
|
||||
Future<Uint8List> _createFallbackBackground() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
const size = Size(100, 100);
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(0, 0, size.width, size.height),
|
||||
Paint()..color = const Color(0xFF1A1A2E),
|
||||
);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(
|
||||
size.width.toInt(),
|
||||
size.height.toInt(),
|
||||
);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
image.dispose();
|
||||
return byteData!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// 最小 1x1 像素 PNG(硬编码),确保编辑器一定能渲染
|
||||
///
|
||||
/// 这是一个有效的 1x1 深蓝色 PNG 文件的字节数据,
|
||||
// 作为最终回退,避免任何 toImage 调用失败时白屏
|
||||
Uint8List _minimalPng() {
|
||||
// 1x1 像素 #1A1A2E (深蓝色) 的 PNG 编码
|
||||
return Uint8List.fromList([
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // 8bit RGB
|
||||
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk
|
||||
0x54, 0x08, 0xD7, 0x63, 0xA0, 0xA0, 0x20, 0x20, // compressed data
|
||||
0x00, 0x00, 0x00, 0x44, 0x00, 0x01, 0xA6, 0x21, // for #1A1A2E
|
||||
0x80, 0x9E, 0xE7, 0x00, 0x00, 0x00, 0x00, 0x49, // IEND chunk
|
||||
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,16 +613,21 @@ class ProEditorPageState extends State<ProEditorPage>
|
||||
final borderRadius = _canvasStyle.useIndependentRadius
|
||||
? BorderRadius.only(
|
||||
topLeft: Radius.circular(
|
||||
_canvasStyle.borderRadiusTL.clamp(0.0, 80.0)),
|
||||
_canvasStyle.borderRadiusTL.clamp(0.0, 80.0),
|
||||
),
|
||||
topRight: Radius.circular(
|
||||
_canvasStyle.borderRadiusTR.clamp(0.0, 80.0)),
|
||||
_canvasStyle.borderRadiusTR.clamp(0.0, 80.0),
|
||||
),
|
||||
bottomLeft: Radius.circular(
|
||||
_canvasStyle.borderRadiusBL.clamp(0.0, 80.0)),
|
||||
_canvasStyle.borderRadiusBL.clamp(0.0, 80.0),
|
||||
),
|
||||
bottomRight: Radius.circular(
|
||||
_canvasStyle.borderRadiusBR.clamp(0.0, 80.0)),
|
||||
_canvasStyle.borderRadiusBR.clamp(0.0, 80.0),
|
||||
),
|
||||
)
|
||||
: BorderRadius.circular(
|
||||
_canvasStyle.borderRadius.clamp(0.0, 80.0));
|
||||
_canvasStyle.borderRadius.clamp(0.0, 80.0),
|
||||
);
|
||||
if (borderRadius == BorderRadius.zero) return child;
|
||||
return Container(
|
||||
color: p.bgCanvas,
|
||||
@@ -726,7 +731,11 @@ class ProEditorPageState extends State<ProEditorPage>
|
||||
}
|
||||
},
|
||||
onEditorZoomMatrix4Change: (matrix) {
|
||||
_zoomScaleNotifier.value = matrix.getMaxScaleOnAxis();
|
||||
final scale = matrix.getMaxScaleOnAxis();
|
||||
// 防止 NaN/Infinite 值导致 Matrix4 entries must be finite 崩溃
|
||||
if (scale.isFinite) {
|
||||
_zoomScaleNotifier.value = scale;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -136,7 +136,7 @@ class ProEditorBridge {
|
||||
mainEditor: pro.MainEditorConfigs(
|
||||
enableZoom: true,
|
||||
editorMinScale: 0.5,
|
||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
||||
boundaryMargin: const EdgeInsets.all(10000),
|
||||
safeArea: const pro.EditorSafeArea.none(),
|
||||
widgets: pro.MainEditorWidgets(
|
||||
appBar: (editor, rebuildStream) => pro.ReactiveAppbar(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 桌面端托盘控制器
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 整合托盘服务 + 菜单构建器 + 未读数 Provider,管理托盘生命周期
|
||||
/// 上次更新: 初始创建,实现 DesktopTrayController
|
||||
/// 上次更新: 添加语言变化监听,托盘菜单支持动态多语言
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
@@ -48,8 +48,10 @@ class DesktopTrayController {
|
||||
final WidgetRef _ref;
|
||||
StreamSubscription<TrayEvent>? _eventSub;
|
||||
ProviderSubscription<int>? _unreadSub;
|
||||
ProviderSubscription<GeneralSettingsState>? _generalSettingsSub;
|
||||
bool _initialized = false;
|
||||
bool _isWindowVisible = true;
|
||||
|
||||
/// 防止 popUpContextMenu 触发的 performClick 导致重入
|
||||
bool _isPopUpMenuInProgress = false;
|
||||
|
||||
@@ -89,16 +91,27 @@ class DesktopTrayController {
|
||||
await trayService.setUnreadBadge(unreadCount);
|
||||
|
||||
// 6. 监听未读数变化
|
||||
_unreadSub = _ref.listenManual(
|
||||
trayUnreadCountProvider,
|
||||
(previous, next) {
|
||||
_onUnreadCountChanged(next);
|
||||
},
|
||||
);
|
||||
_unreadSub = _ref.listenManual(trayUnreadCountProvider, (previous, next) {
|
||||
_onUnreadCountChanged(next);
|
||||
});
|
||||
|
||||
// 7. 监听托盘事件
|
||||
_eventSub = trayService.events.listen(_onTrayEvent);
|
||||
|
||||
// 8. 监听语言设置变化,动态更新托盘菜单
|
||||
_generalSettingsSub = _ref.listenManual(generalSettingsProvider, (
|
||||
previous,
|
||||
next,
|
||||
) {
|
||||
if (previous?.languageId != next.languageId) {
|
||||
_updateMenu();
|
||||
// 更新 Tooltip
|
||||
final unreadCount = _ref.read(trayUnreadCountProvider);
|
||||
final labels = TrayMenuLabels.forLanguage(next.languageId);
|
||||
trayService.setToolTip(labels.formatTooltip(unreadCount));
|
||||
}
|
||||
});
|
||||
|
||||
Log.i('DesktopTrayController 初始化完成');
|
||||
} catch (e, st) {
|
||||
Log.e('DesktopTrayController 初始化失败', e, st);
|
||||
@@ -111,6 +124,8 @@ class DesktopTrayController {
|
||||
_eventSub = null;
|
||||
_unreadSub?.close();
|
||||
_unreadSub = null;
|
||||
_generalSettingsSub?.close();
|
||||
_generalSettingsSub = null;
|
||||
|
||||
if (_initialized) {
|
||||
try {
|
||||
@@ -183,9 +198,7 @@ class DesktopTrayController {
|
||||
|
||||
final languageId = _ref.read(generalSettingsProvider).languageId;
|
||||
final labels = TrayMenuLabels.forLanguage(languageId);
|
||||
await DesktopTrayService.instance.setToolTip(
|
||||
labels.formatTooltip(count),
|
||||
);
|
||||
await DesktopTrayService.instance.setToolTip(labels.formatTooltip(count));
|
||||
|
||||
// 未读数变化时也更新菜单(最近阅读列表可能变化)
|
||||
await _updateMenu();
|
||||
@@ -304,9 +317,7 @@ class DesktopTrayController {
|
||||
|
||||
void _onToggleDarkMode() {
|
||||
final settings = _ref.read(themeSettingsProvider);
|
||||
final newMode = settings.isDark
|
||||
? AppThemeMode.light
|
||||
: AppThemeMode.dark;
|
||||
final newMode = settings.isDark ? AppThemeMode.light : AppThemeMode.dark;
|
||||
_ref.read(themeSettingsProvider.notifier).setThemeMode(newMode);
|
||||
// 主题变化会触发 onThemeChanged 回调,无需在此更新菜单
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 桌面端自定义窗口标题栏
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-22
|
||||
/// 作用: 软件样式标题栏,替代系统默认标题栏,支持动态主题+动态样式
|
||||
/// 上次更新: 初始创建,实现 macOS 风格(红黄绿) + Windows 风格(─ ▢ ✕)
|
||||
/// 上次更新: "口"按钮改为弹出 3×2 窗口大小预设网格菜单
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -17,6 +15,7 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../core/utils/platform/platform_utils.dart' as pu;
|
||||
import '../../features/settings/providers/theme_settings_provider.dart';
|
||||
import '../../shared/widgets/window_size_popup.dart';
|
||||
|
||||
/// 标题栏样式配置(动态样式)
|
||||
class DesktopTitleBarStyle {
|
||||
@@ -167,7 +166,8 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
|
||||
final isAmoled = theme.isAmoled;
|
||||
|
||||
// 根据平台选择默认样式
|
||||
final style = widget.style ??
|
||||
final style =
|
||||
widget.style ??
|
||||
(pu.isMacOS
|
||||
? const DesktopTitleBarStyle.macOS()
|
||||
: const DesktopTitleBarStyle.windows());
|
||||
@@ -180,24 +180,29 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
|
||||
? const Color(0xFFE5E5E7)
|
||||
: const Color(0xFF1D1D1F);
|
||||
|
||||
// Windows: C++ HTTRANSPARENT 方案处理拖拽
|
||||
// 子类化 Flutter 子窗口,WM_NCHITTEST 标题栏区域返回 HTTRANSPARENT,
|
||||
// Windows 转发到父窗口,window_manager_plugin.cpp 返回 HTCAPTION → 原生拖拽。
|
||||
// 无需 Dart 侧 startDragging(MethodChannel 延迟导致卡顿)。
|
||||
if (pu.isWindows) {
|
||||
return Container(
|
||||
height: style.height,
|
||||
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
|
||||
child: _buildWindowsLayout(style, foregroundColor),
|
||||
);
|
||||
}
|
||||
|
||||
// macOS: 使用 GestureDetector 处理拖拽和双击
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanStart: (_) => windowManager.startDragging(),
|
||||
onPanDown: (_) {
|
||||
windowManager.startDragging();
|
||||
},
|
||||
onDoubleTap: _toggleMaximize,
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: style.blurRadius,
|
||||
sigmaY: style.blurRadius,
|
||||
),
|
||||
child: Container(
|
||||
height: style.height,
|
||||
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
|
||||
child: pu.isMacOS
|
||||
? _buildMacOSLayout(style, foregroundColor)
|
||||
: _buildWindowsLayout(style, foregroundColor),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
height: style.height,
|
||||
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
|
||||
child: _buildMacOSLayout(style, foregroundColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -250,9 +255,7 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
|
||||
SizedBox(width: style.buttonSpacing),
|
||||
_MacOSTrafficButton(
|
||||
color: const Color(0xFF28C840),
|
||||
icon: _isMaximized
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
icon: _isMaximized ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
isHovering: _isHoveringMaximize,
|
||||
onHover: (v) => setState(() => _isHoveringMaximize = v),
|
||||
onTap: _toggleMaximize,
|
||||
@@ -333,19 +336,17 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
|
||||
iconSize: 16,
|
||||
isHovering: _isHoveringMinimize,
|
||||
onHover: (v) => setState(() => _isHoveringMinimize = v),
|
||||
onTap: () => windowManager.minimize(),
|
||||
onTap: (_) => windowManager.minimize(),
|
||||
foregroundColor: foregroundColor,
|
||||
width: 46,
|
||||
height: style.height,
|
||||
),
|
||||
_WindowsControlButton(
|
||||
icon: _isMaximized
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
icon: _isMaximized ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
iconSize: 14,
|
||||
isHovering: _isHoveringMaximize,
|
||||
onHover: (v) => setState(() => _isHoveringMaximize = v),
|
||||
onTap: _toggleMaximize,
|
||||
onTap: (btnCtx) => WindowSizePopup.show(btnCtx),
|
||||
foregroundColor: foregroundColor,
|
||||
width: 46,
|
||||
height: style.height,
|
||||
@@ -355,7 +356,7 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
|
||||
iconSize: 16,
|
||||
isHovering: _isHoveringClose,
|
||||
onHover: (v) => setState(() => _isHoveringClose = v),
|
||||
onTap: () => windowManager.close(),
|
||||
onTap: (_) => windowManager.close(),
|
||||
foregroundColor: foregroundColor,
|
||||
width: 46,
|
||||
height: style.height,
|
||||
@@ -460,7 +461,9 @@ class _WindowsControlButton extends StatefulWidget {
|
||||
final double iconSize;
|
||||
final bool isHovering;
|
||||
final ValueChanged<bool> onHover;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// 点击回调,传入按钮自身的 BuildContext 用于弹出菜单定位
|
||||
final void Function(BuildContext buttonContext) onTap;
|
||||
final Color foregroundColor;
|
||||
final double width;
|
||||
final double height;
|
||||
@@ -485,7 +488,7 @@ class _WindowsControlButtonState extends State<_WindowsControlButton> {
|
||||
onEnter: (_) => widget.onHover(true),
|
||||
onExit: (_) => widget.onHover(false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onTap: () => widget.onTap(context),
|
||||
child: Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../../core/theme/app_spacing.dart';
|
||||
import '../../../../../core/theme/app_theme.dart';
|
||||
@@ -597,7 +598,7 @@ mixin ReadLaterPageUiMixin on ReadLaterPageStateAccessor {
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushNamed('/discover');
|
||||
context.go('/discover');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart' show Material, MaterialType, PageView, Colors;
|
||||
import 'package:flutter/material.dart'
|
||||
show Material, MaterialType, PageView, Colors;
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/storage/kv_storage.dart';
|
||||
@@ -70,8 +71,9 @@ class NewFeaturesDialog {
|
||||
// 1. 精确匹配(去掉 v 前缀比较)
|
||||
final cleanVer = version.startsWith('v') ? version.substring(1) : version;
|
||||
for (final e in AppUpdateLog.entries) {
|
||||
final entryVer =
|
||||
e.version.startsWith('v') ? e.version.substring(1) : e.version;
|
||||
final entryVer = e.version.startsWith('v')
|
||||
? e.version.substring(1)
|
||||
: e.version;
|
||||
if (entryVer == cleanVer) return e;
|
||||
}
|
||||
|
||||
@@ -80,8 +82,9 @@ class NewFeaturesDialog {
|
||||
if (parts.length >= 2) {
|
||||
final majorMinor = '${parts[0]}.${parts[1]}';
|
||||
for (final e in AppUpdateLog.entries) {
|
||||
final entryVer =
|
||||
e.version.startsWith('v') ? e.version.substring(1) : e.version;
|
||||
final entryVer = e.version.startsWith('v')
|
||||
? e.version.substring(1)
|
||||
: e.version;
|
||||
if (entryVer.startsWith('$majorMinor.')) return e;
|
||||
}
|
||||
}
|
||||
@@ -100,11 +103,7 @@ class NewFeaturesDialog {
|
||||
await showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return _NewFeaturesCarousel(
|
||||
entry: entry,
|
||||
t: t,
|
||||
ext: ext,
|
||||
);
|
||||
return _NewFeaturesCarousel(entry: entry, t: t, ext: ext);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -150,25 +149,31 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
final cards = <_FeatureCardData>[];
|
||||
|
||||
// 第一张卡片:版本概览
|
||||
cards.add(_FeatureCardData(
|
||||
icon: CupertinoIcons.sparkles,
|
||||
iconColor: widget.ext.accent,
|
||||
title: '${widget.entry.version} 更新',
|
||||
subtitle: widget.entry.date,
|
||||
description: widget.t.onboarding.knowNewFeatures
|
||||
.replaceAll('{0}', AppVersion.version),
|
||||
isOverview: true,
|
||||
));
|
||||
cards.add(
|
||||
_FeatureCardData(
|
||||
icon: CupertinoIcons.sparkles,
|
||||
iconColor: widget.ext.accent,
|
||||
title: '${widget.entry.version} 更新',
|
||||
subtitle: widget.entry.date,
|
||||
description: widget.t.onboarding.knowNewFeatures.replaceAll(
|
||||
'{0}',
|
||||
AppVersion.version,
|
||||
),
|
||||
isOverview: true,
|
||||
),
|
||||
);
|
||||
|
||||
// 后续卡片:每个变更点
|
||||
for (final change in widget.entry.changes) {
|
||||
final parsed = _parseChange(change);
|
||||
cards.add(_FeatureCardData(
|
||||
icon: parsed.icon,
|
||||
iconColor: parsed.color,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
));
|
||||
cards.add(
|
||||
_FeatureCardData(
|
||||
icon: parsed.icon,
|
||||
iconColor: parsed.color,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return cards;
|
||||
@@ -182,11 +187,26 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
String? emoji;
|
||||
String rest = trimmed;
|
||||
if (trimmed.isNotEmpty) {
|
||||
// 匹配常见 emoji 前缀
|
||||
final emojiMatch = RegExp(r'^([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}✨🔧🆕🎨⚡🌐📱🔒🎯✅🐛🚀💡📦🔥⭐💕🎉]+)\s*').firstMatch(trimmed);
|
||||
if (emojiMatch != null) {
|
||||
emoji = emojiMatch.group(1);
|
||||
rest = trimmed.substring(emojiMatch.end).trim();
|
||||
// 使用 code point 检测 emoji 前缀(避免正则 Unicode 转义兼容性问题)
|
||||
final runes = trimmed.runes.toList();
|
||||
int emojiEnd = 0;
|
||||
while (emojiEnd < runes.length) {
|
||||
final cp = runes[emojiEnd];
|
||||
final isEmoji =
|
||||
(cp >= 0x1F300 && cp <= 0x1FAFF) || // Emoji 主范围
|
||||
(cp >= 0x2600 && cp <= 0x27BF) || // 杂项符号(☀➿等)
|
||||
(cp >= 0x2B00 && cp <= 0x2BFF) || // 其他符号
|
||||
'✨🔧🆕🎨⚡🌐📱🔒🎯✅🐛🚀💡📦🔥⭐💕🎉'.runes.contains(cp) ||
|
||||
(cp >= 0xFE00 && cp <= 0xFE0F) || // Variation Selector
|
||||
(cp >= 0x1F900 && cp <= 0x1F9FF); // 补充符号
|
||||
if (!isEmoji) break;
|
||||
emojiEnd++;
|
||||
}
|
||||
if (emojiEnd > 0) {
|
||||
emoji = String.fromCharCodes(runes.sublist(0, emojiEnd));
|
||||
rest = trimmed
|
||||
.substring(String.fromCharCodes(runes.sublist(0, emojiEnd)).length)
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +245,10 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor);
|
||||
}
|
||||
if (emoji.contains('📱')) {
|
||||
return _IconMatch(CupertinoIcons.device_phone_portrait, ext.iconTintCyan);
|
||||
return _IconMatch(
|
||||
CupertinoIcons.device_phone_portrait,
|
||||
ext.iconTintCyan,
|
||||
);
|
||||
}
|
||||
if (emoji.contains('🚀') || emoji.contains('✨')) {
|
||||
return _IconMatch(CupertinoIcons.sparkles, ext.accent);
|
||||
@@ -255,19 +278,28 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
if (text.contains('修复') || lower.contains('fix') || lower.contains('bug')) {
|
||||
return _IconMatch(CupertinoIcons.wrench_fill, ext.warningColor);
|
||||
}
|
||||
if (text.contains('优化') || text.contains('改进') || text.contains('提升') || lower.contains('optim')) {
|
||||
if (text.contains('优化') ||
|
||||
text.contains('改进') ||
|
||||
text.contains('提升') ||
|
||||
lower.contains('optim')) {
|
||||
return _IconMatch(CupertinoIcons.paintbrush_fill, ext.iconTintPurple);
|
||||
}
|
||||
if (text.contains('性能') || lower.contains('performance')) {
|
||||
return _IconMatch(CupertinoIcons.bolt_fill, ext.iconTintYellow);
|
||||
}
|
||||
if (text.contains('多语言') || text.contains('翻译') || lower.contains('language')) {
|
||||
if (text.contains('多语言') ||
|
||||
text.contains('翻译') ||
|
||||
lower.contains('language')) {
|
||||
return _IconMatch(CupertinoIcons.globe, ext.iconTintBlue);
|
||||
}
|
||||
if (text.contains('安全') || text.contains('密保') || lower.contains('security')) {
|
||||
if (text.contains('安全') ||
|
||||
text.contains('密保') ||
|
||||
lower.contains('security')) {
|
||||
return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor);
|
||||
}
|
||||
if (text.contains('框架') || text.contains('架构') || lower.contains('framework')) {
|
||||
if (text.contains('框架') ||
|
||||
text.contains('架构') ||
|
||||
lower.contains('framework')) {
|
||||
return _IconMatch(CupertinoIcons.cube_box_fill, ext.iconTintMint);
|
||||
}
|
||||
|
||||
@@ -375,7 +407,11 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
}
|
||||
|
||||
/// 构建单张卡片
|
||||
Widget _buildCard(_FeatureCardData card, AppThemeExtension ext, bool isOverview) {
|
||||
Widget _buildCard(
|
||||
_FeatureCardData card,
|
||||
AppThemeExtension ext,
|
||||
bool isOverview,
|
||||
) {
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -429,7 +465,10 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
|
||||
/// 构建带光晕效果的图标
|
||||
Widget _buildIconWithGlow(
|
||||
_FeatureCardData card, AppThemeExtension ext, bool isOverview) {
|
||||
_FeatureCardData card,
|
||||
AppThemeExtension ext,
|
||||
bool isOverview,
|
||||
) {
|
||||
final iconSize = isOverview ? 64.0 : 56.0;
|
||||
final containerSize = isOverview ? 120.0 : 100.0;
|
||||
|
||||
@@ -459,11 +498,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
card.icon,
|
||||
size: iconSize,
|
||||
color: card.iconColor,
|
||||
),
|
||||
child: Icon(card.icon, size: iconSize, color: card.iconColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -480,9 +515,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgCard,
|
||||
border: Border(
|
||||
top: BorderSide(color: ext.overlaySubtle, width: 0.5),
|
||||
),
|
||||
border: Border(top: BorderSide(color: ext.overlaySubtle, width: 0.5)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
@@ -544,9 +577,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Text(
|
||||
isLastPage
|
||||
? widget.t.common.gotIt
|
||||
: widget.t.common.confirm,
|
||||
isLastPage ? widget.t.common.gotIt : widget.t.common.confirm,
|
||||
style: AppTypography.body.copyWith(
|
||||
color: ext.textOnAccent,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// ============================================================
|
||||
/// ============================================================
|
||||
// 闲言APP — 应用入口
|
||||
// 创建时间: 2026-04-20
|
||||
/// 更新时间: 2026-06-05
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: main 函数,初始化存储 + 液态玻璃 + 异常捕获 + 启动 App
|
||||
/// 上次更新: 新增PlatformCapabilities.init()平台能力注册表初始化
|
||||
/// 上次更新: 移除 main.dart 中的 flutter_acrylic 初始化,统一由 WindowsAcrylicService 管理
|
||||
// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
@@ -65,6 +65,7 @@ void main() async {
|
||||
Future<void> _appMain() async {
|
||||
if (pu.isDesktop) {
|
||||
await windowManager.ensureInitialized();
|
||||
|
||||
const windowOptions = WindowOptions(
|
||||
minimumSize: Size(400, 600),
|
||||
title: '闲言',
|
||||
@@ -74,6 +75,10 @@ Future<void> _appMain() async {
|
||||
// macOS: 隐藏原生红黄绿按钮,由 Flutter 侧自绘
|
||||
skipTaskbar: false,
|
||||
);
|
||||
|
||||
// Windows: flutter_acrylic 初始化统一由 WindowsAcrylicService 管理
|
||||
// Window.initialize() 和 Window.setEffect() 均在 app.dart 的 _initWindowEffect 中调用
|
||||
// 不在此处调用 Window.initialize(),避免重复初始化和布局异常
|
||||
await windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 键盘适配工具
|
||||
// 创建时间: 2026-05-02
|
||||
// 更新时间: 2026-05-30
|
||||
// 更新时间: 2026-06-21
|
||||
// 作用: 解决输入法面板遮挡底部Sheet/弹窗的通用方案 + 全局键盘管理
|
||||
// 上次更新: 集成flutter_keyboard_visibility替换MediaQuery轮询检测键盘可见性
|
||||
// 上次更新: 鸿蒙端 flutter_keyboard_visibility 插件无原生实现,
|
||||
// 优雅降级为 MediaQuery 检测,避免 MissingPluginException
|
||||
// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
@@ -11,6 +12,7 @@ import 'dart:async';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
|
||||
/// 键盘安全底部弹窗包装器
|
||||
///
|
||||
@@ -118,15 +120,36 @@ class KeyboardManager {
|
||||
static final KeyboardManager instance = KeyboardManager._();
|
||||
|
||||
/// 键盘可见性控制器(flutter_keyboard_visibility)
|
||||
static final KeyboardVisibilityController _keyboardVisibility =
|
||||
KeyboardVisibilityController();
|
||||
/// 鸿蒙端无原生实现,延迟初始化并捕获异常,降级为不可用
|
||||
static KeyboardVisibilityController? _keyboardVisibility;
|
||||
static bool _keyboardVisibilityAvailable = true;
|
||||
|
||||
static KeyboardVisibilityController? get _keyboardVisibilityController {
|
||||
if (!_keyboardVisibilityAvailable) return null;
|
||||
if (_keyboardVisibility != null) return _keyboardVisibility;
|
||||
try {
|
||||
_keyboardVisibility = KeyboardVisibilityController();
|
||||
return _keyboardVisibility;
|
||||
} catch (e) {
|
||||
_keyboardVisibilityAvailable = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式键盘可见性变更
|
||||
static Stream<bool> get keyboardVisibilityStream =>
|
||||
_keyboardVisibility.onChange;
|
||||
/// 鸿蒙端插件不可用时返回空流(永不触发),调用方需用 MediaQuery 兜底
|
||||
static Stream<bool> get keyboardVisibilityStream {
|
||||
if (pu.isOhos) return const Stream<bool>.empty();
|
||||
return _keyboardVisibilityController?.onChange ??
|
||||
const Stream<bool>.empty();
|
||||
}
|
||||
|
||||
/// 同步获取键盘是否可见(无需BuildContext)
|
||||
static bool get isKeyboardVisibleSync => _keyboardVisibility.isVisible;
|
||||
/// 鸿蒙端插件不可用时返回 false,调用方需用 MediaQuery 兜底
|
||||
static bool get isKeyboardVisibleSync {
|
||||
if (pu.isOhos) return false;
|
||||
return _keyboardVisibilityController?.isVisible ?? false;
|
||||
}
|
||||
|
||||
/// 页面生命周期管理
|
||||
final Set<String> _activePages = {};
|
||||
@@ -170,8 +193,10 @@ class KeyboardManager {
|
||||
}
|
||||
|
||||
/// 键盘是否可见(使用KeyboardVisibilityController,无需BuildContext轮询)
|
||||
/// 鸿蒙端降级为 MediaQuery 检测
|
||||
static bool isKeyboardVisible(BuildContext context) {
|
||||
return _keyboardVisibility.isVisible;
|
||||
if (pu.isOhos) return MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
return _keyboardVisibilityController?.isVisible ?? false;
|
||||
}
|
||||
|
||||
/// 键盘高度(仍需MediaQuery,flutter_keyboard_visibility不提供高度)
|
||||
@@ -191,8 +216,10 @@ class KeyboardSafe {
|
||||
}
|
||||
|
||||
/// 键盘是否可见(使用KeyboardVisibilityController,无需BuildContext轮询)
|
||||
/// 鸿蒙端降级为 MediaQuery 检测
|
||||
static bool isKeyboardVisible(BuildContext context) {
|
||||
return KeyboardManager._keyboardVisibility.isVisible;
|
||||
if (pu.isOhos) return MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
return KeyboardManager._keyboardVisibilityController?.isVisible ?? false;
|
||||
}
|
||||
|
||||
static Future<T?> showSheet<T>({
|
||||
@@ -261,7 +288,9 @@ class _ManagedCupertinoTextFieldState extends State<ManagedCupertinoTextField> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyboardSub = KeyboardManager.keyboardVisibilityStream.listen(_onKeyboardVisibility);
|
||||
_keyboardSub = KeyboardManager.keyboardVisibilityStream.listen(
|
||||
_onKeyboardVisibility,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
413
lib/shared/widgets/window_size_popup.dart
Normal file
413
lib/shared/widgets/window_size_popup.dart
Normal file
@@ -0,0 +1,413 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 窗口大小预设弹出菜单
|
||||
/// 创建时间: 2026-06-22
|
||||
/// 更新时间: 2026-06-22
|
||||
/// 作用: 点击"口"按钮在按钮下方弹出 3×2 网格菜单,选择窗口预设尺寸
|
||||
/// 上次更新: 从 adaptive_nav_bar.dart 提取为共享组件,供 DesktopWindowTitleBar 和 AdaptiveNavBar 复用
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/theme/glass_tokens.dart';
|
||||
|
||||
/// 窗口大小预设弹出菜单
|
||||
///
|
||||
/// 使用 [WindowSizePopup.show] 在指定按钮位置弹出 3×2 网格菜单。
|
||||
/// iOS 26 Liquid Glass 风格,Alert 层级毛玻璃。
|
||||
class WindowSizePopup {
|
||||
WindowSizePopup._();
|
||||
|
||||
/// 在 [buttonContext] 对应的按钮下方弹出窗口大小预设菜单
|
||||
///
|
||||
/// [buttonContext] 应为按钮自身的 BuildContext,用于计算弹出位置。
|
||||
static Future<void> show(BuildContext buttonContext) async {
|
||||
final isMaximized = await windowManager.isMaximized();
|
||||
if (!buttonContext.mounted) return;
|
||||
|
||||
// 获取按钮在屏幕中的位置,用于弹出菜单定位
|
||||
final renderBox = buttonContext.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
final buttonPos = renderBox.localToGlobal(Offset.zero);
|
||||
final buttonSize = renderBox.size;
|
||||
|
||||
// 通过 OverlayEntry 弹出自定义菜单,避免阻塞 UI 线程
|
||||
late OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => _WindowSizePopupContent(
|
||||
buttonPos: buttonPos,
|
||||
buttonSize: buttonSize,
|
||||
isMaximized: isMaximized,
|
||||
onDismiss: () => entry.remove(),
|
||||
),
|
||||
);
|
||||
Overlay.of(buttonContext, rootOverlay: true).insert(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// 窗口大小弹出菜单内容(iOS 26 Liquid Glass 风格)
|
||||
/// 3 列 × 2 行网格布局,在按钮下方定位弹出
|
||||
class _WindowSizePopupContent extends StatefulWidget {
|
||||
const _WindowSizePopupContent({
|
||||
required this.buttonPos,
|
||||
required this.buttonSize,
|
||||
required this.isMaximized,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
/// 按钮左上角的全局坐标
|
||||
final Offset buttonPos;
|
||||
/// 按钮尺寸
|
||||
final Size buttonSize;
|
||||
/// 当前窗口是否已最大化
|
||||
final bool isMaximized;
|
||||
/// 关闭菜单回调
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
State<_WindowSizePopupContent> createState() =>
|
||||
_WindowSizePopupContentState();
|
||||
}
|
||||
|
||||
class _WindowSizePopupContentState extends State<_WindowSizePopupContent>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _scale;
|
||||
late final Animation<double> _opacity;
|
||||
|
||||
/// 弹出菜单宽度
|
||||
static const double _popupWidth = 348.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
reverseDuration: const Duration(milliseconds: 120),
|
||||
vsync: this,
|
||||
);
|
||||
_scale = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
|
||||
_opacity = CurvedAnimation(parent: _controller, curve: Curves.easeIn);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 关闭菜单(先播放退出动画再移除 Overlay)
|
||||
void _close() {
|
||||
_controller.reverse().then((_) {
|
||||
if (mounted) widget.onDismiss();
|
||||
});
|
||||
}
|
||||
|
||||
/// 应用窗口尺寸
|
||||
Future<void> _applySize(int? w, int? h) async {
|
||||
_close();
|
||||
if (w == null || h == null) {
|
||||
// 最大化 / 还原
|
||||
if (widget.isMaximized) {
|
||||
await windowManager.unmaximize();
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
}
|
||||
} else {
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
}
|
||||
await windowManager.setSize(Size(w.toDouble(), h.toDouble()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = AppTheme.ext(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
// 计算弹出位置:右对齐按钮,出现在按钮下方
|
||||
double left = widget.buttonPos.dx + widget.buttonSize.width - _popupWidth;
|
||||
const margin = 8.0;
|
||||
if (left < margin) left = margin;
|
||||
if (left + _popupWidth > mediaQuery.size.width - margin) {
|
||||
left = mediaQuery.size.width - _popupWidth - margin;
|
||||
}
|
||||
final top = widget.buttonPos.dy + widget.buttonSize.height + 4;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 全屏透明遮罩,点击外部关闭
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _close,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
// 弹出菜单本体
|
||||
Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (_, child) => Opacity(
|
||||
opacity: _opacity.value,
|
||||
child: Transform.scale(
|
||||
scale: 0.92 + 0.08 * _scale.value,
|
||||
alignment: Alignment.topRight,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: _buildPopup(ext),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建弹出菜单容器
|
||||
Widget _buildPopup(AppThemeExtension ext) {
|
||||
return Container(
|
||||
width: _popupWidth,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: GlassTokens.borderColor(ext.isDark),
|
||||
width: GlassTokens.borderWidth,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: GlassTokens.shadowColor(ext.isDark),
|
||||
blurRadius: GlassTokens.shadowBlur,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: GlassTokens.alertBlur,
|
||||
sigmaY: GlassTokens.alertBlur,
|
||||
),
|
||||
child: Container(
|
||||
color: ext.glassColor.withValues(
|
||||
alpha: ext.isDark
|
||||
? GlassTokens.alertOpacityDark
|
||||
: GlassTokens.alertOpacityLight,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(ext),
|
||||
Container(
|
||||
height: 0.5,
|
||||
color: ext.dividerOnCard,
|
||||
),
|
||||
_buildGrid(ext),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 标题栏
|
||||
Widget _buildHeader(AppThemeExtension ext) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.rectangle_split_3x1,
|
||||
size: 14,
|
||||
color: ext.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'窗口大小',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ext.textSecondary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
widget.isMaximized ? '当前:最大化' : '当前:自定义',
|
||||
style: TextStyle(fontSize: 11, color: ext.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 3×2 网格
|
||||
Widget _buildGrid(AppThemeExtension ext) {
|
||||
// 预设尺寸列表:(图标, 标签, 宽, 高)
|
||||
final presets = <_SizePreset>[
|
||||
const _SizePreset(
|
||||
icon: CupertinoIcons.device_phone_portrait,
|
||||
label: '小窗',
|
||||
width: 800,
|
||||
height: 600,
|
||||
),
|
||||
const _SizePreset(
|
||||
icon: CupertinoIcons.desktopcomputer,
|
||||
label: '标准',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
),
|
||||
const _SizePreset(
|
||||
icon: CupertinoIcons.device_laptop,
|
||||
label: '宽屏',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
),
|
||||
const _SizePreset(
|
||||
icon: CupertinoIcons.tv,
|
||||
label: '大屏',
|
||||
width: 1440,
|
||||
height: 900,
|
||||
),
|
||||
const _SizePreset(
|
||||
icon: CupertinoIcons.rectangle_on_rectangle,
|
||||
label: '全高清',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
),
|
||||
_SizePreset(
|
||||
icon: widget.isMaximized
|
||||
? CupertinoIcons.arrow_down_right_square
|
||||
: CupertinoIcons.rectangle_fill,
|
||||
label: widget.isMaximized ? '还原' : '最大化',
|
||||
width: null,
|
||||
height: null,
|
||||
),
|
||||
];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
child: GridView.count(
|
||||
crossAxisCount: 3,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: AppSpacing.sm,
|
||||
crossAxisSpacing: AppSpacing.sm,
|
||||
childAspectRatio: 1.05,
|
||||
children: presets
|
||||
.map((p) => _HoverCell(
|
||||
preset: p,
|
||||
ext: ext,
|
||||
onTap: () => _applySize(p.width, p.height),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 窗口尺寸预设数据模型
|
||||
class _SizePreset {
|
||||
const _SizePreset({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final int? width;
|
||||
final int? height;
|
||||
|
||||
/// 尺寸显示文本
|
||||
String get dimensionText =>
|
||||
width != null && height != null ? '$width × $height' : '';
|
||||
}
|
||||
|
||||
/// 可悬停的尺寸选择单元格
|
||||
class _HoverCell extends StatefulWidget {
|
||||
const _HoverCell({
|
||||
required this.preset,
|
||||
required this.ext,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final _SizePreset preset;
|
||||
final AppThemeExtension ext;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_HoverCell> createState() => _HoverCellState();
|
||||
}
|
||||
|
||||
class _HoverCellState extends State<_HoverCell> {
|
||||
bool _isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = widget.ext;
|
||||
final preset = widget.preset;
|
||||
final accent = ext.accent;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovering = true),
|
||||
onExit: (_) => setState(() => _isHovering = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovering
|
||||
? accent.withValues(alpha: 0.14)
|
||||
: ext.overlaySubtle.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: _isHovering
|
||||
? accent.withValues(alpha: 0.45)
|
||||
: const Color(0x00000000),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
preset.icon,
|
||||
size: 22,
|
||||
color: _isHovering ? accent : ext.iconSecondary,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
preset.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _isHovering ? accent : ext.textPrimary,
|
||||
),
|
||||
),
|
||||
if (preset.dimensionText.isNotEmpty) ...[
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
preset.dimensionText,
|
||||
style: TextStyle(fontSize: 10, color: ext.textHint),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import flutter_app_group_directory
|
||||
import flutter_image_compress_macos
|
||||
import flutter_inappwebview_macos
|
||||
import flutter_local_notifications
|
||||
import flutter_secure_storage_darwin
|
||||
import flutter_secure_storage_macos
|
||||
import flutter_tts
|
||||
import flutter_webrtc
|
||||
import gal
|
||||
@@ -56,7 +56,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
|
||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
|
||||
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||
|
||||
222
ohos/entry/src/main/ets/entryability/CustomPlatformPlugin.ets
Normal file
222
ohos/entry/src/main/ets/entryability/CustomPlatformPlugin.ets
Normal file
@@ -0,0 +1,222 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 鸿蒙端自定义 PlatformPlugin 中间件
|
||||
// 创建时间: 2026-06-21
|
||||
// 更新时间: 2026-06-21
|
||||
// 作用: 拦截 Flutter 引擎的剪贴板读取请求,使用系统安全控件
|
||||
// PasteButton 替代 READ_PASTEBOARD 系统级权限申请。
|
||||
// READ_PASTEBOARD 为 system_basic 级权限,普通应用无法通过
|
||||
// requestPermissionsFromUser 申请;PasteButton 点击后授予
|
||||
// 临时授权(持续到灭屏/切后台/退出),无需声明权限。
|
||||
// 架构说明: 此中间件不修改 SDK 源码,通过继承 PlatformPlugin 和
|
||||
// PlatformPluginCallback 实现剪贴板方法覆盖,SDK 升级不受影响。
|
||||
// ============================================================
|
||||
|
||||
import PlatformPlugin, {
|
||||
PlatformPluginCallback,
|
||||
PlatformPluginDelegate,
|
||||
} from '@ohos/flutter_ohos/src/main/ets/plugin/PlatformPlugin';
|
||||
import PlatformChannel from '@ohos/flutter_ohos/src/main/ets/embedding/engine/systemchannels/PlatformChannel';
|
||||
import common from '@ohos.app.ability.common';
|
||||
import { MethodResult } from '@ohos/flutter_ohos/src/main/ets/plugin/common/MethodChannel';
|
||||
import Any from '@ohos/flutter_ohos/src/main/ets/plugin/common/Any';
|
||||
import Log from '@ohos/flutter_ohos/src/main/ets/util/Log';
|
||||
import { pasteboard, BusinessError } from '@kit.BasicServicesKit';
|
||||
import { ComponentContent } from '@kit.ArkUI';
|
||||
|
||||
const TAG = 'CustomPlatformPlugin';
|
||||
|
||||
// ============================================================
|
||||
// 粘贴安全控件对话框(PasteButton)
|
||||
// ============================================================
|
||||
|
||||
/// 粘贴对话框控制器(用于 PasteButton 回调传递)
|
||||
class PasteDialogController {
|
||||
onPaste: ((text: string) => void) | null = null;
|
||||
onClose: (() => void) | null = null;
|
||||
}
|
||||
|
||||
/// 粘贴对话框内容构建器(使用系统安全控件 PasteButton)
|
||||
/// PasteButton 是鸿蒙系统安全控件,点击后授予临时剪贴板读取授权
|
||||
@Builder
|
||||
function PasteDialogContent(controller: PasteDialogController) {
|
||||
Column({ space: 16 }) {
|
||||
Text('粘贴')
|
||||
.fontSize(18)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor('#333333')
|
||||
|
||||
Text('点击下方按钮粘贴剪贴板内容')
|
||||
.fontSize(14)
|
||||
.fontColor('#666666')
|
||||
|
||||
PasteButton()
|
||||
.padding({ top: 12, bottom: 12, left: 24, right: 24 })
|
||||
.onClick((event: ClickEvent, result: PasteButtonOnClickResult) => {
|
||||
if (PasteButtonOnClickResult.SUCCESS === result) {
|
||||
try {
|
||||
const pasteData = pasteboard.getSystemPasteboard().getDataSync();
|
||||
let pasteText: string = '';
|
||||
if (pasteData && pasteData.getRecordCount() > 0) {
|
||||
const record = pasteData.getRecordAt(0);
|
||||
if (record.mimeType === pasteboard.MIMETYPE_TEXT_PLAIN) {
|
||||
pasteText = record.plainText ?? '';
|
||||
} else if (record.mimeType === pasteboard.MIMETYPE_TEXT_HTML) {
|
||||
pasteText = record.htmlText ?? '';
|
||||
}
|
||||
}
|
||||
controller.onPaste?.(pasteText);
|
||||
} catch (e) {
|
||||
Log.e(TAG, 'PasteButton read clipboard error: ' + JSON.stringify(e));
|
||||
controller.onPaste?.('');
|
||||
}
|
||||
} else {
|
||||
controller.onClose?.();
|
||||
}
|
||||
})
|
||||
|
||||
Button('取消')
|
||||
.fontSize(16)
|
||||
.fontColor('#999999')
|
||||
.backgroundColor(Color.Transparent)
|
||||
.onClick(() => {
|
||||
controller.onClose?.();
|
||||
})
|
||||
}
|
||||
.padding(24)
|
||||
.backgroundColor(Color.White)
|
||||
.borderRadius(16)
|
||||
.width('70%')
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自定义 PlatformPluginCallback
|
||||
// 覆盖剪贴板读取方法,使用 PasteButton 安全控件
|
||||
// ============================================================
|
||||
|
||||
class CustomPlatformPluginCallback extends PlatformPluginCallback {
|
||||
/**
|
||||
* 获取剪贴板数据
|
||||
* 使用 PasteButton 安全控件获取临时授权,替代 READ_PASTEBOARD 权限申请
|
||||
*/
|
||||
getClipboardData(result: MethodResult): void {
|
||||
this.showPasteDialog(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查剪贴板是否有字符串
|
||||
* 始终返回 true,确保输入框工具栏的粘贴按钮显示
|
||||
* 实际剪贴板内容在用户点击 PasteButton 后读取
|
||||
*/
|
||||
clipboardHasStrings(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示粘贴对话框(使用 PasteButton 安全控件)
|
||||
* 用户点击 PasteButton 后获得临时授权,可静默读取剪贴板数据
|
||||
*/
|
||||
private showPasteDialog(result: MethodResult): void {
|
||||
try {
|
||||
const win = this.lastWindow ?? this.mainWindow;
|
||||
if (!win) {
|
||||
Log.e(TAG, 'No window available for paste dialog');
|
||||
result.success(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const uiContext = win.getUIContext();
|
||||
const promptAction = uiContext.getPromptAction();
|
||||
|
||||
const controller = new PasteDialogController();
|
||||
let isResolved: boolean = false;
|
||||
let dialogContent: ComponentContent<PasteDialogController> | null = null;
|
||||
|
||||
const closeDialog = () => {
|
||||
if (isResolved) return;
|
||||
isResolved = true;
|
||||
if (dialogContent) {
|
||||
try {
|
||||
promptAction.closeCustomDialog(dialogContent);
|
||||
} catch (e) {
|
||||
// ignore close error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
controller.onPaste = (text: string) => {
|
||||
closeDialog();
|
||||
const response: Any = new Map().set('text', text);
|
||||
result.success(response);
|
||||
};
|
||||
|
||||
controller.onClose = () => {
|
||||
closeDialog();
|
||||
result.success(null);
|
||||
};
|
||||
|
||||
dialogContent = new ComponentContent(
|
||||
uiContext,
|
||||
wrapBuilder(PasteDialogContent),
|
||||
controller
|
||||
);
|
||||
|
||||
promptAction.openCustomDialog(dialogContent, {
|
||||
alignment: DialogAlignment.Center,
|
||||
autoCancel: true,
|
||||
onWillDismiss: (dialog: DismissDialogAction) => {
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
result.success(null);
|
||||
}
|
||||
dialog.dismiss();
|
||||
},
|
||||
}).catch((e: BusinessError) => {
|
||||
Log.e(TAG, 'Failed to show paste dialog: ' + JSON.stringify(e));
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
result.success(null);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Log.e(TAG, 'showPasteDialog error: ' + JSON.stringify(e));
|
||||
result.success(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自定义 PlatformPlugin
|
||||
// 继承原生 PlatformPlugin,替换 callback 为自定义实现
|
||||
// ============================================================
|
||||
|
||||
export default class CustomPlatformPlugin extends PlatformPlugin {
|
||||
constructor(
|
||||
platformChannel: PlatformChannel,
|
||||
context: common.Context,
|
||||
platformPluginDelegate?: PlatformPluginDelegate
|
||||
) {
|
||||
super(platformChannel, context, platformPluginDelegate);
|
||||
|
||||
// 创建自定义 callback,复制原生 callback 的所有属性
|
||||
const customCallback = new CustomPlatformPluginCallback();
|
||||
customCallback.platform = this.callback.platform;
|
||||
customCallback.mainWindow = this.callback.mainWindow;
|
||||
customCallback.lastWindow = this.callback.lastWindow;
|
||||
customCallback.platformChannel = this.callback.platformChannel;
|
||||
customCallback.platformPluginDelegate = this.callback.platformPluginDelegate;
|
||||
customCallback.context = this.callback.context;
|
||||
customCallback.uiAbilityContext = this.callback.uiAbilityContext;
|
||||
customCallback.applicationContext = this.callback.applicationContext;
|
||||
customCallback.flutterView = this.callback.flutterView;
|
||||
customCallback.showBarOrNavigation = this.callback.showBarOrNavigation;
|
||||
customCallback.currentTheme = this.callback.currentTheme;
|
||||
customCallback.callbackId = this.callback.callbackId;
|
||||
|
||||
// 替换 callback 并重新注册到 PlatformChannel
|
||||
this.callback = customCallback;
|
||||
customCallback.platformChannel?.setPlatformMessageHandler(customCallback);
|
||||
|
||||
Log.i(TAG, 'CustomPlatformPlugin initialized with PasteButton support');
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 鸿蒙端 EntryAbility
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-06-21
|
||||
// 作用: Flutter引擎配置 + 自定义剪贴板通道 + 快捷操作通道
|
||||
// 上次更新: 1. 移除 flutter/platform 通道拦截器,避免覆盖引擎原生
|
||||
// Clipboard/SystemChrome.setPreferredOrientations 实现
|
||||
// 2. 覆盖 providePlatformPlugin 返回 CustomPlatformPlugin,
|
||||
// 使用 PasteButton 安全控件替代 READ_PASTEBOARD 系统权限
|
||||
// 3. 不修改 SDK 源码,通过中间件继承实现,SDK 升级不受影响
|
||||
// ============================================================
|
||||
|
||||
import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
|
||||
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
|
||||
import Want from '@ohos.app.ability.Want';
|
||||
@@ -7,25 +19,36 @@ import MethodCall from '@ohos/flutter_ohos/src/main/ets/plugin/common/MethodCall
|
||||
import { MethodResult } from '@ohos/flutter_ohos/src/main/ets/plugin/common/MethodChannel';
|
||||
import { pasteboard } from '@kit.BasicServicesKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { componentUtils } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import PlatformPlugin from '@ohos/flutter_ohos/src/main/ets/plugin/PlatformPlugin';
|
||||
import CustomPlatformPlugin from './CustomPlatformPlugin';
|
||||
|
||||
// 自定义剪贴板通道(ClipboardBridge使用)
|
||||
// 自定义剪贴板通道(ClipboardBridge 使用,提供同步读写接口)
|
||||
const CLIPBOARD_CHANNEL = 'plugins.flutter.io/clipboard_ohos';
|
||||
// 快捷操作通道
|
||||
const QUICK_ACTIONS_CHANNEL = 'plugins.flutter.io/quick_actions_ohos';
|
||||
// Flutter标准平台通道(TextInputPlugin粘贴使用此通道的Clipboard方法)
|
||||
const FLUTTER_PLATFORM_CHANNEL = 'flutter/platform';
|
||||
|
||||
export default class EntryAbility extends FlutterAbility {
|
||||
private clipboardChannel: MethodChannel | null = null;
|
||||
private platformChannel: MethodChannel | null = null;
|
||||
|
||||
/**
|
||||
* 覆盖 providePlatformPlugin,返回自定义 PlatformPlugin
|
||||
* 使用 PasteButton 安全控件替代 READ_PASTEBOARD 系统权限
|
||||
* 不修改 SDK 源码,通过继承实现剪贴板读取拦截
|
||||
*/
|
||||
providePlatformPlugin(flutterEngine: FlutterEngine): PlatformPlugin | undefined {
|
||||
return new CustomPlatformPlugin(
|
||||
flutterEngine.getPlatformChannel()!,
|
||||
this.context,
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
GeneratedPluginRegistrant.registerWith(flutterEngine)
|
||||
|
||||
// 注册自定义剪贴板通道(ClipboardBridge使用)
|
||||
// 注册自定义剪贴板通道(ClipboardBridge 使用)
|
||||
// 注意: 此通道独立于 flutter/platform,不会覆盖引擎原生 Clipboard 实现
|
||||
this.clipboardChannel = new MethodChannel(flutterEngine.dartExecutor.getBinaryMessenger(), CLIPBOARD_CHANNEL);
|
||||
this.clipboardChannel.setMethodCallHandler({
|
||||
onMethodCall: (call: MethodCall, result: MethodResult): void => {
|
||||
@@ -46,31 +69,6 @@ export default class EntryAbility extends FlutterAbility {
|
||||
}
|
||||
});
|
||||
|
||||
// 注册Flutter标准平台通道的剪贴板方法拦截器
|
||||
// 鸿蒙端Flutter引擎的C++层未实现Clipboard方法,导致TextInputPlugin
|
||||
// 长按粘贴无反应。此处拦截Clipboard方法并路由到鸿蒙pasteboard API,
|
||||
// 非Clipboard方法调用result.notImplemented()交由C++层默认处理。
|
||||
this.platformChannel = new MethodChannel(flutterEngine.dartExecutor.getBinaryMessenger(), FLUTTER_PLATFORM_CHANNEL);
|
||||
this.platformChannel.setMethodCallHandler({
|
||||
onMethodCall: (call: MethodCall, result: MethodResult): void => {
|
||||
switch (call.method) {
|
||||
case 'Clipboard.getData':
|
||||
this.getClipboardDataForPlatform(result);
|
||||
break;
|
||||
case 'Clipboard.setData':
|
||||
this.setClipboardDataForPlatform(call, result);
|
||||
break;
|
||||
case 'Clipboard.hasStrings':
|
||||
this.hasClipboardStringsForPlatform(result);
|
||||
break;
|
||||
default:
|
||||
// 非Clipboard方法,交由Flutter引擎C++层默认处理
|
||||
result.notImplemented();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 冷启动时检查待处理的快捷方式
|
||||
if (this.pendingShortcutType && this.pendingShortcutType.length > 0) {
|
||||
this.notifyShortcutAction(this.pendingShortcutType);
|
||||
@@ -79,7 +77,7 @@ export default class EntryAbility extends FlutterAbility {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自定义通道方法(ClipboardBridge使用)
|
||||
// 自定义通道方法(ClipboardBridge 使用)
|
||||
// ============================================================
|
||||
|
||||
/// 读取剪贴板文本(返回纯文本字符串)
|
||||
@@ -100,7 +98,7 @@ export default class EntryAbility extends FlutterAbility {
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查剪贴板是否有文本(返回boolean)
|
||||
/// 检查剪贴板是否有文本(返回 boolean)
|
||||
private hasClipboardStrings(result: MethodResult): void {
|
||||
try {
|
||||
const pasteData = pasteboard.getSystemPasteboard().getDataSync();
|
||||
@@ -126,55 +124,6 @@ export default class EntryAbility extends FlutterAbility {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Flutter标准平台通道方法(TextInputPlugin粘贴使用)
|
||||
// 返回格式需符合Flutter Clipboard类期望
|
||||
// ============================================================
|
||||
|
||||
/// Flutter标准Clipboard.getData — 返回 {text: string}
|
||||
private getClipboardDataForPlatform(result: MethodResult): void {
|
||||
try {
|
||||
const pasteData = pasteboard.getSystemPasteboard().getDataSync();
|
||||
if (pasteData && pasteData.getRecordCount() > 0) {
|
||||
const record = pasteData.getRecordAt(0);
|
||||
const text = record.plainText;
|
||||
result.success({ 'text': text ?? '' });
|
||||
} else {
|
||||
result.success({ 'text': '' });
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as BusinessError;
|
||||
console.error(`Platform Clipboard.getData error: ${err.code} ${err.message}`);
|
||||
result.success({ 'text': '' });
|
||||
}
|
||||
}
|
||||
|
||||
/// Flutter标准Clipboard.setData
|
||||
private setClipboardDataForPlatform(call: MethodCall, result: MethodResult): void {
|
||||
try {
|
||||
const args = call.args as Record<string, string>;
|
||||
const text = args['text'] ?? '';
|
||||
const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
|
||||
pasteboard.getSystemPasteboard().setDataSync(pasteData);
|
||||
result.success(null);
|
||||
} catch (e) {
|
||||
const err = e as BusinessError;
|
||||
console.error(`Platform Clipboard.setData error: ${err.code} ${err.message}`);
|
||||
result.success(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Flutter标准Clipboard.hasStrings — 返回 {value: boolean}
|
||||
private hasClipboardStringsForPlatform(result: MethodResult): void {
|
||||
try {
|
||||
const pasteData = pasteboard.getSystemPasteboard().getDataSync();
|
||||
const hasStrings = pasteData && pasteData.hasType(pasteboard.MIMETYPE_TEXT_PLAIN);
|
||||
result.success({ 'value': hasStrings });
|
||||
} catch (e) {
|
||||
result.success({ 'value': false });
|
||||
}
|
||||
}
|
||||
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
super.onCreate(want, launchParam);
|
||||
const uri = want?.parameters?.['uri'] as string ?? want?.uri;
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
"name": "permission_pasteboard_reason",
|
||||
"value": "Used for clipboard paste in text input"
|
||||
},
|
||||
{
|
||||
"name": "permission_read_pasteboard_reason",
|
||||
"value": "Used for clipboard paste in text input"
|
||||
},
|
||||
{
|
||||
"name": "permission_bundle_info_reason",
|
||||
"value": "Used to get application package information"
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
{
|
||||
"name": "shortcut_general_settings_label",
|
||||
"value": "Settings"
|
||||
},
|
||||
{
|
||||
"name": "permission_read_pasteboard_reason",
|
||||
"value": "Used for clipboard paste in text input"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -62,6 +62,10 @@
|
||||
"name": "permission_pasteboard_reason",
|
||||
"value": "用于文本输入框的剪贴板粘贴"
|
||||
},
|
||||
{
|
||||
"name": "permission_read_pasteboard_reason",
|
||||
"value": "用于文本输入框的剪贴板粘贴"
|
||||
},
|
||||
{
|
||||
"name": "permission_bundle_info_reason",
|
||||
"value": "用于获取应用包信息"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# ============================================================
|
||||
# 闲言APP (Xianyan) — MacBook Pro端 pubspec 模板
|
||||
# 创建时间: 2026-06-02
|
||||
# 更新时间: 2026-06-15
|
||||
# 更新时间: 2026-06-18
|
||||
# 作用: MacBook Pro端(iOS/macOS)依赖与资源配置模板(使用远程版本号)
|
||||
# 上次更新: 同步pubspec.yaml依赖升级 + 删除custom_lint/riverpod_lint + 新增analyzer/test_api/test overrides
|
||||
# 上次更新: 新增桌面端增强库(tray_manager/macos_window_utils/flutter_acrylic)
|
||||
# 使用方式:
|
||||
# ⚠️ 此文件为模板,不要直接重命名为 pubspec.yaml 使用
|
||||
# ⚠️ 新增三方库时,必须同步更新 pubspec.ohos.yaml 和 pubspec.macos.yaml
|
||||
@@ -136,7 +136,7 @@ dependencies:
|
||||
image: ^4.9.0 # 图片解码/编码/变换
|
||||
|
||||
# --- 图片编辑器 ---
|
||||
pro_image_editor: ^12.5.0 # 图片编辑器核心(官方版)
|
||||
pro_image_editor: 12.4.4 # v12.4.4 | 图片编辑器核心(12.5.x与Flutter 3.33运行时不兼容)
|
||||
|
||||
# --- 桌面端增强 ---
|
||||
desktop_drop: ^0.7.0 # 桌面端文件拖放接收
|
||||
|
||||
@@ -157,7 +157,7 @@ dependencies:
|
||||
image: ^4.9.0 # 图片解码/编码/变换
|
||||
|
||||
# --- 图片编辑器 ---
|
||||
pro_image_editor: ^12.5.0 # v12.5.0 | 图片编辑器核心(官方版)
|
||||
pro_image_editor: 12.4.4 # v12.4.4 | 图片编辑器核心(12.5.x与Flutter 3.33运行时不兼容)
|
||||
|
||||
# --- 桌面端增强 ---
|
||||
desktop_drop: ^0.7.0 # 桌面端文件拖放接收
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,3 +116,8 @@ install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/local_packages/sqlite3/prebuilt/sqlite3.dll"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
# Install app_icon.ico for system tray (tray_manager on Windows only supports .ico files)
|
||||
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/runner/resources/app_icon.ico"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
@@ -33,7 +33,7 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
|
||||
# Add dependency libraries and include directories. Add any application-specific
|
||||
# dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib" "comctl32.lib")
|
||||
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
|
||||
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
|
||||
@@ -1,12 +1,84 @@
|
||||
#include "flutter_window.h"
|
||||
|
||||
#include <optional>
|
||||
#include <commctrl.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
// GET_X_LPARAM / GET_Y_LPARAM from windowsx.h
|
||||
// Defined inline to avoid potential macro conflicts with plugin code
|
||||
#ifndef GET_X_LPARAM
|
||||
#define GET_X_LPARAM(lp) ((int)(short)LOWORD(lp))
|
||||
#endif
|
||||
#ifndef GET_Y_LPARAM
|
||||
#define GET_Y_LPARAM(lp) ((int)(short)HIWORD(lp))
|
||||
#endif
|
||||
|
||||
// ============================================================
|
||||
// debug instrumentation for window-drag-lag (flutter_window side)
|
||||
// ============================================================
|
||||
namespace {
|
||||
std::mutex g_debug_log_mutex2;
|
||||
std::wstring g_debug_log_path2;
|
||||
|
||||
void DebugLog(const std::string& tag, const std::string& detail) {
|
||||
// 已禁用文件日志以排除 I/O 对消息循环的干扰。
|
||||
(void)tag;
|
||||
(void)detail;
|
||||
#if 0
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
now.time_since_epoch())
|
||||
.count();
|
||||
std::lock_guard<std::mutex> lock(g_debug_log_mutex2);
|
||||
if (g_debug_log_path2.empty()) {
|
||||
g_debug_log_path2 = L"E:\\project\\flutter\\f\\xianyan\\xianyan_drag_debug.log";
|
||||
}
|
||||
std::wofstream ofs(g_debug_log_path2, std::ios::app);
|
||||
if (!ofs.is_open()) return;
|
||||
ofs << us << L" [" << std::wstring(tag.begin(), tag.end()) << L"] "
|
||||
<< std::wstring(detail.begin(), detail.end()) << L"\n";
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string MsgName(UINT msg) {
|
||||
switch (msg) {
|
||||
case WM_NCHITTEST: return "WM_NCHITTEST";
|
||||
case WM_NCLBUTTONDOWN: return "WM_NCLBUTTONDOWN";
|
||||
case WM_NCLBUTTONUP: return "WM_NCLBUTTONUP";
|
||||
case WM_NCMOUSEMOVE: return "WM_NCMOUSEMOVE";
|
||||
case WM_LBUTTONDOWN: return "WM_LBUTTONDOWN";
|
||||
case WM_LBUTTONUP: return "WM_LBUTTONUP";
|
||||
case WM_MOUSEMOVE: return "WM_MOUSEMOVE";
|
||||
case WM_MOVE: return "WM_MOVE";
|
||||
case WM_MOVING: return "WM_MOVING";
|
||||
case WM_SIZE: return "WM_SIZE";
|
||||
case WM_WINDOWPOSCHANGING: return "WM_WINDOWPOSCHANGING";
|
||||
case WM_WINDOWPOSCHANGED: return "WM_WINDOWPOSCHANGED";
|
||||
case WM_SYSCOMMAND: return "WM_SYSCOMMAND";
|
||||
case WM_ENTERSIZEMOVE: return "WM_ENTERSIZEMOVE";
|
||||
case WM_EXITSIZEMOVE: return "WM_EXITSIZEMOVE";
|
||||
case WM_PAINT: return "WM_PAINT";
|
||||
case WM_ERASEBKGND: return "WM_ERASEBKGND";
|
||||
default: return "MSG_" + std::to_string(msg);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ============================================================
|
||||
// 静态成员初始化
|
||||
// ============================================================
|
||||
bool FlutterWindow::is_in_native_drag_ = false;
|
||||
|
||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||
: project_(project) {}
|
||||
|
||||
@@ -30,6 +102,18 @@ bool FlutterWindow::OnCreate() {
|
||||
RegisterPlugins(flutter_controller_->engine());
|
||||
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||
|
||||
// ============================================================
|
||||
// 子类化 Flutter 子窗口:标题栏命中测试穿透
|
||||
// Flutter 子窗口覆盖整个客户区,拦截了所有鼠标消息。
|
||||
// 通过子类化,在标题栏可拖拽区域返回 HTTRANSPARENT,让父窗口
|
||||
// 的 WM_NCHITTEST 有机会返回 HTCAPTION,从而触发 Windows 原生
|
||||
// 模态拖拽循环。原生拖拽由 DWM 直接处理,延迟最低。
|
||||
// 标题栏右侧控制按钮区域返回 HTCLIENT,保证按钮可点击。
|
||||
// ============================================================
|
||||
HWND child_hwnd = flutter_controller_->view()->GetNativeWindow();
|
||||
SetWindowSubclass(child_hwnd, ChildWndProc, kChildSubclassId,
|
||||
reinterpret_cast<DWORD_PTR>(GetHandle()));
|
||||
|
||||
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
||||
this->Show();
|
||||
});
|
||||
@@ -62,7 +146,6 @@ bool FlutterWindow::OnCreate() {
|
||||
Win32Window::SetDarkMode(GetHandle(), is_dark);
|
||||
result->Success();
|
||||
} else if (method == "setWindowTitle") {
|
||||
// 设置窗口标题
|
||||
std::string title;
|
||||
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
|
||||
auto it = args->find(flutter::EncodableValue("title"));
|
||||
@@ -74,7 +157,6 @@ bool FlutterWindow::OnCreate() {
|
||||
Win32Window::SetWindowTitle(GetHandle(), wide_title);
|
||||
result->Success();
|
||||
} else if (method == "setFullscreen") {
|
||||
// 进入/退出全屏
|
||||
bool fullscreen = false;
|
||||
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
|
||||
auto it = args->find(flutter::EncodableValue("fullscreen"));
|
||||
@@ -85,11 +167,9 @@ bool FlutterWindow::OnCreate() {
|
||||
Win32Window::SetFullscreen(GetHandle(), fullscreen);
|
||||
result->Success();
|
||||
} else if (method == "isFullscreen") {
|
||||
// 查询全屏状态
|
||||
bool is_fullscreen = Win32Window::IsFullscreen(GetHandle());
|
||||
result->Success(flutter::EncodableValue(is_fullscreen));
|
||||
} else if (method == "setMinSize") {
|
||||
// 设置最小尺寸
|
||||
unsigned int width = 0;
|
||||
unsigned int height = 0;
|
||||
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
|
||||
@@ -105,7 +185,6 @@ bool FlutterWindow::OnCreate() {
|
||||
Win32Window::SetMinSize(GetHandle(), width, height);
|
||||
result->Success();
|
||||
} else if (method == "performHapticFeedback") {
|
||||
// 触觉反馈(Windows 用 MessageBeep 模拟)
|
||||
int feedback_type = 0;
|
||||
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
|
||||
auto it = args->find(flutter::EncodableValue("type"));
|
||||
@@ -116,7 +195,6 @@ bool FlutterWindow::OnCreate() {
|
||||
Win32Window::PerformHapticFeedback(GetHandle(), feedback_type);
|
||||
result->Success();
|
||||
} else if (method == "getSystemAppearance") {
|
||||
// 获取系统外观模式
|
||||
std::string appearance = Win32Window::GetSystemAppearance();
|
||||
result->Success(flutter::EncodableValue(appearance));
|
||||
} else {
|
||||
@@ -124,21 +202,387 @@ bool FlutterWindow::OnCreate() {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 窗口控制 MethodChannel(窗口大小预设菜单等)
|
||||
// ============================================================
|
||||
window_control_channel_ = std::make_unique<flutter::MethodChannel<>>(
|
||||
flutter_controller_->engine()->messenger(), "xianyan/window_control",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
|
||||
window_control_channel_->SetMethodCallHandler(
|
||||
[this](const flutter::MethodCall<>& call,
|
||||
std::unique_ptr<flutter::MethodResult<>> result) {
|
||||
const std::string& method = call.method_name();
|
||||
|
||||
if (method == "showWindowSizeMenu") {
|
||||
// 使用 PostMessage 异步弹出菜单,避免 TrackPopupMenuEx
|
||||
// 阻塞 MethodChannel 回调线程(Flutter UI 线程)
|
||||
PostMessage(GetHandle(), WM_USER + 1000, 0, 0);
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
} else {
|
||||
result->NotImplemented();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void FlutterWindow::OnDestroy() {
|
||||
if (flutter_controller_) {
|
||||
HWND child_hwnd = flutter_controller_->view()->GetNativeWindow();
|
||||
RemoveWindowSubclass(child_hwnd, ChildWndProc, kChildSubclassId);
|
||||
flutter_controller_ = nullptr;
|
||||
}
|
||||
|
||||
Win32Window::OnDestroy();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 原生窗口大小预设菜单
|
||||
//
|
||||
// 由于 Mica/Acrylic 导致 DefWindowProc 边框命中测试失效,
|
||||
// 手动实现的边框命中测试在某些环境下仍无法调整窗口大小。
|
||||
// 这里提供点击"口"按钮弹出原生菜单选择窗口大小的替代方案。
|
||||
//
|
||||
// 使用 Win32 TrackPopupMenuEx 弹出原生菜单,符合 Windows 原生风格。
|
||||
// 菜单项包括:最大化/还原、几种预设尺寸。
|
||||
// ============================================================
|
||||
bool FlutterWindow::ShowWindowSizeMenu(HWND hwnd) {
|
||||
if (!hwnd) return false;
|
||||
|
||||
// 创建弹出菜单
|
||||
HMENU hMenu = CreatePopupMenu();
|
||||
if (!hMenu) return false;
|
||||
|
||||
// 检查当前是否最大化
|
||||
bool is_maximized = (::IsZoomed(hwnd) != 0);
|
||||
|
||||
// 菜单项 ID(从 1001 开始,避免与系统命令冲突)
|
||||
enum MenuId {
|
||||
ID_TOGGLE_MAXIMIZE = 1001,
|
||||
ID_SIZE_800x600,
|
||||
ID_SIZE_1024x768,
|
||||
ID_SIZE_1280x720,
|
||||
ID_SIZE_1440x900,
|
||||
ID_SIZE_1920x1080,
|
||||
};
|
||||
|
||||
// 添加菜单项
|
||||
AppendMenuW(hMenu, MF_STRING,
|
||||
ID_TOGGLE_MAXIMIZE,
|
||||
is_maximized ? L"🔽 还原窗口" : L"⬜ 最大化");
|
||||
AppendMenuW(hMenu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuW(hMenu, MF_STRING, ID_SIZE_800x600, L"📱 小窗 800 × 600");
|
||||
AppendMenuW(hMenu, MF_STRING, ID_SIZE_1024x768, L"💻 标准 1024 × 768");
|
||||
AppendMenuW(hMenu, MF_STRING, ID_SIZE_1280x720, L"🖥️ 宽屏 1280 × 720");
|
||||
AppendMenuW(hMenu, MF_STRING, ID_SIZE_1440x900, L"🖥️ 大屏 1440 × 900");
|
||||
AppendMenuW(hMenu, MF_STRING, ID_SIZE_1920x1080, L"📺 全高清 1920 × 1080");
|
||||
|
||||
// 获取鼠标位置(菜单显示在鼠标位置)
|
||||
POINT cursor_pos;
|
||||
GetCursorPos(&cursor_pos);
|
||||
|
||||
// 设置菜单为右对齐、右键选择
|
||||
UINT flags = TPM_LEFTALIGN | TPM_TOPALIGN | TPM_LEFTBUTTON |
|
||||
TPM_RIGHTBUTTON | TPM_RETURNCMD;
|
||||
|
||||
// 弹出菜单(同步阻塞,返回选择的菜单项 ID,0 表示取消)
|
||||
int cmd = TrackPopupMenuEx(hMenu, flags, cursor_pos.x, cursor_pos.y, hwnd,
|
||||
nullptr);
|
||||
DestroyMenu(hMenu);
|
||||
|
||||
if (cmd == 0) return false; // 用户取消
|
||||
|
||||
// 处理用户选择
|
||||
int width = 0, height = 0;
|
||||
switch (cmd) {
|
||||
case ID_TOGGLE_MAXIMIZE:
|
||||
if (is_maximized) {
|
||||
PostMessage(hwnd, WM_SYSCOMMAND, SC_RESTORE, 0);
|
||||
} else {
|
||||
PostMessage(hwnd, WM_SYSCOMMAND, SC_MAXIMIZE, 0);
|
||||
}
|
||||
return true;
|
||||
case ID_SIZE_800x600: width = 800; height = 600; break;
|
||||
case ID_SIZE_1024x768: width = 1024; height = 768; break;
|
||||
case ID_SIZE_1280x720: width = 1280; height = 720; break;
|
||||
case ID_SIZE_1440x900: width = 1440; height = 900; break;
|
||||
case ID_SIZE_1920x1080: width = 1920; height = 1080; break;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
// 取消最大化(如果当前是最大化状态)
|
||||
if (is_maximized) {
|
||||
ShowWindow(hwnd, SW_RESTORE);
|
||||
}
|
||||
|
||||
// 获取窗口当前位置,保持左上角不变
|
||||
RECT wr;
|
||||
GetWindowRect(hwnd, &wr);
|
||||
|
||||
// 调整为指定大小(考虑 DPI 缩放)
|
||||
UINT dpi = GetDpiForWindow(hwnd);
|
||||
int physical_width = static_cast<int>(width * dpi / 96.0);
|
||||
int physical_height = static_cast<int>(height * dpi / 96.0);
|
||||
|
||||
SetWindowPos(hwnd, nullptr, wr.left, wr.top,
|
||||
physical_width, physical_height,
|
||||
SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Flutter 子窗口子类化回调
|
||||
//
|
||||
// 核心思路:
|
||||
// 1. 标题栏可拖拽区域返回 HTTRANSPARENT,让父窗口的 WM_NCHITTEST
|
||||
// 返回 HTCAPTION,Windows 随后进入原生模态拖拽循环。
|
||||
// 2. 标题栏右侧控制按钮区域返回 HTCLIENT,保证 Flutter 按钮可点击。
|
||||
// 3. 其他区域返回 DefSubclassProc,由 Flutter 正常处理。
|
||||
//
|
||||
// 注意:窗口边框缩放由父窗口原生非客户区处理,不需要子窗口干预。
|
||||
// ============================================================
|
||||
LRESULT CALLBACK FlutterWindow::ChildWndProc(
|
||||
HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam,
|
||||
UINT_PTR subclass_id, DWORD_PTR ref_data) {
|
||||
HWND top_hwnd = reinterpret_cast<HWND>(ref_data);
|
||||
|
||||
// ============================================================
|
||||
// 拖拽期间:拦截 Flutter 子窗口的所有消息(除了 WM_NCHITTEST)
|
||||
//
|
||||
// Flutter 子窗口覆盖整个客户区,在拖拽期间会持续接收消息
|
||||
// (WM_PAINT/WM_MOVE/WM_SIZE/WM_WINDOWPOSCHANGED 等)并触发
|
||||
// Flutter 引擎重绘,阻塞 Win32 消息循环(= UI 线程),导致
|
||||
// DWM 无法应用 live-drag 优化。
|
||||
//
|
||||
// 拖拽期间所有消息直接交给 DefWindowProc(不经过 Flutter 引擎的
|
||||
// WndProc),阻止 Flutter 重绘。WM_NCHITTEST 仍需正常处理,
|
||||
// 否则鼠标在标题栏的命中测试会失败。
|
||||
// ============================================================
|
||||
if (is_in_native_drag_ && message != WM_NCHITTEST) {
|
||||
return DefWindowProc(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
// region debug-point child-msg
|
||||
if (message == WM_NCHITTEST || message == WM_NCLBUTTONDOWN ||
|
||||
message == WM_NCLBUTTONUP || message == WM_NCMOUSEMOVE ||
|
||||
message == WM_LBUTTONDOWN || message == WM_LBUTTONUP ||
|
||||
message == WM_MOUSEMOVE || message == WM_MOVE || message == WM_MOVING ||
|
||||
message == WM_SIZE || message == WM_WINDOWPOSCHANGING ||
|
||||
message == WM_WINDOWPOSCHANGED || message == WM_SYSCOMMAND ||
|
||||
message == WM_ENTERSIZEMOVE || message == WM_EXITSIZEMOVE ||
|
||||
message == WM_PAINT || message == WM_ERASEBKGND) {
|
||||
std::ostringstream oss;
|
||||
oss << "hwnd=" << hwnd << " msg=" << MsgName(message)
|
||||
<< " wp=" << wparam << " lp=" << lparam;
|
||||
DebugLog("child", oss.str());
|
||||
}
|
||||
// endregion debug-point child-msg
|
||||
|
||||
switch (message) {
|
||||
case WM_NCHITTEST: {
|
||||
POINT pt = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
|
||||
ScreenToClient(hwnd, &pt);
|
||||
|
||||
UINT dpi = GetDpiForWindow(top_hwnd);
|
||||
double scale = static_cast<double>(dpi) / 96.0;
|
||||
int title_h = static_cast<int>(36 * scale);
|
||||
int btn_w = static_cast<int>(138 * scale);
|
||||
RECT cr;
|
||||
GetClientRect(top_hwnd, &cr);
|
||||
|
||||
{
|
||||
std::ostringstream oss;
|
||||
oss << "WM_NCHITTEST raw_screen=(" << GET_X_LPARAM(lparam) << ","
|
||||
<< GET_Y_LPARAM(lparam) << ") client=(" << pt.x << "," << pt.y
|
||||
<< ") dpi=" << dpi << " scale=" << scale
|
||||
<< " title_h=" << title_h << " btn_w=" << btn_w
|
||||
<< " cr=(" << cr.right << "," << cr.bottom << ")";
|
||||
DebugLog("child", oss.str());
|
||||
}
|
||||
|
||||
if (cr.right <= 0 || cr.bottom <= 0) {
|
||||
DebugLog("child", "WM_NCHITTEST fallback: empty client rect");
|
||||
return DefSubclassProc(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
// 标题栏可拖拽区域:穿透到父窗口,父窗口会返回 HTCAPTION
|
||||
if (pt.y >= 0 && pt.y < title_h &&
|
||||
pt.x >= 0 && pt.x < cr.right - btn_w) {
|
||||
std::ostringstream oss;
|
||||
oss << "WM_NCHITTEST -> HTTRANSPARENT pt=(" << pt.x << "," << pt.y
|
||||
<< ")";
|
||||
DebugLog("child", oss.str());
|
||||
return HTTRANSPARENT;
|
||||
}
|
||||
|
||||
// 标题栏右侧控制按钮区域:保持 HTCLIENT,让 Flutter 处理点击
|
||||
if (pt.y >= 0 && pt.y < title_h &&
|
||||
pt.x >= cr.right - btn_w && pt.x <= cr.right) {
|
||||
DebugLog("child", "WM_NCHITTEST -> HTCLIENT (buttons)");
|
||||
return HTCLIENT;
|
||||
}
|
||||
|
||||
DebugLog("child", "WM_NCHITTEST -> DefSubclassProc");
|
||||
return DefSubclassProc(hwnd, message, wparam, lparam);
|
||||
}
|
||||
}
|
||||
|
||||
return DefSubclassProc(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
LRESULT
|
||||
FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
// ============================================================
|
||||
// 异步弹出窗口大小预设菜单
|
||||
// MethodChannel 回调中通过 PostMessage 触发,避免 TrackPopupMenuEx
|
||||
// 阻塞 Flutter UI 线程
|
||||
// ============================================================
|
||||
if (message == WM_USER + 1000) {
|
||||
ShowWindowSizeMenu(hwnd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 拖拽卡顿修复 v9:WM_NCLBUTTONDOWN 时禁用 Mica + SetWindowCompositionAttribute
|
||||
//
|
||||
// 根因链(经 v1~v8 验证):
|
||||
// 1. 手动 SetWindowPos 拖拽不触发 DWM "live drag" 优化
|
||||
// 2. 完全禁用 Mica 能解决卡顿(v8 验证),但临时禁用 Mica(v4~v7)失败
|
||||
// 3. v4~v7 失败的原因:EnterSizeMove 遗漏了 SetWindowCompositionAttribute
|
||||
// (ACCENT_DISABLED) 调用——这是 flutter_acrylic 的 setEffect(disabled)
|
||||
// 能立即禁用 Mica 的关键。仅靠 DwmSetWindowAttribute 是异步的,不生效。
|
||||
//
|
||||
// v9 修复:
|
||||
// - WM_NCLBUTTONDOWN(HTCAPTION) 时(拖拽还没开始)调用 EnterSizeMove 禁用 Mica
|
||||
// (现在包含 SetWindowCompositionAttribute(ACCENT_DISABLED),能立即生效)
|
||||
// - SWP_FRAMECHANGED 强制 DWM 同步应用变更
|
||||
// - DefWindowProc 进入 SC_MOVE 模态循环
|
||||
// - DefWindowProc 返回后(拖拽已结束)调用 ExitSizeMove 恢复 Mica
|
||||
// - 模态循环期间绕过插件链 + 子窗口消息拦截(v8)
|
||||
// ============================================================
|
||||
|
||||
// --- 标题栏 + 边框命中测试:直接返回,绕过插件 ---
|
||||
// flutter_acrylic 的 DwmExtendFrameIntoClientArea({-1,-1,-1,-1}) 和
|
||||
// window_manager 的 TitleBarStyle.hidden 会导致 DefWindowProc 的边框
|
||||
// 命中测试失效。这里手动实现边框命中测试,恢复窗口边缘调整大小功能。
|
||||
if (message == WM_NCHITTEST) {
|
||||
POINT pt = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
|
||||
|
||||
UINT dpi = GetDpiForWindow(hwnd);
|
||||
double scale = static_cast<double>(dpi) / 96.0;
|
||||
int title_h = static_cast<int>(36.0 * scale);
|
||||
int btn_w = static_cast<int>(138.0 * scale);
|
||||
// 边框命中测试宽度(稍大于实际边框,方便用户操作)
|
||||
int border_w = static_cast<int>(6.0 * scale);
|
||||
|
||||
// 使用窗口坐标(而非客户区坐标),避免客户区边距导致的负坐标问题
|
||||
RECT wr;
|
||||
GetWindowRect(hwnd, &wr);
|
||||
|
||||
bool maximized = (::IsZoomed(hwnd) != 0);
|
||||
|
||||
if (!maximized) {
|
||||
bool on_left = pt.x >= wr.left && pt.x < wr.left + border_w;
|
||||
bool on_right = pt.x >= wr.right - border_w && pt.x < wr.right;
|
||||
bool on_top = pt.y >= wr.top && pt.y < wr.top + border_w;
|
||||
bool on_bottom = pt.y >= wr.bottom - border_w && pt.y < wr.bottom;
|
||||
|
||||
if (on_top && on_left) return HTTOPLEFT;
|
||||
if (on_top && on_right) return HTTOPRIGHT;
|
||||
if (on_bottom && on_left) return HTBOTTOMLEFT;
|
||||
if (on_bottom && on_right) return HTBOTTOMRIGHT;
|
||||
if (on_left) return HTLEFT;
|
||||
if (on_right) return HTRIGHT;
|
||||
if (on_top) return HTTOP;
|
||||
if (on_bottom) return HTBOTTOM;
|
||||
}
|
||||
|
||||
// 标题栏拖拽区域(使用客户区坐标判断)
|
||||
POINT client_pt = pt;
|
||||
ScreenToClient(hwnd, &client_pt);
|
||||
RECT cr;
|
||||
GetClientRect(hwnd, &cr);
|
||||
|
||||
if (cr.right > 0 && cr.bottom > 0) {
|
||||
if (client_pt.y >= 0 && client_pt.y < title_h &&
|
||||
client_pt.x >= 0 && client_pt.x < cr.right - btn_w) {
|
||||
return HTCAPTION;
|
||||
}
|
||||
}
|
||||
// 非标题栏/边框区域:继续交给插件处理(返回 HTCLIENT)
|
||||
}
|
||||
|
||||
// --- 标题栏拖拽启动:提前禁用 Mica,再进入原生 SC_MOVE 模态循环 ---
|
||||
// 关键:在 WM_NCLBUTTONDOWN 时(拖拽还没开始)禁用 Mica。
|
||||
// EnterSizeMove 现在包含 SetWindowCompositionAttribute(ACCENT_DISABLED),
|
||||
// 能立即禁用 Mica(与 flutter_acrylic 的 setEffect(disabled) 一致)。
|
||||
if (message == WM_NCLBUTTONDOWN && wparam == HTCAPTION) {
|
||||
// 1. 拖拽开始前禁用 Mica/Acrylic backdrop(立即生效)
|
||||
EnterSizeMove(hwnd);
|
||||
// 2. SWP_FRAMECHANGED 触发 WM_NCCALCSIZE,强制 DWM 同步应用变更
|
||||
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE |
|
||||
SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
// 3. 进入原生 SC_MOVE 模态循环(同步阻塞,返回时拖拽已结束)
|
||||
LRESULT drag_result = DefWindowProc(hwnd, message, wparam, lparam);
|
||||
// 4. 拖拽结束后恢复 Mica/Acrylic backdrop
|
||||
ExitSizeMove(hwnd);
|
||||
// 5. SWP_FRAMECHANGED 强制 DWM 同步应用 Mica 恢复
|
||||
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE |
|
||||
SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
return drag_result;
|
||||
}
|
||||
|
||||
// --- 原生拖拽/调整大小模态循环开始 ---
|
||||
// 无论是拖拽标题栏(已在 WM_NCLBUTTONDOWN 提前禁用 Mica)还是从边框调整大小,
|
||||
// 都会触发 WM_ENTERSIZEMOVE。对于边框调整大小的情况,这里禁用 Mica。
|
||||
if (message == WM_ENTERSIZEMOVE) {
|
||||
is_in_native_drag_ = true;
|
||||
// 如果还没有禁用 Mica(边框调整大小的情况),现在禁用
|
||||
if (!is_in_size_move_loop_) {
|
||||
EnterSizeMove(hwnd);
|
||||
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE |
|
||||
SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
return DefWindowProc(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
// --- 原生拖拽/调整大小模态循环结束 ---
|
||||
if (message == WM_EXITSIZEMOVE) {
|
||||
is_in_native_drag_ = false;
|
||||
// 如果之前禁用了 Mica(边框调整大小的情况),现在恢复
|
||||
if (is_in_size_move_loop_) {
|
||||
ExitSizeMove(hwnd);
|
||||
SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
|
||||
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE |
|
||||
SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
return DefWindowProc(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 原生拖拽期间:完全绕过插件链
|
||||
//
|
||||
// window_manager 插件在 WM_MOVING 中调用 _EmitEvent("move"),
|
||||
// 通过 MethodChannel 通知 Dart 侧。Dart 侧的 listener 可能触发
|
||||
// setState → Flutter 重建 → 阻塞 UI 线程(= Win32 消息循环线程)。
|
||||
// 22 个插件的 delegate 分发也会累积延迟。
|
||||
//
|
||||
// 拖拽期间所有消息直接交给 DefWindowProc,让 DWM live-drag 优化
|
||||
// 不受任何干扰。同时 ChildWndProc 也会拦截 Flutter 子窗口的消息,
|
||||
// 阻止 Flutter 引擎重绘。此时 Mica 已被 EnterSizeMove 禁用。
|
||||
// ============================================================
|
||||
if (is_in_native_drag_) {
|
||||
return DefWindowProc(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
// --- 非拖拽期间:正常消息流转 ---
|
||||
// Give Flutter, including plugins, an opportunity to handle window messages.
|
||||
if (flutter_controller_) {
|
||||
std::optional<LRESULT> result =
|
||||
|
||||
@@ -27,11 +27,48 @@ class FlutterWindow : public Win32Window {
|
||||
// The project to run.
|
||||
flutter::DartProject project_;
|
||||
|
||||
// The Flutter instance hosted by this window.
|
||||
// The Flutter instance hosted in this window.
|
||||
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
|
||||
|
||||
// Windows platform MethodChannel for theme control.
|
||||
std::unique_ptr<flutter::MethodChannel<>> platform_channel_;
|
||||
|
||||
// 窗口控制 MethodChannel(窗口大小预设菜单等)
|
||||
std::unique_ptr<flutter::MethodChannel<>> window_control_channel_;
|
||||
|
||||
// 弹出原生窗口大小预设菜单(TrackPopupMenuEx)
|
||||
// 返回 true 表示用户选择了某个尺寸,false 表示用户取消了菜单
|
||||
bool ShowWindowSizeMenu(HWND hwnd);
|
||||
|
||||
// Subclass ID for the Flutter child window (WM_NCHITTEST → HTTRANSPARENT)
|
||||
static const UINT_PTR kChildSubclassId = 12345;
|
||||
|
||||
// Subclass callback: forward title-bar hit-test to parent window so the
|
||||
// parent can return HTCAPTION and Windows performs native modal dragging.
|
||||
static LRESULT CALLBACK ChildWndProc(HWND hwnd, UINT message,
|
||||
WPARAM wparam, LPARAM lparam,
|
||||
UINT_PTR subclass_id,
|
||||
DWORD_PTR ref_data);
|
||||
|
||||
// ============================================================
|
||||
// 原生拖拽状态:SC_MOVE 模态循环期间绕过插件链 + 子窗口消息拦截
|
||||
//
|
||||
// 根因(经 v1~v8 验证):
|
||||
// 1. 手动 SetWindowPos 拖拽不触发 DWM "live drag" 优化
|
||||
// 2. 完全禁用 Mica 能解决卡顿(v8 验证),但临时禁用 Mica(v4~v7)失败
|
||||
// 3. v4~v7 失败的原因:EnterSizeMove 遗漏了 SetWindowCompositionAttribute
|
||||
// (ACCENT_DISABLED) 调用——这是 flutter_acrylic 的 setEffect(disabled)
|
||||
// 能立即禁用 Mica 的关键。仅靠 DwmSetWindowAttribute 是异步的,不生效。
|
||||
//
|
||||
// 修复(v9):
|
||||
// - WM_NCLBUTTONDOWN(HTCAPTION) 时调用 EnterSizeMove 禁用 Mica
|
||||
// (现在包含 SetWindowCompositionAttribute(ACCENT_DISABLED),能立即生效)
|
||||
// - SWP_FRAMECHANGED 强制 DWM 同步应用变更
|
||||
// - DefWindowProc 进入 SC_MOVE 模态循环
|
||||
// - DefWindowProc 返回后调用 ExitSizeMove 恢复 Mica + SWP_FRAMECHANGED
|
||||
// - 模态循环期间绕过插件链 + 子窗口消息拦截(v8)
|
||||
// ============================================================
|
||||
static bool is_in_native_drag_;
|
||||
};
|
||||
|
||||
#endif // RUNNER_FLUTTER_WINDOW_H_
|
||||
|
||||
@@ -3,9 +3,132 @@
|
||||
#include <dwmapi.h>
|
||||
#include <flutter_windows.h>
|
||||
#include <shellscalingapi.h>
|
||||
#include <windowsx.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include "resource.h"
|
||||
|
||||
// ============================================================
|
||||
// WindowCompositionAttribute 相关定义(与 flutter_acrylic 一致)
|
||||
// 用于调用 user32.dll 的未公开 API SetWindowCompositionAttribute
|
||||
// 这是 flutter_acrylic 禁用 Mica/Acrylic 的关键调用
|
||||
// ============================================================
|
||||
typedef enum _WCA_WINDOWCOMPOSITIONATTRIB {
|
||||
WCA_ATTRIB_ACCENT_POLICY = 19,
|
||||
} WCA_WINDOWCOMPOSITIONATTRIB;
|
||||
|
||||
typedef enum _WCA_ACCENT_STATE {
|
||||
WCA_ACCENT_DISABLED = 0,
|
||||
WCA_ACCENT_ENABLE_ACRYLICBLURBEHIND = 4,
|
||||
WCA_ACCENT_ENABLE_HOSTBACKDROP = 5,
|
||||
} WCA_ACCENT_STATE;
|
||||
|
||||
typedef struct _WCA_ACCENT_POLICY {
|
||||
WCA_ACCENT_STATE AccentState;
|
||||
DWORD AccentFlags;
|
||||
DWORD GradientColor;
|
||||
DWORD AnimationId;
|
||||
} WCA_ACCENT_POLICY;
|
||||
|
||||
typedef struct _WCA_WINDOWCOMPOSITIONATTRIBDATA {
|
||||
WCA_WINDOWCOMPOSITIONATTRIB Attrib;
|
||||
PVOID pvData;
|
||||
SIZE_T cbData;
|
||||
} WCA_WINDOWCOMPOSITIONATTRIBDATA;
|
||||
|
||||
typedef BOOL(WINAPI* WCA_SetWindowCompositionAttribute)(
|
||||
HWND, WCA_WINDOWCOMPOSITIONATTRIBDATA*);
|
||||
|
||||
// ============================================================
|
||||
// debug instrumentation for window-drag-lag
|
||||
// ============================================================
|
||||
namespace {
|
||||
std::mutex g_debug_log_mutex;
|
||||
std::wstring g_debug_log_path;
|
||||
|
||||
void DebugLog(const std::string& tag, const std::string& detail) {
|
||||
// 已禁用文件日志以排除 I/O 对消息循环的干扰。
|
||||
(void)tag;
|
||||
(void)detail;
|
||||
#if 0
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
now.time_since_epoch())
|
||||
.count();
|
||||
std::lock_guard<std::mutex> lock(g_debug_log_mutex);
|
||||
if (g_debug_log_path.empty()) {
|
||||
g_debug_log_path = L"E:\\project\\flutter\\f\\xianyan\\xianyan_drag_debug.log";
|
||||
}
|
||||
std::wofstream ofs(g_debug_log_path, std::ios::app);
|
||||
if (!ofs.is_open()) return;
|
||||
ofs << us << L" [" << std::wstring(tag.begin(), tag.end()) << L"] "
|
||||
<< std::wstring(detail.begin(), detail.end()) << L"\n";
|
||||
#endif
|
||||
}
|
||||
|
||||
// 仅记录拖拽状态关键事件,验证 EnterSizeMove/ExitSizeMove 是否被调用。
|
||||
void DragDebugLog(const std::string& line) {
|
||||
static std::mutex m;
|
||||
static std::wstring path =
|
||||
L"E:\\project\\flutter\\f\\xianyan\\xianyan_drag_state.log";
|
||||
std::lock_guard<std::mutex> lock(m);
|
||||
std::ofstream ofs(path, std::ios::app);
|
||||
if (!ofs.is_open()) return;
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now.time_since_epoch())
|
||||
.count();
|
||||
ofs << ms << " " << line << "\n";
|
||||
}
|
||||
|
||||
const char* HitTestName(LRESULT ht) {
|
||||
switch (ht) {
|
||||
case HTNOWHERE: return "HTNOWHERE";
|
||||
case HTCLIENT: return "HTCLIENT";
|
||||
case HTCAPTION: return "HTCAPTION";
|
||||
case HTLEFT: return "HTLEFT";
|
||||
case HTRIGHT: return "HTRIGHT";
|
||||
case HTTOP: return "HTTOP";
|
||||
case HTBOTTOM: return "HTBOTTOM";
|
||||
case HTTOPLEFT: return "HTTOPLEFT";
|
||||
case HTTOPRIGHT: return "HTTOPRIGHT";
|
||||
case HTBOTTOMLEFT: return "HTBOTTOMLEFT";
|
||||
case HTBOTTOMRIGHT: return "HTBOTTOMRIGHT";
|
||||
case HTTRANSPARENT: return "HTTRANSPARENT";
|
||||
default: return "OTHER";
|
||||
}
|
||||
}
|
||||
|
||||
std::string MsgName(UINT msg) {
|
||||
switch (msg) {
|
||||
case WM_NCHITTEST: return "WM_NCHITTEST";
|
||||
case WM_NCLBUTTONDOWN: return "WM_NCLBUTTONDOWN";
|
||||
case WM_NCLBUTTONUP: return "WM_NCLBUTTONUP";
|
||||
case WM_NCMOUSEMOVE: return "WM_NCMOUSEMOVE";
|
||||
case WM_LBUTTONDOWN: return "WM_LBUTTONDOWN";
|
||||
case WM_LBUTTONUP: return "WM_LBUTTONUP";
|
||||
case WM_MOUSEMOVE: return "WM_MOUSEMOVE";
|
||||
case WM_MOVE: return "WM_MOVE";
|
||||
case WM_MOVING: return "WM_MOVING";
|
||||
case WM_SIZE: return "WM_SIZE";
|
||||
case WM_WINDOWPOSCHANGING: return "WM_WINDOWPOSCHANGING";
|
||||
case WM_WINDOWPOSCHANGED: return "WM_WINDOWPOSCHANGED";
|
||||
case WM_SYSCOMMAND: return "WM_SYSCOMMAND";
|
||||
case WM_ENTERSIZEMOVE: return "WM_ENTERSIZEMOVE";
|
||||
case WM_EXITSIZEMOVE: return "WM_EXITSIZEMOVE";
|
||||
case WM_PAINT: return "WM_PAINT";
|
||||
case WM_ERASEBKGND: return "WM_ERASEBKGND";
|
||||
default: return "MSG_" + std::to_string(msg);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
|
||||
/// Window attribute that enables dark mode window decorations.
|
||||
@@ -17,6 +140,37 @@ namespace {
|
||||
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
||||
#endif
|
||||
|
||||
// DWM 过渡动画与背景材质类型(兼容旧版 SDK)
|
||||
#ifndef DWMWA_TRANSITIONS_FORCEDISABLED
|
||||
#define DWMWA_TRANSITIONS_FORCEDISABLED 3
|
||||
#endif
|
||||
#ifndef DWMWA_SYSTEMBACKDROP_TYPE
|
||||
#define DWMWA_SYSTEMBACKDROP_TYPE 38
|
||||
#endif
|
||||
#ifndef DWMWA_MICA_EFFECT
|
||||
#define DWMWA_MICA_EFFECT 1029
|
||||
#endif
|
||||
|
||||
// RtlGetVersion 用于获取真实系统版本(GetVersionExW 在 Win8.1+ 会返回兼容性版本)
|
||||
typedef LONG NTSTATUS, *PNTSTATUS;
|
||||
#define STATUS_SUCCESS (0x00000000)
|
||||
typedef NTSTATUS(WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW);
|
||||
#ifndef DWMSBT_AUTO
|
||||
#define DWMSBT_AUTO 0
|
||||
#endif
|
||||
#ifndef DWMSBT_NONE
|
||||
#define DWMSBT_NONE 1
|
||||
#endif
|
||||
#ifndef DWMSBT_MAINWINDOW
|
||||
#define DWMSBT_MAINWINDOW 3
|
||||
#endif
|
||||
#ifndef DWMSBT_TRANSIENTWINDOW
|
||||
#define DWMSBT_TRANSIENTWINDOW 4
|
||||
#endif
|
||||
#ifndef DWMSBT_TABBEDWINDOW
|
||||
#define DWMSBT_TABBEDWINDOW 2
|
||||
#endif
|
||||
|
||||
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
|
||||
|
||||
/// Registry key for app theme preference.
|
||||
@@ -92,7 +246,7 @@ const wchar_t* WindowClassRegistrar::GetWindowClass() {
|
||||
WNDCLASS window_class{};
|
||||
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
|
||||
window_class.lpszClassName = kWindowClassName;
|
||||
window_class.style = CS_HREDRAW | CS_VREDRAW;
|
||||
window_class.style = CS_DBLCLKS;
|
||||
window_class.cbClsExtra = 0;
|
||||
window_class.cbWndExtra = 0;
|
||||
window_class.hInstance = GetModuleHandle(nullptr);
|
||||
@@ -136,7 +290,8 @@ bool Win32Window::Create(const std::wstring& title,
|
||||
double scale_factor = dpi / 96.0;
|
||||
|
||||
HWND window = CreateWindow(
|
||||
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
|
||||
window_class, title.c_str(),
|
||||
WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN,
|
||||
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
|
||||
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
|
||||
nullptr, nullptr, GetModuleHandle(nullptr), this);
|
||||
@@ -145,6 +300,8 @@ bool Win32Window::Create(const std::wstring& title,
|
||||
return false;
|
||||
}
|
||||
|
||||
DetectBackdropCapabilities(&system_backdrop_supported_,
|
||||
&mica_effect_supported_);
|
||||
UpdateTheme(window);
|
||||
|
||||
return OnCreate();
|
||||
@@ -179,6 +336,22 @@ Win32Window::MessageHandler(HWND hwnd,
|
||||
UINT const message,
|
||||
WPARAM const wparam,
|
||||
LPARAM const lparam) noexcept {
|
||||
// region debug-point parent-msg
|
||||
if (message == WM_NCHITTEST || message == WM_NCLBUTTONDOWN ||
|
||||
message == WM_NCLBUTTONUP || message == WM_NCMOUSEMOVE ||
|
||||
message == WM_LBUTTONDOWN || message == WM_LBUTTONUP ||
|
||||
message == WM_MOUSEMOVE || message == WM_MOVE || message == WM_MOVING ||
|
||||
message == WM_SIZE || message == WM_WINDOWPOSCHANGING ||
|
||||
message == WM_WINDOWPOSCHANGED || message == WM_SYSCOMMAND ||
|
||||
message == WM_ENTERSIZEMOVE || message == WM_EXITSIZEMOVE ||
|
||||
message == WM_ERASEBKGND) {
|
||||
std::ostringstream oss;
|
||||
oss << "hwnd=" << hwnd << " msg=" << MsgName(message)
|
||||
<< " wp=" << wparam << " lp=" << lparam;
|
||||
DebugLog("parent", oss.str());
|
||||
}
|
||||
// endregion debug-point parent-msg
|
||||
|
||||
switch (message) {
|
||||
case WM_DESTROY:
|
||||
window_handle_ = nullptr;
|
||||
@@ -201,9 +374,11 @@ Win32Window::MessageHandler(HWND hwnd,
|
||||
case WM_SIZE: {
|
||||
RECT rect = GetClientArea();
|
||||
if (child_content_ != nullptr) {
|
||||
// Size and position the child window.
|
||||
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
|
||||
rect.bottom - rect.top, TRUE);
|
||||
// DwmExtendFrameIntoClientArea(margins={-1}) 会改变窗口帧布局,
|
||||
// 但 GetClientRect 始终返回正确的客户区尺寸(左上角为 0,0),
|
||||
// 所以 MoveWindow 从 (0,0) 开始填充整个客户区是正确的。
|
||||
// 关键:确保 bRepaint=TRUE 以立即重绘。
|
||||
MoveWindow(child_content_, 0, 0, rect.right, rect.bottom, TRUE);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -218,6 +393,30 @@ Win32Window::MessageHandler(HWND hwnd,
|
||||
UpdateTheme(hwnd);
|
||||
return 0;
|
||||
|
||||
case WM_ERASEBKGND: {
|
||||
UINT64 enter = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
LRESULT r = DefWindowProc(hwnd, message, wparam, lparam);
|
||||
UINT64 now = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
std::ostringstream oss;
|
||||
oss << "WM_ERASEBKGND duration_us=" << (now - enter);
|
||||
DebugLog("parent", oss.str());
|
||||
return r;
|
||||
}
|
||||
|
||||
case WM_ENTERSIZEMOVE: {
|
||||
EnterSizeMove(hwnd);
|
||||
break;
|
||||
}
|
||||
|
||||
case WM_EXITSIZEMOVE: {
|
||||
ExitSizeMove(hwnd);
|
||||
break;
|
||||
}
|
||||
|
||||
case WM_GETMINMAXINFO: {
|
||||
// 处理窗口最小尺寸限制
|
||||
MINMAXINFO* mmi = reinterpret_cast<MINMAXINFO*>(lparam);
|
||||
@@ -233,6 +432,61 @@ Win32Window::MessageHandler(HWND hwnd,
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_NCHITTEST: {
|
||||
// 标题栏可拖拽区域返回 HTCAPTION,让 Windows 进入原生模态拖拽循环。
|
||||
// 原生拖拽由 DWM 直接处理,不会经过 Flutter 插件链,避免 MethodChannel
|
||||
// 和 Dart setState 导致的延迟。
|
||||
POINT pt = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
|
||||
ScreenToClient(hwnd, &pt);
|
||||
|
||||
UINT dpi = GetDpiForWindow(hwnd);
|
||||
double scale = static_cast<double>(dpi) / 96.0;
|
||||
int title_h = static_cast<int>(title_bar_height_ * scale);
|
||||
int btn_w = static_cast<int>(title_bar_button_width_ * scale);
|
||||
RECT cr;
|
||||
GetClientRect(hwnd, &cr);
|
||||
|
||||
{
|
||||
std::ostringstream oss;
|
||||
oss << "WM_NCHITTEST raw_screen=(" << GET_X_LPARAM(lparam) << ","
|
||||
<< GET_Y_LPARAM(lparam) << ") client=(" << pt.x << "," << pt.y
|
||||
<< ") dpi=" << dpi << " scale=" << scale
|
||||
<< " title_h=" << title_h << " btn_w=" << btn_w
|
||||
<< " cr=(" << cr.right << "," << cr.bottom << ")";
|
||||
DebugLog("parent", oss.str());
|
||||
}
|
||||
|
||||
if (cr.right <= 0 || cr.bottom <= 0) {
|
||||
DebugLog("parent", "WM_NCHITTEST fallback: empty client rect");
|
||||
break;
|
||||
}
|
||||
|
||||
if (pt.y >= 0 && pt.y < title_h &&
|
||||
pt.x >= 0 && pt.x < cr.right - btn_w) {
|
||||
std::ostringstream oss;
|
||||
oss << "WM_NCHITTEST -> HTCAPTION pt=(" << pt.x << "," << pt.y
|
||||
<< ") title_h=" << title_h << " btn_w=" << btn_w
|
||||
<< " cr=(" << cr.right << "," << cr.bottom << ")";
|
||||
DebugLog("parent", oss.str());
|
||||
return HTCAPTION;
|
||||
}
|
||||
std::ostringstream oss;
|
||||
oss << "WM_NCHITTEST -> Def pt=(" << pt.x << "," << pt.y
|
||||
<< ") title_h=" << title_h << " btn_w=" << btn_w;
|
||||
DebugLog("parent", oss.str());
|
||||
break;
|
||||
}
|
||||
|
||||
case WM_NCLBUTTONDOWN: {
|
||||
// 标题栏 HTCAPTION 的拖拽已在 FlutterWindow::MessageHandler 中
|
||||
// 前置处理(先禁用 DWM 效果再进入原生拖拽循环)。
|
||||
// 此处仅处理非标题栏区域点击,由 DefWindowProc 处理窗口边框调整大小等。
|
||||
std::ostringstream oss;
|
||||
oss << "WM_NCLBUTTONDOWN ht=" << HitTestName(static_cast<LRESULT>(wparam));
|
||||
DebugLog("parent", oss.str());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return DefWindowProc(window_handle_, message, wparam, lparam);
|
||||
@@ -260,8 +514,7 @@ void Win32Window::SetChildContent(HWND content) {
|
||||
SetParent(content, window_handle_);
|
||||
RECT frame = GetClientArea();
|
||||
|
||||
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
|
||||
frame.bottom - frame.top, true);
|
||||
MoveWindow(content, 0, 0, frame.right, frame.bottom, true);
|
||||
|
||||
SetFocus(child_content_);
|
||||
}
|
||||
@@ -321,6 +574,8 @@ LONG Win32Window::saved_window_ex_style_ = 0;
|
||||
WINDOWPLACEMENT Win32Window::saved_placement_ = {sizeof(WINDOWPLACEMENT)};
|
||||
unsigned int Win32Window::min_width_ = 400;
|
||||
unsigned int Win32Window::min_height_ = 600;
|
||||
double Win32Window::title_bar_height_ = 36.0;
|
||||
double Win32Window::title_bar_button_width_ = 138.0;
|
||||
|
||||
// ============================================================
|
||||
// 窗口管理扩展方法实现
|
||||
@@ -422,3 +677,166 @@ std::string Win32Window::GetSystemAppearance() {
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 拖拽时临时禁用 DWM Mica/Acrylic 合成,避免拖拽卡顿
|
||||
// ============================================================
|
||||
|
||||
void Win32Window::DetectBackdropCapabilities(bool* system_backdrop,
|
||||
bool* mica_effect) {
|
||||
*system_backdrop = false;
|
||||
*mica_effect = false;
|
||||
|
||||
// 使用 RtlGetVersion 获取真实系统版本,避免 GetVersionExW 的兼容性谎言。
|
||||
RTL_OSVERSIONINFOW osvi = {0};
|
||||
osvi.dwOSVersionInfoSize = sizeof(osvi);
|
||||
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
|
||||
if (ntdll) {
|
||||
auto rtl_get_version = reinterpret_cast<RtlGetVersionPtr>(
|
||||
GetProcAddress(ntdll, "RtlGetVersion"));
|
||||
if (rtl_get_version && STATUS_SUCCESS != rtl_get_version(&osvi)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (osvi.dwMajorVersion < 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
HWND hwnd = GetDesktopWindow();
|
||||
if (osvi.dwBuildNumber >= 22523) {
|
||||
INT probe = DWMSBT_AUTO;
|
||||
*system_backdrop = SUCCEEDED(DwmSetWindowAttribute(
|
||||
hwnd, DWMWA_SYSTEMBACKDROP_TYPE, &probe, sizeof(probe)));
|
||||
}
|
||||
if (osvi.dwBuildNumber >= 22000) {
|
||||
BOOL probe = FALSE;
|
||||
*mica_effect = SUCCEEDED(DwmSetWindowAttribute(
|
||||
hwnd, DWMWA_MICA_EFFECT, &probe, sizeof(probe)));
|
||||
}
|
||||
}
|
||||
|
||||
int Win32Window::GetCurrentBackdropType(HWND hwnd) {
|
||||
INT value = -1;
|
||||
HRESULT hr = DwmGetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE,
|
||||
&value, sizeof(value));
|
||||
if (SUCCEEDED(hr)) {
|
||||
return static_cast<int>(value);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void Win32Window::SetBackdropType(HWND hwnd, int type) {
|
||||
INT value = static_cast<INT>(type);
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, &value, sizeof(value));
|
||||
}
|
||||
|
||||
void Win32Window::SetTransitionsEnabled(HWND hwnd, BOOL enabled) {
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED,
|
||||
&enabled, sizeof(enabled));
|
||||
}
|
||||
|
||||
void Win32Window::EnterSizeMove(HWND hwnd) {
|
||||
if (is_in_size_move_loop_) return;
|
||||
is_in_size_move_loop_ = true;
|
||||
|
||||
DragDebugLog("EnterSizeMove begin");
|
||||
|
||||
// ============================================================
|
||||
// 关键:调用 SetWindowCompositionAttribute(ACCENT_DISABLED)
|
||||
//
|
||||
// 这是 flutter_acrylic 的 Window.setEffect(disabled) 能立即禁用 Mica
|
||||
// 的关键调用,之前的 v4~v7 修复都遗漏了这一步。
|
||||
//
|
||||
// SetWindowCompositionAttribute 是 user32.dll 的未公开 API,
|
||||
// 通过它设置窗口的 ACCENT_POLICY 为 ACCENT_DISABLED,可以立即禁用
|
||||
// 所有窗口合成效果(包括 Mica/Acrylic)。
|
||||
//
|
||||
// 仅靠 DwmSetWindowAttribute 是不够的——它是异步的,DWM 不会立即应用
|
||||
// 变更。SetWindowCompositionAttribute 能立即生效。
|
||||
// ============================================================
|
||||
{
|
||||
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||
if (user32) {
|
||||
auto set_window_composition_attribute =
|
||||
reinterpret_cast<WCA_SetWindowCompositionAttribute>(
|
||||
GetProcAddress(user32, "SetWindowCompositionAttribute"));
|
||||
if (set_window_composition_attribute) {
|
||||
WCA_ACCENT_POLICY accent = {WCA_ACCENT_DISABLED, 2, 0, 0};
|
||||
WCA_WINDOWCOMPOSITIONATTRIBDATA data;
|
||||
data.Attrib = WCA_ATTRIB_ACCENT_POLICY;
|
||||
data.pvData = &accent;
|
||||
data.cbData = sizeof(accent);
|
||||
set_window_composition_attribute(hwnd, &data);
|
||||
DragDebugLog("SetWindowCompositionAttribute(ACCENT_DISABLED) called");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 {0, 0, 1, 0} 而非 {0, 0, 0, 0}:
|
||||
// 与 flutter_acrylic 的 setEffect(disabled) 一致。
|
||||
// 注释:At least one margin should be non-negative in order to show
|
||||
// the DWM window shadow created by handling WM_NCCALCSIZE.
|
||||
saved_margins_ = {-1, -1, -1, -1};
|
||||
has_saved_margins_ = true;
|
||||
MARGINS drag_margins = {0, 0, 1, 0};
|
||||
DwmExtendFrameIntoClientArea(hwnd, &drag_margins);
|
||||
|
||||
// 禁用 System Backdrop(Win11 22523+)
|
||||
saved_backdrop_type_ = GetCurrentBackdropType(hwnd);
|
||||
DragDebugLog(std::string("saved_backdrop_type=") +
|
||||
std::to_string(saved_backdrop_type_));
|
||||
if (saved_backdrop_type_ > DWMSBT_NONE || saved_backdrop_type_ == -1) {
|
||||
SetBackdropType(hwnd, DWMSBT_NONE);
|
||||
}
|
||||
|
||||
// 禁用 Mica Effect(Win11 22000+)
|
||||
if (mica_effect_supported_) {
|
||||
DWORD value = FALSE;
|
||||
DwmGetWindowAttribute(hwnd, DWMWA_MICA_EFFECT, &saved_mica_enabled_,
|
||||
sizeof(saved_mica_enabled_));
|
||||
DragDebugLog(std::string("saved_mica_enabled=") +
|
||||
std::to_string(saved_mica_enabled_));
|
||||
if (saved_mica_enabled_) {
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_MICA_EFFECT, &value, sizeof(value));
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用 DWM 过渡动画,进一步降低合成延迟
|
||||
DwmGetWindowAttribute(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED,
|
||||
&saved_transitions_enabled_,
|
||||
sizeof(saved_transitions_enabled_));
|
||||
SetTransitionsEnabled(hwnd, TRUE);
|
||||
|
||||
DragDebugLog("EnterSizeMove done");
|
||||
}
|
||||
|
||||
void Win32Window::ExitSizeMove(HWND hwnd) {
|
||||
if (!is_in_size_move_loop_) return;
|
||||
is_in_size_move_loop_ = false;
|
||||
|
||||
DragDebugLog("ExitSizeMove begin");
|
||||
|
||||
// 恢复 backdrop
|
||||
if (system_backdrop_supported_ && saved_backdrop_type_ > DWMSBT_NONE) {
|
||||
SetBackdropType(hwnd, saved_backdrop_type_);
|
||||
saved_backdrop_type_ = -1;
|
||||
}
|
||||
|
||||
// 恢复 Mica effect
|
||||
if (mica_effect_supported_ && saved_mica_enabled_) {
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_MICA_EFFECT, &saved_mica_enabled_,
|
||||
sizeof(saved_mica_enabled_));
|
||||
saved_mica_enabled_ = FALSE;
|
||||
}
|
||||
|
||||
// 恢复 DWM 扩展帧边距
|
||||
if (has_saved_margins_) {
|
||||
DwmExtendFrameIntoClientArea(hwnd, &saved_margins_);
|
||||
has_saved_margins_ = false;
|
||||
}
|
||||
|
||||
// 恢复 DWM 过渡动画
|
||||
SetTransitionsEnabled(hwnd, saved_transitions_enabled_);
|
||||
|
||||
DragDebugLog("ExitSizeMove done");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#define RUNNER_WIN32_WINDOW_H_
|
||||
|
||||
#include <windows.h>
|
||||
#include <dwmapi.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
@@ -99,6 +100,22 @@ class Win32Window {
|
||||
// Called when Destroy is called.
|
||||
virtual void OnDestroy();
|
||||
|
||||
protected:
|
||||
// ============================================================
|
||||
// 拖拽时临时禁用 DWM Mica/Acrylic 合成,避免拖拽卡顿
|
||||
// ============================================================
|
||||
|
||||
// 当前是否处于系统拖拽/调整大小模态循环中
|
||||
// protected:FlutterWindow 需要访问此标志,判断是否需要在
|
||||
// WM_ENTERSIZEMOVE/WM_EXITSIZEMOVE 时禁用/恢复 Mica
|
||||
bool is_in_size_move_loop_ = false;
|
||||
|
||||
// 本机是否支持 DWMWA_SYSTEMBACKDROP_TYPE(Win11 22523+)
|
||||
bool system_backdrop_supported_ = false;
|
||||
|
||||
// 本机是否支持 DWMWA_MICA_EFFECT(Win11 22000+)
|
||||
bool mica_effect_supported_ = false;
|
||||
|
||||
private:
|
||||
friend class WindowClassRegistrar;
|
||||
|
||||
@@ -140,6 +157,46 @@ class Win32Window {
|
||||
// 最小尺寸限制(物理像素)
|
||||
static unsigned int min_width_;
|
||||
static unsigned int min_height_;
|
||||
|
||||
// 自定义标题栏高度(逻辑像素),用于 WM_NCHITTEST 拖拽区域
|
||||
// 与 Flutter 侧 DesktopTitleBarStyle.windows().height 保持一致
|
||||
static double title_bar_height_;
|
||||
|
||||
// 自定义标题栏右侧按钮区域宽度(逻辑像素),用于 WM_NCHITTEST 排除可点击按钮
|
||||
// 与 Flutter 侧 Windows 控制按钮总宽度(46 * 3 = 138)保持一致
|
||||
static double title_bar_button_width_;
|
||||
|
||||
// 拖拽前保存的 backdrop 类型,拖拽结束恢复
|
||||
// -1 表示尚未记录;0=none, 2=tabbed, 3=mica, 4=transient(acrylic)
|
||||
int saved_backdrop_type_ = -1;
|
||||
|
||||
// 拖拽前 Mica 启用状态,拖拽结束恢复
|
||||
BOOL saved_mica_enabled_ = FALSE;
|
||||
|
||||
// 拖拽前 DWM 扩展帧边距,拖拽结束恢复
|
||||
MARGINS saved_margins_ = {0, 0, 0, 0};
|
||||
bool has_saved_margins_ = false;
|
||||
|
||||
// 拖拽前 DWM 过渡动画状态,拖拽结束恢复
|
||||
BOOL saved_transitions_enabled_ = TRUE;
|
||||
|
||||
// 检测并缓存系统支持的 backdrop 能力
|
||||
static void DetectBackdropCapabilities(bool* system_backdrop,
|
||||
bool* mica_effect);
|
||||
|
||||
// 获取当前 DWM backdrop 类型(若支持),否则返回 -1
|
||||
static int GetCurrentBackdropType(HWND hwnd);
|
||||
|
||||
// 设置 DWM backdrop 类型
|
||||
static void SetBackdropType(HWND hwnd, int type);
|
||||
|
||||
// 设置 DWM 过渡动画启用/禁用
|
||||
static void SetTransitionsEnabled(HWND hwnd, BOOL enabled);
|
||||
|
||||
protected:
|
||||
// 进入/退出拖拽循环时调用(子类可覆写以在拖拽前后执行自定义逻辑)
|
||||
virtual void EnterSizeMove(HWND hwnd);
|
||||
virtual void ExitSizeMove(HWND hwnd);
|
||||
};
|
||||
|
||||
#endif // RUNNER_WIN32_WINDOW_H_
|
||||
|
||||
Reference in New Issue
Block a user