win提交

This commit is contained in:
Developer
2026-06-22 03:50:59 +08:00
parent 8786abd59e
commit f7520b17b2
32 changed files with 2179 additions and 1431 deletions

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
/// ============================================================
/// 闲言APP — tray_manager 系统托盘服务实现
/// 创建时间: 2026-06-18
/// 更新时间: 2026-06-18
/// 更新时间: 2026-06-19
/// 作用: 基于 tray_manager 实现跨平台系统托盘macOS/Win/Linux
/// 上次更新: 初始创建,实现 DesktopTrayService 接口
/// 上次更新: 修复 Windows 托盘图标路径问题,使用文件系统绝对路径替代 Flutter asset 路径
/// ============================================================
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:tray_manager/tray_manager.dart';
@@ -19,8 +20,7 @@ import 'package:xianyan/core/utils/logger.dart';
///
/// 支持 macOS / Windows / Linux 三端。
/// iOS / Android / 鸿蒙端使用 [StubDesktopTrayService]。
class TrayManagerTrayService
implements DesktopTrayService, TrayListener {
class TrayManagerTrayService implements DesktopTrayService, TrayListener {
TrayManagerTrayService._();
static final TrayManagerTrayService _instance = TrayManagerTrayService._();
@@ -72,27 +72,44 @@ class TrayManagerTrayService
Future<void> setIcon({required bool isDark}) async {
if (!_initialized) return;
try {
// macOS 使用 isTemplate 让系统自动反色
// Windows/Linux 需要明暗两套图标
final iconPath = isDark
? 'assets/images/tray_icon_dark.png'
: 'assets/images/tray_icon_light.png';
if (pu.isMacOS) {
// macOS: isTemplate=true 时系统自动处理深浅色
await trayManager.setIcon(
'assets/images/tray_icon_light.png',
isTemplate: true,
);
} else {
// Windows/Linux: 明暗两套图标
} else if (pu.isWindows) {
// Windows: tray_manager 仅支持 .ico 文件LoadImageW 不支持 PNG
// 使用 app_icon.ico已通过 CMakeLists.txt 安装到构建产物目录)
final iconPath = _resolveWindowsIcoPath();
await trayManager.setIcon(iconPath);
} else {
// Linux: 使用 Flutter asset 路径
final assetName = isDark
? 'assets/images/tray_icon_dark.png'
: 'assets/images/tray_icon_light.png';
await trayManager.setIcon(assetName);
}
} catch (e) {
Log.e('TrayManagerTrayService.setIcon 失败: $e');
}
}
/// 解析 Windows 平台的 .ico 托盘图标路径
///
/// tray_manager 在 Windows 上使用 LoadImageW 加载图标,
/// 仅支持 .ico 格式(不支持 .png
/// app_icon.ico 已通过 CMakeLists.txt 安装到构建产物目录。
static String _resolveWindowsIcoPath() {
try {
final exeDir = File(Platform.resolvedExecutable).parent.path;
return '$exeDir\\app_icon.ico';
} catch (e) {
Log.w('TrayManagerTrayService._resolveWindowsIcoPath 失败: $e');
return 'app_icon.ico';
}
}
@override
Future<void> setToolTip(String tip) async {
if (!_initialized) return;
@@ -108,13 +125,11 @@ class TrayManagerTrayService
if (!_initialized) return;
try {
// macOS: setTitle 显示数字角标(标题显示在图标旁)
// Windows/Linux: tray_manager 不原生支持角标,仅更新 Tooltip
if (pu.isMacOS) {
await trayManager.setTitle(count > 0 ? count.toString() : '');
}
// 统一更新 Tooltip 包含未读数
final tip = count > 0 ? '闲言 — $count 条未读' : '闲言';
await trayManager.setToolTip(tip);
// Tooltip 的多语言版本由 DesktopTrayController 统一管理,
// 此处不再硬编码中文 Tooltip避免覆盖多语言版本
} catch (e) {
Log.e('TrayManagerTrayService.setUnreadBadge 失败: $e');
}

View File

@@ -3,7 +3,7 @@
/// 创建时间: 2026-06-18
/// 更新时间: 2026-06-19
/// 作用: 基于 flutter_acrylic 实现 Windows 窗口特效Win11 Mica Alt/Mica/Win10 Acrylic
/// 上次更新: 新增 Mica Alt 特效支持Win11 build >= 22621自动降级 Mica Alt → Mica → Acrylic
/// 上次更新: 修复 setEffect 后 DwmExtendFrameIntoClientArea 导致 Flutter 视图布局异常
/// ============================================================
import 'dart:io';
@@ -11,6 +11,7 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_acrylic/flutter_acrylic.dart';
import 'package:window_manager/window_manager.dart';
import '../desktop_window_effect_service.dart';
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
@@ -49,8 +50,10 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
_isWin11OrLater = await _detectWindows11OrLater();
_isMicaAltSupportedCache = await _isMicaAltSupported();
_initialized = true;
Log.i('WindowsAcrylicService 初始化完成 '
'(isWin11=$_isWin11OrLater, micaAlt=$_isMicaAltSupportedCache)');
Log.i(
'WindowsAcrylicService 初始化完成 '
'(isWin11=$_isWin11OrLater, micaAlt=$_isMicaAltSupportedCache)',
);
} catch (e) {
Log.e('WindowsAcrylicService.initialize 失败: $e');
}
@@ -74,15 +77,9 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
dark: isDark,
);
Log.i('WindowsAcrylicService 应用 Mica Alt 特效 (isDark=$isDark)');
return;
}
if (_isWin11OrLater == true) {
} else if (_isWin11OrLater == true) {
// Win11: Mica 背景(跟随系统主题)
await Window.setEffect(
effect: WindowEffect.mica,
dark: isDark,
);
await Window.setEffect(effect: WindowEffect.mica, dark: isDark);
Log.i('WindowsAcrylicService 应用 Mica 特效 (isDark=$isDark)');
} else {
// Win10: Acrylic 半透明
@@ -90,23 +87,44 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
effect: WindowEffect.acrylic,
dark: isDark,
color: isDark
? const Color(0xCC1F1F1F) // 深色半透明
? const Color(0xCC1F1F1F) // 深色半透明
: const Color(0xCCF3F3F3), // 浅色半透明
);
Log.i('WindowsAcrylicService 应用 Acrylic 特效 (isDark=$isDark)');
}
// 修复setEffect 调用 DwmExtendFrameIntoClientArea(margins={-1}) 后,
// Flutter 视图子窗口尺寸与实际客户区不匹配,导致上方黑色+下方低分辨率。
// 通过强制重设窗口大小触发 WM_SIZE → MoveWindow 重新布局 Flutter 视图。
await _refreshWindowLayout();
} catch (e) {
Log.e('WindowsAcrylicService.applyEffect 失败: $e');
// 降级:禁用特效
try {
await Window.setEffect(
effect: WindowEffect.disabled,
dark: isDark,
);
await Window.setEffect(effect: WindowEffect.disabled, dark: isDark);
} catch (_) {}
}
}
/// 刷新窗口布局
///
/// DwmExtendFrameIntoClientArea 会改变客户区边界计算方式,
/// 导致 Flutter 视图child_content_ HWND无法正确填充窗口。
/// 通过重设窗口大小触发 WM_SIZE → Win32Window::MessageHandler → MoveWindow
/// 让 Flutter 视图重新适配客户区尺寸。
Future<void> _refreshWindowLayout() async {
try {
final size = await windowManager.getSize();
// 先设为稍大尺寸再恢复,确保 WM_SIZE 一定会被触发
await windowManager.setSize(const Size(1281, 721));
await Future<void>.delayed(const Duration(milliseconds: 50));
await windowManager.setSize(size);
Log.i('WindowsAcrylicService 窗口布局刷新完成');
} catch (e) {
Log.w('WindowsAcrylicService._refreshWindowLayout 失败: $e');
}
}
/// 检测当前系统是否支持 Mica AltWin11 build >= 22621
///
/// 使用 device_info_plus 读取精确的 Windows build 号。

