diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f573ed..5f59be89 100644 --- a/CHANGELOG.md +++ b/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 升级时无需合并冲突,中间件代码完全在项目可控范围内 *** diff --git a/lib/app/layout/adaptive_nav_bar.dart b/lib/app/layout/adaptive_nav_bar.dart index 6a064d1d..e48a0657 100644 --- a/lib/app/layout/adaptive_nav_bar.dart +++ b/lib/app/layout/adaptive_nav_bar.dart @@ -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, diff --git a/lib/core/services/auth/token_refresh_watcher.dart b/lib/core/services/auth/token_refresh_watcher.dart index 87e94c3d..8002056d 100644 --- a/lib/core/services/auth/token_refresh_watcher.dart +++ b/lib/core/services/auth/token_refresh_watcher.dart @@ -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) { diff --git a/lib/core/services/desktop/desktop_tray_menu_builder.dart b/lib/core/services/desktop/desktop_tray_menu_builder.dart index 7a666d5c..6bb3627b 100644 --- a/lib/core/services/desktop/desktop_tray_menu_builder.dart +++ b/lib/core/services/desktop/desktop_tray_menu_builder.dart @@ -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 条 diff --git a/lib/core/services/desktop/implementations/tray_manager_tray_service.dart b/lib/core/services/desktop/implementations/tray_manager_tray_service.dart index f4d83e85..3f3fd8d6 100644 --- a/lib/core/services/desktop/implementations/tray_manager_tray_service.dart +++ b/lib/core/services/desktop/implementations/tray_manager_tray_service.dart @@ -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 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 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'); } diff --git a/lib/core/services/desktop/implementations/windows_acrylic_service.dart b/lib/core/services/desktop/implementations/windows_acrylic_service.dart index ebaf031c..43fd7ab4 100644 --- a/lib/core/services/desktop/implementations/windows_acrylic_service.dart +++ b/lib/core/services/desktop/implementations/windows_acrylic_service.dart @@ -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 _refreshWindowLayout() async { + try { + final size = await windowManager.getSize(); + // 先设为稍大尺寸再恢复,确保 WM_SIZE 一定会被触发 + await windowManager.setSize(const Size(1281, 721)); + await Future.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 号。 diff --git a/lib/core/utils/platform/clipboard_bridge.dart b/lib/core/utils/platform/clipboard_bridge.dart index eb4807d7..037d797b 100644 --- a/lib/core/utils/platform/clipboard_bridge.dart +++ b/lib/core/utils/platform/clipboard_bridge.dart @@ -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( - 'Clipboard.setData', - {'text': plainText}, - ); + await _channel.invokeMethod('Clipboard.setData', { + 'text': plainText, + }); } return; } on MissingPluginException { diff --git a/lib/editor/pages/editor/editor_page.dart b/lib/editor/pages/editor/editor_page.dart index 52c6b65c..79736c70 100644 --- a/lib/editor/pages/editor/editor_page.dart +++ b/lib/editor/pages/editor/editor_page.dart @@ -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 { @override void initState() { super.initState(); - _createDefaultBackground() - .then((bytes) { - if (mounted) setState(() => _backgroundBytes = bytes); - }) - .catchError((Object e) { - Log.w('背景创建失败,使用默认背景', e); - }); + _initBackground(); + } + + /// 初始化背景图片,带超时和多重回退 + Future _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 { Future _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 { size.height.toInt(), ); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); return byteData!.buffer.asUint8List(); } + + /// 创建回退背景(小尺寸纯色),防止主背景创建失败导致白屏 + Future _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, + ]); + } } diff --git a/lib/editor/pages/editor/pro_editor_page.dart b/lib/editor/pages/editor/pro_editor_page.dart index 54936dc9..2f14889d 100644 --- a/lib/editor/pages/editor/pro_editor_page.dart +++ b/lib/editor/pages/editor/pro_editor_page.dart @@ -613,16 +613,21 @@ class ProEditorPageState extends State 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 } }, onEditorZoomMatrix4Change: (matrix) { - _zoomScaleNotifier.value = matrix.getMaxScaleOnAxis(); + final scale = matrix.getMaxScaleOnAxis(); + // 防止 NaN/Infinite 值导致 Matrix4 entries must be finite 崩溃 + if (scale.isFinite) { + _zoomScaleNotifier.value = scale; + } }, ), ), diff --git a/lib/editor/services/core/pro_editor_bridge.dart b/lib/editor/services/core/pro_editor_bridge.dart index 5eebbb3e..46941dfa 100644 --- a/lib/editor/services/core/pro_editor_bridge.dart +++ b/lib/editor/services/core/pro_editor_bridge.dart @@ -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( diff --git a/lib/features/desktop/desktop_tray_controller.dart b/lib/features/desktop/desktop_tray_controller.dart index 1dd8ea45..45e20abf 100644 --- a/lib/features/desktop/desktop_tray_controller.dart +++ b/lib/features/desktop/desktop_tray_controller.dart @@ -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? _eventSub; ProviderSubscription? _unreadSub; + ProviderSubscription? _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 回调,无需在此更新菜单 } diff --git a/lib/features/desktop/desktop_window_title_bar.dart b/lib/features/desktop/desktop_window_title_bar.dart index d42e49ab..04ce4a01 100644 --- a/lib/features/desktop/desktop_window_title_bar.dart +++ b/lib/features/desktop/desktop_window_title_bar.dart @@ -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 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 ? 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 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 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 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 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, diff --git a/lib/features/home/presentation/providers/readlater/readlater_page_ui_mixin.dart b/lib/features/home/presentation/providers/readlater/readlater_page_ui_mixin.dart index 1bd294ef..96813150 100644 --- a/lib/features/home/presentation/providers/readlater/readlater_page_ui_mixin.dart +++ b/lib/features/home/presentation/providers/readlater/readlater_page_ui_mixin.dart @@ -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'); }, ), ], diff --git a/lib/features/home/presentation/widgets/new_features_dialog.dart b/lib/features/home/presentation/widgets/new_features_dialog.dart index 009c1f32..667521fd 100644 --- a/lib/features/home/presentation/widgets/new_features_dialog.dart +++ b/lib/features/home/presentation/widgets/new_features_dialog.dart @@ -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( 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, diff --git a/lib/main.dart b/lib/main.dart index 1697ef98..9adf54f5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 _appMain() async { if (pu.isDesktop) { await windowManager.ensureInitialized(); + const windowOptions = WindowOptions( minimumSize: Size(400, 600), title: '闲言', @@ -74,6 +75,10 @@ Future _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(); }); diff --git a/lib/shared/widgets/adaptive/keyboard_safe_sheet.dart b/lib/shared/widgets/adaptive/keyboard_safe_sheet.dart index 857bde7d..031612be 100644 --- a/lib/shared/widgets/adaptive/keyboard_safe_sheet.dart +++ b/lib/shared/widgets/adaptive/keyboard_safe_sheet.dart @@ -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 get keyboardVisibilityStream => - _keyboardVisibility.onChange; + /// 鸿蒙端插件不可用时返回空流(永不触发),调用方需用 MediaQuery 兜底 + static Stream get keyboardVisibilityStream { + if (pu.isOhos) return const Stream.empty(); + return _keyboardVisibilityController?.onChange ?? + const Stream.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 _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 showSheet({ @@ -261,7 +288,9 @@ class _ManagedCupertinoTextFieldState extends State { @override void initState() { super.initState(); - _keyboardSub = KeyboardManager.keyboardVisibilityStream.listen(_onKeyboardVisibility); + _keyboardSub = KeyboardManager.keyboardVisibilityStream.listen( + _onKeyboardVisibility, + ); } @override diff --git a/lib/shared/widgets/window_size_popup.dart b/lib/shared/widgets/window_size_popup.dart new file mode 100644 index 00000000..1eea319b --- /dev/null +++ b/lib/shared/widgets/window_size_popup.dart @@ -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 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 _scale; + late final Animation _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 _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), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f7c59e2a..aae0199e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/ohos/entry/src/main/ets/entryability/CustomPlatformPlugin.ets b/ohos/entry/src/main/ets/entryability/CustomPlatformPlugin.ets new file mode 100644 index 00000000..dcaa5ed1 --- /dev/null +++ b/ohos/entry/src/main/ets/entryability/CustomPlatformPlugin.ets @@ -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 | 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'); + } +} diff --git a/ohos/entry/src/main/ets/entryability/EntryAbility.ets b/ohos/entry/src/main/ets/entryability/EntryAbility.ets index 5d4870f4..40823814 100644 --- a/ohos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/ohos/entry/src/main/ets/entryability/EntryAbility.ets @@ -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; - 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; diff --git a/ohos/entry/src/main/resources/base/element/string.json b/ohos/entry/src/main/resources/base/element/string.json index 977fad61..0194ffb9 100644 --- a/ohos/entry/src/main/resources/base/element/string.json +++ b/ohos/entry/src/main/resources/base/element/string.json @@ -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" diff --git a/ohos/entry/src/main/resources/en_US/element/string.json b/ohos/entry/src/main/resources/en_US/element/string.json index dc0cea3f..7c68d90b 100644 --- a/ohos/entry/src/main/resources/en_US/element/string.json +++ b/ohos/entry/src/main/resources/en_US/element/string.json @@ -39,6 +39,10 @@ { "name": "shortcut_general_settings_label", "value": "Settings" + }, + { + "name": "permission_read_pasteboard_reason", + "value": "Used for clipboard paste in text input" } ] } \ No newline at end of file diff --git a/ohos/entry/src/main/resources/zh_CN/element/string.json b/ohos/entry/src/main/resources/zh_CN/element/string.json index 70837e6c..8540877b 100644 --- a/ohos/entry/src/main/resources/zh_CN/element/string.json +++ b/ohos/entry/src/main/resources/zh_CN/element/string.json @@ -62,6 +62,10 @@ "name": "permission_pasteboard_reason", "value": "用于文本输入框的剪贴板粘贴" }, + { + "name": "permission_read_pasteboard_reason", + "value": "用于文本输入框的剪贴板粘贴" + }, { "name": "permission_bundle_info_reason", "value": "用于获取应用包信息" diff --git a/pubspec.macos.yaml b/pubspec.macos.yaml index 0c74e4e7..755ed5ec 100644 --- a/pubspec.macos.yaml +++ b/pubspec.macos.yaml @@ -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 # 桌面端文件拖放接收 diff --git a/pubspec.ohos.yaml b/pubspec.ohos.yaml index 6034bb9b..c560aca4 100644 --- a/pubspec.ohos.yaml +++ b/pubspec.ohos.yaml @@ -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 # 桌面端文件拖放接收 diff --git a/study_plan_preview.html b/study_plan_preview.html deleted file mode 100644 index 06932f60..00000000 --- a/study_plan_preview.html +++ /dev/null @@ -1,1122 +0,0 @@ - - - - - -学习计划页面设计预览 - iOS 26 风格 - - - - - - - - -
-
- - - - -
- - -
- -
-
-
- - 阅读目标 -
- -
-
-
-
- - 今日浏览 -
-
-
7/10
-
-
-
- - 今日收藏 -
-
-
3/3
-
-
-
- - 今日笔记 -
-
-
0/1
-
-
-
- - 连续签到 -
-
-
4/7
-
-
-
- - -
-
进行中
-
- -
-
-
- - - - -
60%
-
-
-
-
- 每日五首诗 - 7天 -
-
每天阅读5首古诗词,积少成多
-
- 今日 3/5 -
-
-
-
-
-
-
- - -
-
- - -
-
-
- - - - -
- -
-
-
-
-
- 成语达人 - 3天 -
-
每天学习5个成语,丰富表达
-
- 今日 5/5 -
-
-
-
-
-
-
- -
-
- - -
-
-
- - - - -
33%
-
-
-
-
- 名言积累 -
-
每天3句名言,启迪智慧
-
- 今日 1/3 -
-
-
-
-
-
-
- - -
-
- - -
- - 添加新计划 -
-
- - -
-
-
- -
-
开始你的学习之旅
-
制定学习计划,每日坚持
积少成多,收获满满
- -
-
- - -
-
-
-
创建学习计划
-
选择模板
-
-
-
- - 每日五首诗 -
-
每天阅读5首古诗词,积少成多
-
-
-
- - 诗词轻量计划 -
-
每天3首诗,轻松坚持
-
-
-
- - 成语达人 -
-
每天学习5个成语,丰富表达
-
-
-
- - 名言积累 -
-
每天3句名言,启迪智慧
-
-
-
- - 经典通读 -
-
每周精读一篇经典名句
-
-
-
- - 混合学习 -
-
每天5条混合内容,全面发展
-
-
-
每日目标
-
- -
5 条/天
- -
- -
-
- - -
-
-
-
每日五首诗
-
-
- - 总完成 -
-
42 条
-
-
-
- - 连续天数 -
-
7 天
-
-
-
- - 开始日期 -
-
6/1
-
-
-
- - 每日目标 -
-
5 条
-
-
-
最近记录
-
-
- - 完成1条 -
-
6/17
-
-
-
- - 完成1条 -
-
6/16
-
-
-
- - 完成1条 -
-
6/15
-
-
-
-
-
- - -
-
-
-
创建学习计划
-
选择模板
-
-
-
- - 每日五首诗 -
-
每天阅读5首古诗词,积少成多
-
-
-
- - 诗词轻量计划 -
-
每天3首诗,轻松坚持
-
-
-
- - 成语达人 -
-
每天学习5个成语,丰富表达
-
-
-
- - 名言积累 -
-
每天3句名言,启迪智慧
-
-
-
- - 经典通读 -
-
每周精读一篇经典名句
-
-
-
- - 混合学习 -
-
每天5条混合内容,全面发展
-
-
-
每日目标
-
- -
5 条/天
- -
- -
-
- - -
-
-
-
每日五首诗
-
-
- - 总完成 -
-
42 条
-
-
-
- - 连续天数 -
-
7 天
-
-
-
- - 开始日期 -
-
6/1
-
-
-
- - 每日目标 -
-
5 条
-
-
-
最近记录
-
-
- - 完成1条 -
-
6/17
-
-
-
- - 完成1条 -
-
6/16
-
-
-
-
- - -
-
-
-
设置阅读目标
-
每日浏览
- -
10
-
每日收藏
- -
3
-
每日笔记
- -
1
-
连续签到
- -
7
-
-
- - - - - - - diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index a7f00426..849ef266 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -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) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 394917c0..28280fb5 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -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. diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index d009b26d..7202e690 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -1,12 +1,84 @@ #include "flutter_window.h" #include +#include + +#include +#include +#include +#include +#include #include #include #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( + now.time_since_epoch()) + .count(); + std::lock_guard 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(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(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(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(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(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_controller_->engine()->messenger(), "xianyan/window_control", + &flutter::StandardMethodCodec::GetInstance()); + + window_control_channel_->SetMethodCallHandler( + [this](const flutter::MethodCall<>& call, + std::unique_ptr> 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(width * dpi / 96.0); + int physical_height = static_cast(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(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(dpi) / 96.0; + int title_h = static_cast(36 * scale); + int btn_w = static_cast(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(dpi) / 96.0; + int title_h = static_cast(36.0 * scale); + int btn_w = static_cast(138.0 * scale); + // 边框命中测试宽度(稍大于实际边框,方便用户操作) + int border_w = static_cast(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 result = diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index ead932f1..546ab00b 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -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_controller_; // Windows platform MethodChannel for theme control. std::unique_ptr> platform_channel_; + + // 窗口控制 MethodChannel(窗口大小预设菜单等) + std::unique_ptr> 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_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index f50040f6..477536d2 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -3,9 +3,132 @@ #include #include #include +#include + +#include +#include +#include +#include +#include +#include #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( + now.time_since_epoch()) + .count(); + std::lock_guard 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 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( + 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::system_clock::now().time_since_epoch()) + .count(); + LRESULT r = DefWindowProc(hwnd, message, wparam, lparam); + UINT64 now = std::chrono::duration_cast( + 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(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(dpi) / 96.0; + int title_h = static_cast(title_bar_height_ * scale); + int btn_w = static_cast(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(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( + 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(value); + } + return -1; +} + +void Win32Window::SetBackdropType(HWND hwnd, int type) { + INT value = static_cast(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( + 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"); +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h index e7541e48..96a300bd 100644 --- a/windows/runner/win32_window.h +++ b/windows/runner/win32_window.h @@ -2,6 +2,7 @@ #define RUNNER_WIN32_WINDOW_H_ #include +#include #include #include @@ -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_