View File

@@ -1,14 +1,14 @@
/// ============================================================
/// 闲言APP — 剪贴板桥接工具(含隐私协议守卫)
/// 创建时间: 2026-05-17
/// 更新时间: 2026-06-18
/// 更新时间: 2026-06-21
/// 作用: 统一剪贴板读取入口鸿蒙平台通过原生MethodChannel读取
/// 其他平台使用Flutter Clipboard未同意隐私协议时禁止读取
/// 鸿蒙端拦截Flutter标准Clipboard通道修复系统粘贴不工作
/// 鸿蒙端剪贴板由引擎原生 PlatformChannel 处理(需 READ_PASTEBOARD 权限)
/// 桌面端macOS/Windows支持富文本HTML读写
/// 上次更新: 新增富文本HTML支持 — 新增 setRichText/getHtml/hasHtml
/// 方法,桌面端通过 apps.xy.xianyan/clipboard 通道写入HTML
/// 移动端/Web 降级为纯文本去除HTML标签
/// 上次更新: 移除原生端 flutter/platform 通道拦截器(此前拦截器覆盖引擎
/// 原生 Clipboard/SystemChrome.setPreferredOrientations 实现
/// 导致横屏翻转失效),剪贴板功能改由引擎原生处理
/// ============================================================
import 'package:flutter/services.dart';
@@ -45,16 +45,15 @@ class ClipboardBridge {
/// 安装鸿蒙端标准剪贴板拦截器
/// Flutter标准TextInputPlugin长按粘贴使用SystemChannels.platform的
/// Clipboard.getData方法,鸿蒙端Flutter引擎未实现此方法,
/// 导致所有输入框粘贴无反应。此拦截器将标准Clipboard调用
/// 路由到鸿蒙原生pasteboard API。
/// Clipboard.getData方法Flutter鸿蒙引擎已在 PlatformChannel.ets 中
/// 实现了 Clipboard 方法,无需 Dart 端额外拦截。
///
/// 实现原理在原生端EntryAbility.ets中注册flutter/platform通道的
/// 剪贴板方法处理器。Dart端此方法仅作为标记实际拦截在原生端完成
/// 此方法保留为空操作,仅为向后兼容。剪贴板读写由引擎原生处理,
/// 粘贴所需 READ_PASTEBOARD 权限已在 module.json5 中声明
static void installOhosClipboardInterceptor() {
if (!_isOhos || _ohosInterceptorInstalled) return;
_ohosInterceptorInstalled = true;
Log.i('ClipboardBridge: 鸿蒙端剪贴板拦截器标记已设置(原生端负责实际拦截)');
Log.i('ClipboardBridge: 鸿蒙端剪贴板由引擎原生 PlatformChannel 处理');
}
// ============================================================
@@ -145,10 +144,9 @@ class ClipboardBridge {
if (_isOhos) {
try {
if (plainText != null && plainText.isNotEmpty) {
await _channel.invokeMethod<void>(
'Clipboard.setData',
{'text': plainText},
);
await _channel.invokeMethod<void>('Clipboard.setData', {
'text': plainText,
});
}
return;
} on MissingPluginException {

View File

@@ -1,9 +1,9 @@
// ============================================================
// 闲言APP — 编辑器主页面 (重写)
// 创建时间: 2026-04-20
// 更新时间: 2026-05-02
// 更新时间: 2026-06-19
// 作用: 卡片/壁纸编辑器入口,直接打开 ProEditorPage
// 上次更新: 移除旧底部状态栏功能已整合至EditorBottomToolbar
// 上次更新: 修复 Windows 端 toImage 失败导致白屏,增加超时和多重回退
// ============================================================
import 'dart:ui' as ui;
@@ -32,13 +32,36 @@ class _EditorPageState extends State<EditorPage> {
@override
void initState() {
super.initState();
_createDefaultBackground()
.then((bytes) {
if (mounted) setState(() => _backgroundBytes = bytes);
})
.catchError((Object e) {
Log.w('背景创建失败,使用默认背景', e);
});
_initBackground();
}
/// 初始化背景图片,带超时和多重回退
Future<void> _initBackground() async {
try {
// 设置 3 秒超时,避免 toImage 在 Windows 上卡死
final bytes = await _createDefaultBackground().timeout(
const Duration(seconds: 3),
);
Log.i('编辑器背景创建成功,大小: ${bytes.length} bytes');
if (mounted) setState(() => _backgroundBytes = bytes);
} catch (e) {
Log.e('背景创建失败,尝试回退', e);
try {
final fallback = await _createFallbackBackground().timeout(
const Duration(seconds: 2),
);
Log.i('编辑器回退背景创建成功,大小: ${fallback.length} bytes');
if (mounted) setState(() => _backgroundBytes = fallback);
} catch (e2) {
Log.e('回退背景也失败,使用内存编码', e2);
// 最终回退:直接使用 1x1 像素 PNG 编码
if (mounted) {
final minimal = _minimalPng();
Log.i('编辑器使用最小 PNG大小: ${minimal.length} bytes');
setState(() => _backgroundBytes = minimal);
}
}
}
}
@override
@@ -82,7 +105,8 @@ class _EditorPageState extends State<EditorPage> {
Future<Uint8List> _createDefaultBackground() async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
const size = Size(1080, 1920);
// 使用较小尺寸避免 Windows 端内存不足导致 toImage 失败
const size = Size(540, 960);
const gradient = LinearGradient(
colors: [Color(0xFF6C63FF), Color(0xFF4ECDC4)],
@@ -103,6 +127,45 @@ class _EditorPageState extends State<EditorPage> {
size.height.toInt(),
);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return byteData!.buffer.asUint8List();
}
/// 创建回退背景(小尺寸纯色),防止主背景创建失败导致白屏
Future<Uint8List> _createFallbackBackground() async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
const size = Size(100, 100);
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..color = const Color(0xFF1A1A2E),
);
final picture = recorder.endRecording();
final image = await picture.toImage(
size.width.toInt(),
size.height.toInt(),
);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return byteData!.buffer.asUint8List();
}
/// 最小 1x1 像素 PNG硬编码确保编辑器一定能渲染
///
/// 这是一个有效的 1x1 深蓝色 PNG 文件的字节数据,
// 作为最终回退,避免任何 toImage 调用失败时白屏
Uint8List _minimalPng() {
// 1x1 像素 #1A1A2E (深蓝色) 的 PNG 编码
return Uint8List.fromList([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // 8bit RGB
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk
0x54, 0x08, 0xD7, 0x63, 0xA0, 0xA0, 0x20, 0x20, // compressed data
0x00, 0x00, 0x00, 0x44, 0x00, 0x01, 0xA6, 0x21, // for #1A1A2E
0x80, 0x9E, 0xE7, 0x00, 0x00, 0x00, 0x00, 0x49, // IEND chunk
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
]);
}
}

View File

@@ -613,16 +613,21 @@ class ProEditorPageState extends State<ProEditorPage>
final borderRadius = _canvasStyle.useIndependentRadius
? BorderRadius.only(
topLeft: Radius.circular(
_canvasStyle.borderRadiusTL.clamp(0.0, 80.0)),
_canvasStyle.borderRadiusTL.clamp(0.0, 80.0),
),
topRight: Radius.circular(
_canvasStyle.borderRadiusTR.clamp(0.0, 80.0)),
_canvasStyle.borderRadiusTR.clamp(0.0, 80.0),
),
bottomLeft: Radius.circular(
_canvasStyle.borderRadiusBL.clamp(0.0, 80.0)),
_canvasStyle.borderRadiusBL.clamp(0.0, 80.0),
),
bottomRight: Radius.circular(
_canvasStyle.borderRadiusBR.clamp(0.0, 80.0)),
_canvasStyle.borderRadiusBR.clamp(0.0, 80.0),
),
)
: BorderRadius.circular(
_canvasStyle.borderRadius.clamp(0.0, 80.0));
_canvasStyle.borderRadius.clamp(0.0, 80.0),
);
if (borderRadius == BorderRadius.zero) return child;
return Container(
color: p.bgCanvas,
@@ -726,7 +731,11 @@ class ProEditorPageState extends State<ProEditorPage>
}
},
onEditorZoomMatrix4Change: (matrix) {
_zoomScaleNotifier.value = matrix.getMaxScaleOnAxis();
final scale = matrix.getMaxScaleOnAxis();
// 防止 NaN/Infinite 值导致 Matrix4 entries must be finite 崩溃
if (scale.isFinite) {
_zoomScaleNotifier.value = scale;
}
},
),
),

View File

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

View File

@@ -1,9 +1,9 @@
/// ============================================================
/// 闲言APP — 桌面端托盘控制器
/// 创建时间: 2026-06-18
/// 更新时间: 2026-06-18
/// 更新时间: 2026-06-19
/// 作用: 整合托盘服务 + 菜单构建器 + 未读数 Provider管理托盘生命周期
/// 上次更新: 初始创建,实现 DesktopTrayController
/// 上次更新: 添加语言变化监听,托盘菜单支持动态多语言
/// ============================================================
import 'dart:async';
@@ -48,8 +48,10 @@ class DesktopTrayController {
final WidgetRef _ref;
StreamSubscription<TrayEvent>? _eventSub;
ProviderSubscription<int>? _unreadSub;
ProviderSubscription<GeneralSettingsState>? _generalSettingsSub;
bool _initialized = false;
bool _isWindowVisible = true;
/// 防止 popUpContextMenu 触发的 performClick 导致重入
bool _isPopUpMenuInProgress = false;
@@ -89,16 +91,27 @@ class DesktopTrayController {
await trayService.setUnreadBadge(unreadCount);
// 6. 监听未读数变化
_unreadSub = _ref.listenManual(
trayUnreadCountProvider,
(previous, next) {
_onUnreadCountChanged(next);
},
);
_unreadSub = _ref.listenManual(trayUnreadCountProvider, (previous, next) {
_onUnreadCountChanged(next);
});
// 7. 监听托盘事件
_eventSub = trayService.events.listen(_onTrayEvent);
// 8. 监听语言设置变化,动态更新托盘菜单
_generalSettingsSub = _ref.listenManual(generalSettingsProvider, (
previous,
next,
) {
if (previous?.languageId != next.languageId) {
_updateMenu();
// 更新 Tooltip
final unreadCount = _ref.read(trayUnreadCountProvider);
final labels = TrayMenuLabels.forLanguage(next.languageId);
trayService.setToolTip(labels.formatTooltip(unreadCount));
}
});
Log.i('DesktopTrayController 初始化完成');
} catch (e, st) {
Log.e('DesktopTrayController 初始化失败', e, st);
@@ -111,6 +124,8 @@ class DesktopTrayController {
_eventSub = null;
_unreadSub?.close();
_unreadSub = null;
_generalSettingsSub?.close();
_generalSettingsSub = null;
if (_initialized) {
try {
@@ -183,9 +198,7 @@ class DesktopTrayController {
final languageId = _ref.read(generalSettingsProvider).languageId;
final labels = TrayMenuLabels.forLanguage(languageId);
await DesktopTrayService.instance.setToolTip(
labels.formatTooltip(count),
);
await DesktopTrayService.instance.setToolTip(labels.formatTooltip(count));
// 未读数变化时也更新菜单(最近阅读列表可能变化)
await _updateMenu();
@@ -304,9 +317,7 @@ class DesktopTrayController {
void _onToggleDarkMode() {
final settings = _ref.read(themeSettingsProvider);
final newMode = settings.isDark
? AppThemeMode.light
: AppThemeMode.dark;
final newMode = settings.isDark ? AppThemeMode.light : AppThemeMode.dark;
_ref.read(themeSettingsProvider.notifier).setThemeMode(newMode);
// 主题变化会触发 onThemeChanged 回调,无需在此更新菜单
}

View File

@@ -1,13 +1,11 @@
/// ============================================================
/// 闲言APP — 桌面端自定义窗口标题栏
/// 创建时间: 2026-06-18
/// 更新时间: 2026-06-18
/// 更新时间: 2026-06-22
/// 作用: 软件样式标题栏,替代系统默认标题栏,支持动态主题+动态样式
/// 上次更新: 初始创建,实现 macOS 风格(红黄绿) + Windows 风格(─ ▢ ✕)
/// 上次更新: "口"按钮改为弹出 3×2 窗口大小预设网格菜单
/// ============================================================
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -17,6 +15,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/utils/logger.dart';
import '../../core/utils/platform/platform_utils.dart' as pu;
import '../../features/settings/providers/theme_settings_provider.dart';
import '../../shared/widgets/window_size_popup.dart';
/// 标题栏样式配置(动态样式)
class DesktopTitleBarStyle {
@@ -167,7 +166,8 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
final isAmoled = theme.isAmoled;
// 根据平台选择默认样式
final style = widget.style ??
final style =
widget.style ??
(pu.isMacOS
? const DesktopTitleBarStyle.macOS()
: const DesktopTitleBarStyle.windows());
@@ -180,24 +180,29 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
? const Color(0xFFE5E5E7)
: const Color(0xFF1D1D1F);
// Windows: C++ HTTRANSPARENT 方案处理拖拽
// 子类化 Flutter 子窗口WM_NCHITTEST 标题栏区域返回 HTTRANSPARENT
// Windows 转发到父窗口window_manager_plugin.cpp 返回 HTCAPTION → 原生拖拽。
// 无需 Dart 侧 startDraggingMethodChannel 延迟导致卡顿)。
if (pu.isWindows) {
return Container(
height: style.height,
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
child: _buildWindowsLayout(style, foregroundColor),
);
}
// macOS: 使用 GestureDetector 处理拖拽和双击
return GestureDetector(
behavior: HitTestBehavior.translucent,
onPanStart: (_) => windowManager.startDragging(),
onPanDown: (_) {
windowManager.startDragging();
},
onDoubleTap: _toggleMaximize,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: style.blurRadius,
sigmaY: style.blurRadius,
),
child: Container(
height: style.height,
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
child: pu.isMacOS
? _buildMacOSLayout(style, foregroundColor)
: _buildWindowsLayout(style, foregroundColor),
),
),
child: Container(
height: style.height,
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
child: _buildMacOSLayout(style, foregroundColor),
),
);
}
@@ -250,9 +255,7 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
SizedBox(width: style.buttonSpacing),
_MacOSTrafficButton(
color: const Color(0xFF28C840),
icon: _isMaximized
? Icons.fullscreen_exit
: Icons.fullscreen,
icon: _isMaximized ? Icons.fullscreen_exit : Icons.fullscreen,
isHovering: _isHoveringMaximize,
onHover: (v) => setState(() => _isHoveringMaximize = v),
onTap: _toggleMaximize,
@@ -333,19 +336,17 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
iconSize: 16,
isHovering: _isHoveringMinimize,
onHover: (v) => setState(() => _isHoveringMinimize = v),
onTap: () => windowManager.minimize(),
onTap: (_) => windowManager.minimize(),
foregroundColor: foregroundColor,
width: 46,
height: style.height,
),
_WindowsControlButton(
icon: _isMaximized
? Icons.fullscreen_exit
: Icons.fullscreen,
icon: _isMaximized ? Icons.fullscreen_exit : Icons.fullscreen,
iconSize: 14,
isHovering: _isHoveringMaximize,
onHover: (v) => setState(() => _isHoveringMaximize = v),
onTap: _toggleMaximize,
onTap: (btnCtx) => WindowSizePopup.show(btnCtx),
foregroundColor: foregroundColor,
width: 46,
height: style.height,
@@ -355,7 +356,7 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
iconSize: 16,
isHovering: _isHoveringClose,
onHover: (v) => setState(() => _isHoveringClose = v),
onTap: () => windowManager.close(),
onTap: (_) => windowManager.close(),
foregroundColor: foregroundColor,
width: 46,
height: style.height,
@@ -460,7 +461,9 @@ class _WindowsControlButton extends StatefulWidget {
final double iconSize;
final bool isHovering;
final ValueChanged<bool> onHover;
final VoidCallback onTap;
/// 点击回调,传入按钮自身的 BuildContext 用于弹出菜单定位
final void Function(BuildContext buttonContext) onTap;
final Color foregroundColor;
final double width;
final double height;
@@ -485,7 +488,7 @@ class _WindowsControlButtonState extends State<_WindowsControlButton> {
onEnter: (_) => widget.onHover(true),
onExit: (_) => widget.onHover(false),
child: GestureDetector(
onTap: widget.onTap,
onTap: () => widget.onTap(context),
child: Container(
width: widget.width,
height: widget.height,

View File

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

View File

@@ -7,7 +7,8 @@
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Material, MaterialType, PageView, Colors;
import 'package:flutter/material.dart'
show Material, MaterialType, PageView, Colors;
import '../../../../core/constants/app_constants.dart';
import '../../../../core/storage/kv_storage.dart';
@@ -70,8 +71,9 @@ class NewFeaturesDialog {
// 1. 精确匹配(去掉 v 前缀比较)
final cleanVer = version.startsWith('v') ? version.substring(1) : version;
for (final e in AppUpdateLog.entries) {
final entryVer =
e.version.startsWith('v') ? e.version.substring(1) : e.version;
final entryVer = e.version.startsWith('v')
? e.version.substring(1)
: e.version;
if (entryVer == cleanVer) return e;
}
@@ -80,8 +82,9 @@ class NewFeaturesDialog {
if (parts.length >= 2) {
final majorMinor = '${parts[0]}.${parts[1]}';
for (final e in AppUpdateLog.entries) {
final entryVer =
e.version.startsWith('v') ? e.version.substring(1) : e.version;
final entryVer = e.version.startsWith('v')
? e.version.substring(1)
: e.version;
if (entryVer.startsWith('$majorMinor.')) return e;
}
}
@@ -100,11 +103,7 @@ class NewFeaturesDialog {
await showCupertinoModalPopup<void>(
context: context,
builder: (ctx) {
return _NewFeaturesCarousel(
entry: entry,
t: t,
ext: ext,
);
return _NewFeaturesCarousel(entry: entry, t: t, ext: ext);
},
);
}
@@ -150,25 +149,31 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
final cards = <_FeatureCardData>[];
// 第一张卡片:版本概览
cards.add(_FeatureCardData(
icon: CupertinoIcons.sparkles,
iconColor: widget.ext.accent,
title: '${widget.entry.version} 更新',
subtitle: widget.entry.date,
description: widget.t.onboarding.knowNewFeatures
.replaceAll('{0}', AppVersion.version),
isOverview: true,
));
cards.add(
_FeatureCardData(
icon: CupertinoIcons.sparkles,
iconColor: widget.ext.accent,
title: '${widget.entry.version} 更新',
subtitle: widget.entry.date,
description: widget.t.onboarding.knowNewFeatures.replaceAll(
'{0}',
AppVersion.version,
),
isOverview: true,
),
);
// 后续卡片:每个变更点
for (final change in widget.entry.changes) {
final parsed = _parseChange(change);
cards.add(_FeatureCardData(
icon: parsed.icon,
iconColor: parsed.color,
title: parsed.title,
description: parsed.description,
));
cards.add(
_FeatureCardData(
icon: parsed.icon,
iconColor: parsed.color,
title: parsed.title,
description: parsed.description,
),
);
}
return cards;
@@ -182,11 +187,26 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
String? emoji;
String rest = trimmed;
if (trimmed.isNotEmpty) {
// 匹配常见 emoji 前缀
final emojiMatch = RegExp(r'^([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}✨🔧🆕🎨⚡🌐📱🔒🎯✅🐛🚀💡📦🔥⭐💕🎉]+)\s*').firstMatch(trimmed);
if (emojiMatch != null) {
emoji = emojiMatch.group(1);
rest = trimmed.substring(emojiMatch.end).trim();
// 使用 code point 检测 emoji 前缀(避免正则 Unicode 转义兼容性问题)
final runes = trimmed.runes.toList();
int emojiEnd = 0;
while (emojiEnd < runes.length) {
final cp = runes[emojiEnd];
final isEmoji =
(cp >= 0x1F300 && cp <= 0x1FAFF) || // Emoji 主范围
(cp >= 0x2600 && cp <= 0x27BF) || // 杂项符号(☀➿等)
(cp >= 0x2B00 && cp <= 0x2BFF) || // 其他符号
'✨🔧🆕🎨⚡🌐📱🔒🎯✅🐛🚀💡📦🔥⭐💕🎉'.runes.contains(cp) ||
(cp >= 0xFE00 && cp <= 0xFE0F) || // Variation Selector
(cp >= 0x1F900 && cp <= 0x1F9FF); // 补充符号
if (!isEmoji) break;
emojiEnd++;
}
if (emojiEnd > 0) {
emoji = String.fromCharCodes(runes.sublist(0, emojiEnd));
rest = trimmed
.substring(String.fromCharCodes(runes.sublist(0, emojiEnd)).length)
.trim();
}
}
@@ -225,7 +245,10 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor);
}
if (emoji.contains('📱')) {
return _IconMatch(CupertinoIcons.device_phone_portrait, ext.iconTintCyan);
return _IconMatch(
CupertinoIcons.device_phone_portrait,
ext.iconTintCyan,
);
}
if (emoji.contains('🚀') || emoji.contains('')) {
return _IconMatch(CupertinoIcons.sparkles, ext.accent);
@@ -255,19 +278,28 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
if (text.contains('修复') || lower.contains('fix') || lower.contains('bug')) {
return _IconMatch(CupertinoIcons.wrench_fill, ext.warningColor);
}
if (text.contains('优化') || text.contains('改进') || text.contains('提升') || lower.contains('optim')) {
if (text.contains('优化') ||
text.contains('改进') ||
text.contains('提升') ||
lower.contains('optim')) {
return _IconMatch(CupertinoIcons.paintbrush_fill, ext.iconTintPurple);
}
if (text.contains('性能') || lower.contains('performance')) {
return _IconMatch(CupertinoIcons.bolt_fill, ext.iconTintYellow);
}
if (text.contains('多语言') || text.contains('翻译') || lower.contains('language')) {
if (text.contains('多语言') ||
text.contains('翻译') ||
lower.contains('language')) {
return _IconMatch(CupertinoIcons.globe, ext.iconTintBlue);
}
if (text.contains('安全') || text.contains('密保') || lower.contains('security')) {
if (text.contains('安全') ||
text.contains('密保') ||
lower.contains('security')) {
return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor);
}
if (text.contains('框架') || text.contains('架构') || lower.contains('framework')) {
if (text.contains('框架') ||
text.contains('架构') ||
lower.contains('framework')) {
return _IconMatch(CupertinoIcons.cube_box_fill, ext.iconTintMint);
}
@@ -375,7 +407,11 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
}
/// 构建单张卡片
Widget _buildCard(_FeatureCardData card, AppThemeExtension ext, bool isOverview) {
Widget _buildCard(
_FeatureCardData card,
AppThemeExtension ext,
bool isOverview,
) {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(
@@ -429,7 +465,10 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
/// 构建带光晕效果的图标
Widget _buildIconWithGlow(
_FeatureCardData card, AppThemeExtension ext, bool isOverview) {
_FeatureCardData card,
AppThemeExtension ext,
bool isOverview,
) {
final iconSize = isOverview ? 64.0 : 56.0;
final containerSize = isOverview ? 120.0 : 100.0;
@@ -459,11 +498,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
width: 1.5,
),
),
child: Icon(
card.icon,
size: iconSize,
color: card.iconColor,
),
child: Icon(card.icon, size: iconSize, color: card.iconColor),
),
),
);
@@ -480,9 +515,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
),
decoration: BoxDecoration(
color: ext.bgCard,
border: Border(
top: BorderSide(color: ext.overlaySubtle, width: 0.5),
),
border: Border(top: BorderSide(color: ext.overlaySubtle, width: 0.5)),
),
child: SafeArea(
top: false,
@@ -544,9 +577,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Text(
isLastPage
? widget.t.common.gotIt
: widget.t.common.confirm,
isLastPage ? widget.t.common.gotIt : widget.t.common.confirm,
style: AppTypography.body.copyWith(
color: ext.textOnAccent,
fontWeight: FontWeight.w600,

View File

@@ -1,9 +1,9 @@
// ============================================================
/// ============================================================
// 闲言APP — 应用入口
// 创建时间: 2026-04-20
/// 更新时间: 2026-06-05
/// 更新时间: 2026-06-19
/// 作用: main 函数,初始化存储 + 液态玻璃 + 异常捕获 + 启动 App
/// 上次更新: 新增PlatformCapabilities.init()平台能力注册表初始化
/// 上次更新: 移除 main.dart 中的 flutter_acrylic 初始化,统一由 WindowsAcrylicService 管理
// ============================================================
import 'dart:async';
@@ -65,6 +65,7 @@ void main() async {
Future<void> _appMain() async {
if (pu.isDesktop) {
await windowManager.ensureInitialized();
const windowOptions = WindowOptions(
minimumSize: Size(400, 600),
title: '闲言',
@@ -74,6 +75,10 @@ Future<void> _appMain() async {
// macOS: 隐藏原生红黄绿按钮,由 Flutter 侧自绘
skipTaskbar: false,
);
// Windows: flutter_acrylic 初始化统一由 WindowsAcrylicService 管理
// Window.initialize() 和 Window.setEffect() 均在 app.dart 的 _initWindowEffect 中调用
// 不在此处调用 Window.initialize(),避免重复初始化和布局异常
await windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
});

View File

@@ -1,9 +1,10 @@
// ============================================================
// 闲言APP — 键盘适配工具
// 创建时间: 2026-05-02
// 更新时间: 2026-05-30
// 更新时间: 2026-06-21
// 作用: 解决输入法面板遮挡底部Sheet/弹窗的通用方案 + 全局键盘管理
// 上次更新: 集成flutter_keyboard_visibility替换MediaQuery轮询检测键盘可见性
// 上次更新: 鸿蒙端 flutter_keyboard_visibility 插件无原生实现,
// 优雅降级为 MediaQuery 检测,避免 MissingPluginException
// ============================================================
import 'dart:async';
@@ -11,6 +12,7 @@ import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
/// 键盘安全底部弹窗包装器
///
@@ -118,15 +120,36 @@ class KeyboardManager {
static final KeyboardManager instance = KeyboardManager._();
/// 键盘可见性控制器flutter_keyboard_visibility
static final KeyboardVisibilityController _keyboardVisibility =
KeyboardVisibilityController();
/// 鸿蒙端无原生实现,延迟初始化并捕获异常,降级为不可用
static KeyboardVisibilityController? _keyboardVisibility;
static bool _keyboardVisibilityAvailable = true;
static KeyboardVisibilityController? get _keyboardVisibilityController {
if (!_keyboardVisibilityAvailable) return null;
if (_keyboardVisibility != null) return _keyboardVisibility;
try {
_keyboardVisibility = KeyboardVisibilityController();
return _keyboardVisibility;
} catch (e) {
_keyboardVisibilityAvailable = false;
return null;
}
}
/// 流式键盘可见性变更
static Stream<bool> get keyboardVisibilityStream =>
_keyboardVisibility.onChange;
/// 鸿蒙端插件不可用时返回空流(永不触发),调用方需用 MediaQuery 兜底
static Stream<bool> get keyboardVisibilityStream {
if (pu.isOhos) return const Stream<bool>.empty();
return _keyboardVisibilityController?.onChange ??
const Stream<bool>.empty();
}
/// 同步获取键盘是否可见无需BuildContext
static bool get isKeyboardVisibleSync => _keyboardVisibility.isVisible;
/// 鸿蒙端插件不可用时返回 false调用方需用 MediaQuery 兜底
static bool get isKeyboardVisibleSync {
if (pu.isOhos) return false;
return _keyboardVisibilityController?.isVisible ?? false;
}
/// 页面生命周期管理
final Set<String> _activePages = {};
@@ -170,8 +193,10 @@ class KeyboardManager {
}
/// 键盘是否可见使用KeyboardVisibilityController无需BuildContext轮询
/// 鸿蒙端降级为 MediaQuery 检测
static bool isKeyboardVisible(BuildContext context) {
return _keyboardVisibility.isVisible;
if (pu.isOhos) return MediaQuery.of(context).viewInsets.bottom > 0;
return _keyboardVisibilityController?.isVisible ?? false;
}
/// 键盘高度仍需MediaQueryflutter_keyboard_visibility不提供高度
@@ -191,8 +216,10 @@ class KeyboardSafe {
}
/// 键盘是否可见使用KeyboardVisibilityController无需BuildContext轮询
/// 鸿蒙端降级为 MediaQuery 检测
static bool isKeyboardVisible(BuildContext context) {
return KeyboardManager._keyboardVisibility.isVisible;
if (pu.isOhos) return MediaQuery.of(context).viewInsets.bottom > 0;
return KeyboardManager._keyboardVisibilityController?.isVisible ?? false;
}
static Future<T?> showSheet<T>({
@@ -261,7 +288,9 @@ class _ManagedCupertinoTextFieldState extends State<ManagedCupertinoTextField> {
@override
void initState() {
super.initState();
_keyboardSub = KeyboardManager.keyboardVisibilityStream.listen(_onKeyboardVisibility);
_keyboardSub = KeyboardManager.keyboardVisibilityStream.listen(
_onKeyboardVisibility,
);
}
@override

View File

@@ -0,0 +1,413 @@
/// ============================================================
/// 闲言APP — 窗口大小预设弹出菜单
/// 创建时间: 2026-06-22
/// 更新时间: 2026-06-22
/// 作用: 点击"口"按钮在按钮下方弹出 3×2 网格菜单,选择窗口预设尺寸
/// 上次更新: 从 adaptive_nav_bar.dart 提取为共享组件,供 DesktopWindowTitleBar 和 AdaptiveNavBar 复用
/// ============================================================
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:window_manager/window_manager.dart';
import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_theme.dart';
import '../../core/theme/glass_tokens.dart';
/// 窗口大小预设弹出菜单
///
/// 使用 [WindowSizePopup.show] 在指定按钮位置弹出 3×2 网格菜单。
/// iOS 26 Liquid Glass 风格Alert 层级毛玻璃。
class WindowSizePopup {
WindowSizePopup._();
/// 在 [buttonContext] 对应的按钮下方弹出窗口大小预设菜单
///
/// [buttonContext] 应为按钮自身的 BuildContext用于计算弹出位置。
static Future<void> show(BuildContext buttonContext) async {
final isMaximized = await windowManager.isMaximized();
if (!buttonContext.mounted) return;
// 获取按钮在屏幕中的位置,用于弹出菜单定位
final renderBox = buttonContext.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final buttonPos = renderBox.localToGlobal(Offset.zero);
final buttonSize = renderBox.size;
// 通过 OverlayEntry 弹出自定义菜单,避免阻塞 UI 线程
late OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => _WindowSizePopupContent(
buttonPos: buttonPos,
buttonSize: buttonSize,
isMaximized: isMaximized,
onDismiss: () => entry.remove(),
),
);
Overlay.of(buttonContext, rootOverlay: true).insert(entry);
}
}
/// 窗口大小弹出菜单内容iOS 26 Liquid Glass 风格)
/// 3 列 × 2 行网格布局,在按钮下方定位弹出
class _WindowSizePopupContent extends StatefulWidget {
const _WindowSizePopupContent({
required this.buttonPos,
required this.buttonSize,
required this.isMaximized,
required this.onDismiss,
});
/// 按钮左上角的全局坐标
final Offset buttonPos;
/// 按钮尺寸
final Size buttonSize;
/// 当前窗口是否已最大化
final bool isMaximized;
/// 关闭菜单回调
final VoidCallback onDismiss;
@override
State<_WindowSizePopupContent> createState() =>
_WindowSizePopupContentState();
}
class _WindowSizePopupContentState extends State<_WindowSizePopupContent>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scale;
late final Animation<double> _opacity;
/// 弹出菜单宽度
static const double _popupWidth = 348.0;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 180),
reverseDuration: const Duration(milliseconds: 120),
vsync: this,
);
_scale = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
_opacity = CurvedAnimation(parent: _controller, curve: Curves.easeIn);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// 关闭菜单(先播放退出动画再移除 Overlay
void _close() {
_controller.reverse().then((_) {
if (mounted) widget.onDismiss();
});
}
/// 应用窗口尺寸
Future<void> _applySize(int? w, int? h) async {
_close();
if (w == null || h == null) {
// 最大化 / 还原
if (widget.isMaximized) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
} else {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
}
await windowManager.setSize(Size(w.toDouble(), h.toDouble()));
}
}
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final mediaQuery = MediaQuery.of(context);
// 计算弹出位置:右对齐按钮,出现在按钮下方
double left = widget.buttonPos.dx + widget.buttonSize.width - _popupWidth;
const margin = 8.0;
if (left < margin) left = margin;
if (left + _popupWidth > mediaQuery.size.width - margin) {
left = mediaQuery.size.width - _popupWidth - margin;
}
final top = widget.buttonPos.dy + widget.buttonSize.height + 4;
return Stack(
children: [
// 全屏透明遮罩,点击外部关闭
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _close,
child: const SizedBox.expand(),
),
),
// 弹出菜单本体
Positioned(
left: left,
top: top,
child: AnimatedBuilder(
animation: _controller,
builder: (_, child) => Opacity(
opacity: _opacity.value,
child: Transform.scale(
scale: 0.92 + 0.08 * _scale.value,
alignment: Alignment.topRight,
child: child,
),
),
child: _buildPopup(ext),
),
),
],
);
}
/// 构建弹出菜单容器
Widget _buildPopup(AppThemeExtension ext) {
return Container(
width: _popupWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: GlassTokens.borderColor(ext.isDark),
width: GlassTokens.borderWidth,
),
boxShadow: [
BoxShadow(
color: GlassTokens.shadowColor(ext.isDark),
blurRadius: GlassTokens.shadowBlur,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: GlassTokens.alertBlur,
sigmaY: GlassTokens.alertBlur,
),
child: Container(
color: ext.glassColor.withValues(
alpha: ext.isDark
? GlassTokens.alertOpacityDark
: GlassTokens.alertOpacityLight,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(ext),
Container(
height: 0.5,
color: ext.dividerOnCard,
),
_buildGrid(ext),
],
),
),
),
),
);
}
/// 标题栏
Widget _buildHeader(AppThemeExtension ext) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: Row(
children: [
Icon(
CupertinoIcons.rectangle_split_3x1,
size: 14,
color: ext.textSecondary,
),
const SizedBox(width: 6),
Text(
'窗口大小',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: ext.textSecondary,
),
),
const Spacer(),
Text(
widget.isMaximized ? '当前:最大化' : '当前:自定义',
style: TextStyle(fontSize: 11, color: ext.textHint),
),
],
),
);
}
/// 3×2 网格
Widget _buildGrid(AppThemeExtension ext) {
// 预设尺寸列表:(图标, 标签, 宽, 高)
final presets = <_SizePreset>[
const _SizePreset(
icon: CupertinoIcons.device_phone_portrait,
label: '小窗',
width: 800,
height: 600,
),
const _SizePreset(
icon: CupertinoIcons.desktopcomputer,
label: '标准',
width: 1024,
height: 768,
),
const _SizePreset(
icon: CupertinoIcons.device_laptop,
label: '宽屏',
width: 1280,
height: 720,
),
const _SizePreset(
icon: CupertinoIcons.tv,
label: '大屏',
width: 1440,
height: 900,
),
const _SizePreset(
icon: CupertinoIcons.rectangle_on_rectangle,
label: '全高清',
width: 1920,
height: 1080,
),
_SizePreset(
icon: widget.isMaximized
? CupertinoIcons.arrow_down_right_square
: CupertinoIcons.rectangle_fill,
label: widget.isMaximized ? '还原' : '最大化',
width: null,
height: null,
),
];
return Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: AppSpacing.sm,
crossAxisSpacing: AppSpacing.sm,
childAspectRatio: 1.05,
children: presets
.map((p) => _HoverCell(
preset: p,
ext: ext,
onTap: () => _applySize(p.width, p.height),
))
.toList(),
),
);
}
}
/// 窗口尺寸预设数据模型
class _SizePreset {
const _SizePreset({
required this.icon,
required this.label,
required this.width,
required this.height,
});
final IconData icon;
final String label;
final int? width;
final int? height;
/// 尺寸显示文本
String get dimensionText =>
width != null && height != null ? '$width × $height' : '';
}
/// 可悬停的尺寸选择单元格
class _HoverCell extends StatefulWidget {
const _HoverCell({
required this.preset,
required this.ext,
required this.onTap,
});
final _SizePreset preset;
final AppThemeExtension ext;
final VoidCallback onTap;
@override
State<_HoverCell> createState() => _HoverCellState();
}
class _HoverCellState extends State<_HoverCell> {
bool _isHovering = false;
@override
Widget build(BuildContext context) {
final ext = widget.ext;
final preset = widget.preset;
final accent = ext.accent;
return MouseRegion(
onEnter: (_) => setState(() => _isHovering = true),
onExit: (_) => setState(() => _isHovering = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: _isHovering
? accent.withValues(alpha: 0.14)
: ext.overlaySubtle.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: _isHovering
? accent.withValues(alpha: 0.45)
: const Color(0x00000000),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
preset.icon,
size: 22,
color: _isHovering ? accent : ext.iconSecondary,
),
const SizedBox(height: 4),
Text(
preset.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _isHovering ? accent : ext.textPrimary,
),
),
if (preset.dimensionText.isNotEmpty) ...[
const SizedBox(height: 1),
Text(
preset.dimensionText,
style: TextStyle(fontSize: 10, color: ext.textHint),
),
],
],
),
),
),
);
}
}