win提交
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 自适应导航栏
|
||||
/// 创建时间: 2026-05-29
|
||||
/// 更新时间: 2026-06-12
|
||||
/// 更新时间: 2026-06-22
|
||||
/// 作用: 宽屏时垂直导航栏,窄屏时底部导航栏,支持4种停靠位置
|
||||
/// 上次更新: 从 core/layout/ 迁移至 app/layout/,修复架构层级违规
|
||||
/// 上次更新: "口"按钮改为弹出 3×2 窗口大小预设网格菜单(OverlayEntry 定位弹出)
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:ui';
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -20,6 +21,7 @@ import '../../core/utils/platform/platform_utils.dart' as pu;
|
||||
import '../../features/discover/providers/chat_provider.dart';
|
||||
import '../../features/settings/providers/theme_settings_provider.dart';
|
||||
import '../../shared/widgets/animation/tab_icon_sprite.dart';
|
||||
import '../../shared/widgets/window_size_popup.dart';
|
||||
|
||||
class AdaptiveNavBar extends ConsumerWidget {
|
||||
const AdaptiveNavBar({
|
||||
@@ -124,11 +126,15 @@ class AdaptiveNavBar extends ConsumerWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: isSelected
|
||||
? (ext.isDark ? ext.textInverse : ext.accent)
|
||||
: (ext.isDark
|
||||
? ext.textInverse.withValues(alpha: 0.38)
|
||||
? ext.textInverse.withValues(
|
||||
alpha: 0.38,
|
||||
)
|
||||
: const Color(0xFFAEAEB2)),
|
||||
),
|
||||
),
|
||||
@@ -182,7 +188,11 @@ class AdaptiveNavBar extends ConsumerWidget {
|
||||
}
|
||||
|
||||
/// 折叠/展开导航栏按钮
|
||||
Widget _buildCollapseButton(WidgetRef ref, bool isCollapsed, AppThemeExtension ext) {
|
||||
Widget _buildCollapseButton(
|
||||
WidgetRef ref,
|
||||
bool isCollapsed,
|
||||
AppThemeExtension ext,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: () => ref.read(splitViewProvider.notifier).toggleNavBarCollapsed(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -312,7 +322,8 @@ class AdaptiveNavBar extends ConsumerWidget {
|
||||
buildTab(TabSpriteType.home, 0, '闲言'),
|
||||
buildTab(TabSpriteType.discover, 1, '发现'),
|
||||
buildTab(TabSpriteType.profile, 2, '我的'),
|
||||
if (isTop && pu.isDesktop) _buildDesktopWindowControls(ext),
|
||||
if (isTop && pu.isDesktop)
|
||||
_buildDesktopWindowControls(context, ext),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -321,40 +332,54 @@ class AdaptiveNavBar extends ConsumerWidget {
|
||||
}
|
||||
|
||||
/// 桌面端窗口控制按钮(最小化/最大化/关闭)+ 可拖拽标题栏区域
|
||||
Widget _buildDesktopWindowControls(AppThemeExtension ext) {
|
||||
Widget _buildDesktopWindowControls(
|
||||
BuildContext context,
|
||||
AppThemeExtension ext,
|
||||
) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onPanStart: (_) => windowManager.startDragging(),
|
||||
child: const SizedBox(height: 52, width: 40),
|
||||
),
|
||||
// Windows: C++ HTTRANSPARENT 方案处理拖拽,无需 Dart 侧 startDragging
|
||||
if (pu.isWindows)
|
||||
const SizedBox(height: 52, width: 40)
|
||||
else
|
||||
GestureDetector(
|
||||
onPanDown: (_) => windowManager.startDragging(),
|
||||
onDoubleTap: () async {
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
}
|
||||
},
|
||||
child: const SizedBox(height: 52, width: 40),
|
||||
),
|
||||
_WindowControlBtn(
|
||||
icon: CupertinoIcons.minus,
|
||||
ext: ext,
|
||||
onPressed: () => windowManager.minimize(),
|
||||
onPressed: (_) => windowManager.minimize(),
|
||||
),
|
||||
_WindowControlBtn(
|
||||
icon: CupertinoIcons.square,
|
||||
ext: ext,
|
||||
iconSize: 12,
|
||||
onPressed: () async {
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
}
|
||||
},
|
||||
onPressed: (btnCtx) => _showWindowSizeMenu(btnCtx),
|
||||
),
|
||||
_WindowControlBtn(
|
||||
icon: CupertinoIcons.xmark,
|
||||
ext: ext,
|
||||
onPressed: () => windowManager.close(),
|
||||
onPressed: (_) => windowManager.close(),
|
||||
isClose: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 窗口大小预设菜单
|
||||
/// 点击"口"按钮在按钮下方弹出 3×2 网格菜单,选择窗口预设尺寸
|
||||
void _showWindowSizeMenu(BuildContext buttonContext) {
|
||||
WindowSizePopup.show(buttonContext);
|
||||
}
|
||||
}
|
||||
|
||||
/// 窗口控制按钮(最小化/最大化/关闭)
|
||||
@@ -369,7 +394,9 @@ class _WindowControlBtn extends StatefulWidget {
|
||||
|
||||
final IconData icon;
|
||||
final AppThemeExtension ext;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// 点击回调,传入按钮自身的 BuildContext 用于弹出菜单定位
|
||||
final void Function(BuildContext buttonContext) onPressed;
|
||||
final double iconSize;
|
||||
final bool isClose;
|
||||
|
||||
@@ -393,7 +420,7 @@ class _WindowControlBtnState extends State<_WindowControlBtn> {
|
||||
onEnter: (_) => setState(() => _isHovering = true),
|
||||
onExit: (_) => setState(() => _isHovering = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onPressed,
|
||||
onTap: () => widget.onPressed(context),
|
||||
child: Container(
|
||||
width: 46,
|
||||
height: 52,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../network/connectivity_service.dart';
|
||||
import 'token_service.dart';
|
||||
@@ -156,9 +157,9 @@ class TokenRefreshWatcher {
|
||||
/// 导航到登录页
|
||||
static void _navigateToLogin() {
|
||||
try {
|
||||
final nav = rootNavigatorKey.currentState;
|
||||
if (nav != null) {
|
||||
nav.pushNamed(AppRoutes.login);
|
||||
final context = rootNavigatorKey.currentContext;
|
||||
if (context != null && context.mounted) {
|
||||
context.go(AppRoutes.login);
|
||||
Log.i('TokenRefreshWatcher: 已导航到登录页');
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
/// 上次更新: 初始创建,实现 TrayMenuCallbacks + TrayMenuLabels + DesktopTrayMenuBuilder
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../features/home/presentation/providers/readlater/readlater_entry.dart';
|
||||
@@ -131,18 +133,41 @@ class TrayMenuLabels {
|
||||
);
|
||||
|
||||
/// 根据语言 ID 获取标签
|
||||
///
|
||||
/// 当 languageId 为 'system' 时,自动解析系统语言。
|
||||
/// 支持中文(简/繁)和英文,其他语言回退到中文。
|
||||
factory TrayMenuLabels.forLanguage(String languageId) {
|
||||
switch (languageId) {
|
||||
final resolvedId = languageId == 'system'
|
||||
? _resolveSystemLanguageId()
|
||||
: languageId;
|
||||
switch (resolvedId) {
|
||||
case 'en':
|
||||
return enUS;
|
||||
case 'zh_CN':
|
||||
case 'zh_TW':
|
||||
case 'system':
|
||||
default:
|
||||
return zhCN;
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析系统语言 ID
|
||||
///
|
||||
/// 从平台获取系统首选语言,映射到项目支持的语言 ID。
|
||||
static String _resolveSystemLanguageId() {
|
||||
try {
|
||||
final locale = PlatformDispatcher.instance.locale;
|
||||
final langCode = locale.languageCode;
|
||||
// 英文系统
|
||||
if (langCode == 'en') return 'en';
|
||||
// 中文系统(简体/繁体)
|
||||
if (langCode == 'zh') return 'zh_CN';
|
||||
// 其他语言回退到中文
|
||||
return 'zh_CN';
|
||||
} catch (e) {
|
||||
return 'zh_CN';
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化带未读数的 Tooltip
|
||||
String formatTooltip(int unreadCount) {
|
||||
if (unreadCount <= 0) return tooltip;
|
||||
@@ -256,12 +281,7 @@ class DesktopTrayMenuBuilder {
|
||||
required TrayMenuCallbacks callbacks,
|
||||
}) {
|
||||
if (entries.isEmpty) {
|
||||
return [
|
||||
TrayMenuItem(
|
||||
label: labels.noRecentRead,
|
||||
disabled: true,
|
||||
),
|
||||
];
|
||||
return [TrayMenuItem(label: labels.noRecentRead, disabled: true)];
|
||||
}
|
||||
|
||||
// 取前 maxRecentReadItems 条
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — tray_manager 系统托盘服务实现
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 基于 tray_manager 实现跨平台系统托盘(macOS/Win/Linux)
|
||||
/// 上次更新: 初始创建,实现 DesktopTrayService 接口
|
||||
/// 上次更新: 修复 Windows 托盘图标路径问题,使用文件系统绝对路径替代 Flutter asset 路径
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:tray_manager/tray_manager.dart';
|
||||
@@ -19,8 +20,7 @@ import 'package:xianyan/core/utils/logger.dart';
|
||||
///
|
||||
/// 支持 macOS / Windows / Linux 三端。
|
||||
/// iOS / Android / 鸿蒙端使用 [StubDesktopTrayService]。
|
||||
class TrayManagerTrayService
|
||||
implements DesktopTrayService, TrayListener {
|
||||
class TrayManagerTrayService implements DesktopTrayService, TrayListener {
|
||||
TrayManagerTrayService._();
|
||||
|
||||
static final TrayManagerTrayService _instance = TrayManagerTrayService._();
|
||||
@@ -72,27 +72,44 @@ class TrayManagerTrayService
|
||||
Future<void> setIcon({required bool isDark}) async {
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
// macOS 使用 isTemplate 让系统自动反色
|
||||
// Windows/Linux 需要明暗两套图标
|
||||
final iconPath = isDark
|
||||
? 'assets/images/tray_icon_dark.png'
|
||||
: 'assets/images/tray_icon_light.png';
|
||||
|
||||
if (pu.isMacOS) {
|
||||
// macOS: isTemplate=true 时系统自动处理深浅色
|
||||
await trayManager.setIcon(
|
||||
'assets/images/tray_icon_light.png',
|
||||
isTemplate: true,
|
||||
);
|
||||
} else {
|
||||
// Windows/Linux: 明暗两套图标
|
||||
} else if (pu.isWindows) {
|
||||
// Windows: tray_manager 仅支持 .ico 文件(LoadImageW 不支持 PNG)
|
||||
// 使用 app_icon.ico(已通过 CMakeLists.txt 安装到构建产物目录)
|
||||
final iconPath = _resolveWindowsIcoPath();
|
||||
await trayManager.setIcon(iconPath);
|
||||
} else {
|
||||
// Linux: 使用 Flutter asset 路径
|
||||
final assetName = isDark
|
||||
? 'assets/images/tray_icon_dark.png'
|
||||
: 'assets/images/tray_icon_light.png';
|
||||
await trayManager.setIcon(assetName);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.e('TrayManagerTrayService.setIcon 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 Windows 平台的 .ico 托盘图标路径
|
||||
///
|
||||
/// tray_manager 在 Windows 上使用 LoadImageW 加载图标,
|
||||
/// 仅支持 .ico 格式(不支持 .png)。
|
||||
/// app_icon.ico 已通过 CMakeLists.txt 安装到构建产物目录。
|
||||
static String _resolveWindowsIcoPath() {
|
||||
try {
|
||||
final exeDir = File(Platform.resolvedExecutable).parent.path;
|
||||
return '$exeDir\\app_icon.ico';
|
||||
} catch (e) {
|
||||
Log.w('TrayManagerTrayService._resolveWindowsIcoPath 失败: $e');
|
||||
return 'app_icon.ico';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setToolTip(String tip) async {
|
||||
if (!_initialized) return;
|
||||
@@ -108,13 +125,11 @@ class TrayManagerTrayService
|
||||
if (!_initialized) return;
|
||||
try {
|
||||
// macOS: setTitle 显示数字角标(标题显示在图标旁)
|
||||
// Windows/Linux: tray_manager 不原生支持角标,仅更新 Tooltip
|
||||
if (pu.isMacOS) {
|
||||
await trayManager.setTitle(count > 0 ? count.toString() : '');
|
||||
}
|
||||
// 统一更新 Tooltip 包含未读数
|
||||
final tip = count > 0 ? '闲言 — $count 条未读' : '闲言';
|
||||
await trayManager.setToolTip(tip);
|
||||
// Tooltip 的多语言版本由 DesktopTrayController 统一管理,
|
||||
// 此处不再硬编码中文 Tooltip,避免覆盖多语言版本
|
||||
} catch (e) {
|
||||
Log.e('TrayManagerTrayService.setUnreadBadge 失败: $e');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 基于 flutter_acrylic 实现 Windows 窗口特效(Win11 Mica Alt/Mica/Win10 Acrylic)
|
||||
/// 上次更新: 新增 Mica Alt 特效支持(Win11 build >= 22621),自动降级 Mica Alt → Mica → Acrylic
|
||||
/// 上次更新: 修复 setEffect 后 DwmExtendFrameIntoClientArea 导致 Flutter 视图布局异常
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:io';
|
||||
@@ -11,6 +11,7 @@ import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_acrylic/flutter_acrylic.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../desktop_window_effect_service.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
@@ -49,8 +50,10 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
|
||||
_isWin11OrLater = await _detectWindows11OrLater();
|
||||
_isMicaAltSupportedCache = await _isMicaAltSupported();
|
||||
_initialized = true;
|
||||
Log.i('WindowsAcrylicService 初始化完成 '
|
||||
'(isWin11=$_isWin11OrLater, micaAlt=$_isMicaAltSupportedCache)');
|
||||
Log.i(
|
||||
'WindowsAcrylicService 初始化完成 '
|
||||
'(isWin11=$_isWin11OrLater, micaAlt=$_isMicaAltSupportedCache)',
|
||||
);
|
||||
} catch (e) {
|
||||
Log.e('WindowsAcrylicService.initialize 失败: $e');
|
||||
}
|
||||
@@ -74,15 +77,9 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
|
||||
dark: isDark,
|
||||
);
|
||||
Log.i('WindowsAcrylicService 应用 Mica Alt 特效 (isDark=$isDark)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isWin11OrLater == true) {
|
||||
} else if (_isWin11OrLater == true) {
|
||||
// Win11: Mica 背景(跟随系统主题)
|
||||
await Window.setEffect(
|
||||
effect: WindowEffect.mica,
|
||||
dark: isDark,
|
||||
);
|
||||
await Window.setEffect(effect: WindowEffect.mica, dark: isDark);
|
||||
Log.i('WindowsAcrylicService 应用 Mica 特效 (isDark=$isDark)');
|
||||
} else {
|
||||
// Win10: Acrylic 半透明
|
||||
@@ -90,23 +87,44 @@ class WindowsAcrylicService implements DesktopWindowEffectService {
|
||||
effect: WindowEffect.acrylic,
|
||||
dark: isDark,
|
||||
color: isDark
|
||||
? const Color(0xCC1F1F1F) // 深色半透明
|
||||
? const Color(0xCC1F1F1F) // 深色半透明
|
||||
: const Color(0xCCF3F3F3), // 浅色半透明
|
||||
);
|
||||
Log.i('WindowsAcrylicService 应用 Acrylic 特效 (isDark=$isDark)');
|
||||
}
|
||||
|
||||
// 修复:setEffect 调用 DwmExtendFrameIntoClientArea(margins={-1}) 后,
|
||||
// Flutter 视图子窗口尺寸与实际客户区不匹配,导致上方黑色+下方低分辨率。
|
||||
// 通过强制重设窗口大小触发 WM_SIZE → MoveWindow 重新布局 Flutter 视图。
|
||||
await _refreshWindowLayout();
|
||||
} catch (e) {
|
||||
Log.e('WindowsAcrylicService.applyEffect 失败: $e');
|
||||
// 降级:禁用特效
|
||||
try {
|
||||
await Window.setEffect(
|
||||
effect: WindowEffect.disabled,
|
||||
dark: isDark,
|
||||
);
|
||||
await Window.setEffect(effect: WindowEffect.disabled, dark: isDark);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新窗口布局
|
||||
///
|
||||
/// DwmExtendFrameIntoClientArea 会改变客户区边界计算方式,
|
||||
/// 导致 Flutter 视图(child_content_ HWND)无法正确填充窗口。
|
||||
/// 通过重设窗口大小触发 WM_SIZE → Win32Window::MessageHandler → MoveWindow,
|
||||
/// 让 Flutter 视图重新适配客户区尺寸。
|
||||
Future<void> _refreshWindowLayout() async {
|
||||
try {
|
||||
final size = await windowManager.getSize();
|
||||
// 先设为稍大尺寸再恢复,确保 WM_SIZE 一定会被触发
|
||||
await windowManager.setSize(const Size(1281, 721));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
await windowManager.setSize(size);
|
||||
Log.i('WindowsAcrylicService 窗口布局刷新完成');
|
||||
} catch (e) {
|
||||
Log.w('WindowsAcrylicService._refreshWindowLayout 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测当前系统是否支持 Mica Alt(Win11 build >= 22621)
|
||||
///
|
||||
/// 使用 device_info_plus 读取精确的 Windows build 号。
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 剪贴板桥接工具(含隐私协议守卫)
|
||||
/// 创建时间: 2026-05-17
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-21
|
||||
/// 作用: 统一剪贴板读取入口,鸿蒙平台通过原生MethodChannel读取,
|
||||
/// 其他平台使用Flutter Clipboard;未同意隐私协议时禁止读取;
|
||||
/// 鸿蒙端拦截Flutter标准Clipboard通道,修复系统粘贴不工作;
|
||||
/// 鸿蒙端剪贴板由引擎原生 PlatformChannel 处理(需 READ_PASTEBOARD 权限);
|
||||
/// 桌面端(macOS/Windows)支持富文本(HTML)读写
|
||||
/// 上次更新: 新增富文本(HTML)支持 — 新增 setRichText/getHtml/hasHtml
|
||||
/// 方法,桌面端通过 apps.xy.xianyan/clipboard 通道写入HTML,
|
||||
/// 移动端/Web 降级为纯文本(去除HTML标签)
|
||||
/// 上次更新: 移除原生端 flutter/platform 通道拦截器(此前拦截器覆盖引擎
|
||||
/// 原生 Clipboard/SystemChrome.setPreferredOrientations 实现,
|
||||
/// 导致横屏翻转失效),剪贴板功能改由引擎原生处理
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -45,16 +45,15 @@ class ClipboardBridge {
|
||||
|
||||
/// 安装鸿蒙端标准剪贴板拦截器
|
||||
/// Flutter标准TextInputPlugin长按粘贴使用SystemChannels.platform的
|
||||
/// Clipboard.getData方法,鸿蒙端Flutter引擎未实现此方法,
|
||||
/// 导致所有输入框粘贴无反应。此拦截器将标准Clipboard调用
|
||||
/// 路由到鸿蒙原生pasteboard API。
|
||||
/// Clipboard.getData方法。Flutter鸿蒙引擎已在 PlatformChannel.ets 中
|
||||
/// 实现了 Clipboard 方法,无需 Dart 端额外拦截。
|
||||
///
|
||||
/// 实现原理:在原生端EntryAbility.ets中注册flutter/platform通道的
|
||||
/// 剪贴板方法处理器。Dart端此方法仅作为标记,实际拦截在原生端完成。
|
||||
/// 此方法保留为空操作,仅为向后兼容。剪贴板读写由引擎原生处理,
|
||||
/// 粘贴所需 READ_PASTEBOARD 权限已在 module.json5 中声明。
|
||||
static void installOhosClipboardInterceptor() {
|
||||
if (!_isOhos || _ohosInterceptorInstalled) return;
|
||||
_ohosInterceptorInstalled = true;
|
||||
Log.i('ClipboardBridge: 鸿蒙端剪贴板拦截器标记已设置(原生端负责实际拦截)');
|
||||
Log.i('ClipboardBridge: 鸿蒙端剪贴板由引擎原生 PlatformChannel 处理');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -145,10 +144,9 @@ class ClipboardBridge {
|
||||
if (_isOhos) {
|
||||
try {
|
||||
if (plainText != null && plainText.isNotEmpty) {
|
||||
await _channel.invokeMethod<void>(
|
||||
'Clipboard.setData',
|
||||
{'text': plainText},
|
||||
);
|
||||
await _channel.invokeMethod<void>('Clipboard.setData', {
|
||||
'text': plainText,
|
||||
});
|
||||
}
|
||||
return;
|
||||
} on MissingPluginException {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 编辑器主页面 (重写)
|
||||
// 创建时间: 2026-04-20
|
||||
// 更新时间: 2026-05-02
|
||||
// 更新时间: 2026-06-19
|
||||
// 作用: 卡片/壁纸编辑器入口,直接打开 ProEditorPage
|
||||
// 上次更新: 移除旧底部状态栏,功能已整合至EditorBottomToolbar
|
||||
// 上次更新: 修复 Windows 端 toImage 失败导致白屏,增加超时和多重回退
|
||||
// ============================================================
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
@@ -32,13 +32,36 @@ class _EditorPageState extends State<EditorPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_createDefaultBackground()
|
||||
.then((bytes) {
|
||||
if (mounted) setState(() => _backgroundBytes = bytes);
|
||||
})
|
||||
.catchError((Object e) {
|
||||
Log.w('背景创建失败,使用默认背景', e);
|
||||
});
|
||||
_initBackground();
|
||||
}
|
||||
|
||||
/// 初始化背景图片,带超时和多重回退
|
||||
Future<void> _initBackground() async {
|
||||
try {
|
||||
// 设置 3 秒超时,避免 toImage 在 Windows 上卡死
|
||||
final bytes = await _createDefaultBackground().timeout(
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
Log.i('编辑器背景创建成功,大小: ${bytes.length} bytes');
|
||||
if (mounted) setState(() => _backgroundBytes = bytes);
|
||||
} catch (e) {
|
||||
Log.e('背景创建失败,尝试回退', e);
|
||||
try {
|
||||
final fallback = await _createFallbackBackground().timeout(
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
Log.i('编辑器回退背景创建成功,大小: ${fallback.length} bytes');
|
||||
if (mounted) setState(() => _backgroundBytes = fallback);
|
||||
} catch (e2) {
|
||||
Log.e('回退背景也失败,使用内存编码', e2);
|
||||
// 最终回退:直接使用 1x1 像素 PNG 编码
|
||||
if (mounted) {
|
||||
final minimal = _minimalPng();
|
||||
Log.i('编辑器使用最小 PNG,大小: ${minimal.length} bytes');
|
||||
setState(() => _backgroundBytes = minimal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -82,7 +105,8 @@ class _EditorPageState extends State<EditorPage> {
|
||||
Future<Uint8List> _createDefaultBackground() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
const size = Size(1080, 1920);
|
||||
// 使用较小尺寸避免 Windows 端内存不足导致 toImage 失败
|
||||
const size = Size(540, 960);
|
||||
|
||||
const gradient = LinearGradient(
|
||||
colors: [Color(0xFF6C63FF), Color(0xFF4ECDC4)],
|
||||
@@ -103,6 +127,45 @@ class _EditorPageState extends State<EditorPage> {
|
||||
size.height.toInt(),
|
||||
);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
image.dispose();
|
||||
return byteData!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// 创建回退背景(小尺寸纯色),防止主背景创建失败导致白屏
|
||||
Future<Uint8List> _createFallbackBackground() async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
const size = Size(100, 100);
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(0, 0, size.width, size.height),
|
||||
Paint()..color = const Color(0xFF1A1A2E),
|
||||
);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(
|
||||
size.width.toInt(),
|
||||
size.height.toInt(),
|
||||
);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
image.dispose();
|
||||
return byteData!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// 最小 1x1 像素 PNG(硬编码),确保编辑器一定能渲染
|
||||
///
|
||||
/// 这是一个有效的 1x1 深蓝色 PNG 文件的字节数据,
|
||||
// 作为最终回退,避免任何 toImage 调用失败时白屏
|
||||
Uint8List _minimalPng() {
|
||||
// 1x1 像素 #1A1A2E (深蓝色) 的 PNG 编码
|
||||
return Uint8List.fromList([
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // 8bit RGB
|
||||
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk
|
||||
0x54, 0x08, 0xD7, 0x63, 0xA0, 0xA0, 0x20, 0x20, // compressed data
|
||||
0x00, 0x00, 0x00, 0x44, 0x00, 0x01, 0xA6, 0x21, // for #1A1A2E
|
||||
0x80, 0x9E, 0xE7, 0x00, 0x00, 0x00, 0x00, 0x49, // IEND chunk
|
||||
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,16 +613,21 @@ class ProEditorPageState extends State<ProEditorPage>
|
||||
final borderRadius = _canvasStyle.useIndependentRadius
|
||||
? BorderRadius.only(
|
||||
topLeft: Radius.circular(
|
||||
_canvasStyle.borderRadiusTL.clamp(0.0, 80.0)),
|
||||
_canvasStyle.borderRadiusTL.clamp(0.0, 80.0),
|
||||
),
|
||||
topRight: Radius.circular(
|
||||
_canvasStyle.borderRadiusTR.clamp(0.0, 80.0)),
|
||||
_canvasStyle.borderRadiusTR.clamp(0.0, 80.0),
|
||||
),
|
||||
bottomLeft: Radius.circular(
|
||||
_canvasStyle.borderRadiusBL.clamp(0.0, 80.0)),
|
||||
_canvasStyle.borderRadiusBL.clamp(0.0, 80.0),
|
||||
),
|
||||
bottomRight: Radius.circular(
|
||||
_canvasStyle.borderRadiusBR.clamp(0.0, 80.0)),
|
||||
_canvasStyle.borderRadiusBR.clamp(0.0, 80.0),
|
||||
),
|
||||
)
|
||||
: BorderRadius.circular(
|
||||
_canvasStyle.borderRadius.clamp(0.0, 80.0));
|
||||
_canvasStyle.borderRadius.clamp(0.0, 80.0),
|
||||
);
|
||||
if (borderRadius == BorderRadius.zero) return child;
|
||||
return Container(
|
||||
color: p.bgCanvas,
|
||||
@@ -726,7 +731,11 @@ class ProEditorPageState extends State<ProEditorPage>
|
||||
}
|
||||
},
|
||||
onEditorZoomMatrix4Change: (matrix) {
|
||||
_zoomScaleNotifier.value = matrix.getMaxScaleOnAxis();
|
||||
final scale = matrix.getMaxScaleOnAxis();
|
||||
// 防止 NaN/Infinite 值导致 Matrix4 entries must be finite 崩溃
|
||||
if (scale.isFinite) {
|
||||
_zoomScaleNotifier.value = scale;
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -136,7 +136,7 @@ class ProEditorBridge {
|
||||
mainEditor: pro.MainEditorConfigs(
|
||||
enableZoom: true,
|
||||
editorMinScale: 0.5,
|
||||
boundaryMargin: const EdgeInsets.all(double.infinity),
|
||||
boundaryMargin: const EdgeInsets.all(10000),
|
||||
safeArea: const pro.EditorSafeArea.none(),
|
||||
widgets: pro.MainEditorWidgets(
|
||||
appBar: (editor, rebuildStream) => pro.ReactiveAppbar(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 桌面端托盘控制器
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: 整合托盘服务 + 菜单构建器 + 未读数 Provider,管理托盘生命周期
|
||||
/// 上次更新: 初始创建,实现 DesktopTrayController
|
||||
/// 上次更新: 添加语言变化监听,托盘菜单支持动态多语言
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
@@ -48,8 +48,10 @@ class DesktopTrayController {
|
||||
final WidgetRef _ref;
|
||||
StreamSubscription<TrayEvent>? _eventSub;
|
||||
ProviderSubscription<int>? _unreadSub;
|
||||
ProviderSubscription<GeneralSettingsState>? _generalSettingsSub;
|
||||
bool _initialized = false;
|
||||
bool _isWindowVisible = true;
|
||||
|
||||
/// 防止 popUpContextMenu 触发的 performClick 导致重入
|
||||
bool _isPopUpMenuInProgress = false;
|
||||
|
||||
@@ -89,16 +91,27 @@ class DesktopTrayController {
|
||||
await trayService.setUnreadBadge(unreadCount);
|
||||
|
||||
// 6. 监听未读数变化
|
||||
_unreadSub = _ref.listenManual(
|
||||
trayUnreadCountProvider,
|
||||
(previous, next) {
|
||||
_onUnreadCountChanged(next);
|
||||
},
|
||||
);
|
||||
_unreadSub = _ref.listenManual(trayUnreadCountProvider, (previous, next) {
|
||||
_onUnreadCountChanged(next);
|
||||
});
|
||||
|
||||
// 7. 监听托盘事件
|
||||
_eventSub = trayService.events.listen(_onTrayEvent);
|
||||
|
||||
// 8. 监听语言设置变化,动态更新托盘菜单
|
||||
_generalSettingsSub = _ref.listenManual(generalSettingsProvider, (
|
||||
previous,
|
||||
next,
|
||||
) {
|
||||
if (previous?.languageId != next.languageId) {
|
||||
_updateMenu();
|
||||
// 更新 Tooltip
|
||||
final unreadCount = _ref.read(trayUnreadCountProvider);
|
||||
final labels = TrayMenuLabels.forLanguage(next.languageId);
|
||||
trayService.setToolTip(labels.formatTooltip(unreadCount));
|
||||
}
|
||||
});
|
||||
|
||||
Log.i('DesktopTrayController 初始化完成');
|
||||
} catch (e, st) {
|
||||
Log.e('DesktopTrayController 初始化失败', e, st);
|
||||
@@ -111,6 +124,8 @@ class DesktopTrayController {
|
||||
_eventSub = null;
|
||||
_unreadSub?.close();
|
||||
_unreadSub = null;
|
||||
_generalSettingsSub?.close();
|
||||
_generalSettingsSub = null;
|
||||
|
||||
if (_initialized) {
|
||||
try {
|
||||
@@ -183,9 +198,7 @@ class DesktopTrayController {
|
||||
|
||||
final languageId = _ref.read(generalSettingsProvider).languageId;
|
||||
final labels = TrayMenuLabels.forLanguage(languageId);
|
||||
await DesktopTrayService.instance.setToolTip(
|
||||
labels.formatTooltip(count),
|
||||
);
|
||||
await DesktopTrayService.instance.setToolTip(labels.formatTooltip(count));
|
||||
|
||||
// 未读数变化时也更新菜单(最近阅读列表可能变化)
|
||||
await _updateMenu();
|
||||
@@ -304,9 +317,7 @@ class DesktopTrayController {
|
||||
|
||||
void _onToggleDarkMode() {
|
||||
final settings = _ref.read(themeSettingsProvider);
|
||||
final newMode = settings.isDark
|
||||
? AppThemeMode.light
|
||||
: AppThemeMode.dark;
|
||||
final newMode = settings.isDark ? AppThemeMode.light : AppThemeMode.dark;
|
||||
_ref.read(themeSettingsProvider.notifier).setThemeMode(newMode);
|
||||
// 主题变化会触发 onThemeChanged 回调,无需在此更新菜单
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 桌面端自定义窗口标题栏
|
||||
/// 创建时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-18
|
||||
/// 更新时间: 2026-06-22
|
||||
/// 作用: 软件样式标题栏,替代系统默认标题栏,支持动态主题+动态样式
|
||||
/// 上次更新: 初始创建,实现 macOS 风格(红黄绿) + Windows 风格(─ ▢ ✕)
|
||||
/// 上次更新: "口"按钮改为弹出 3×2 窗口大小预设网格菜单
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -17,6 +15,7 @@ import '../../core/theme/app_theme.dart';
|
||||
import '../../core/utils/logger.dart';
|
||||
import '../../core/utils/platform/platform_utils.dart' as pu;
|
||||
import '../../features/settings/providers/theme_settings_provider.dart';
|
||||
import '../../shared/widgets/window_size_popup.dart';
|
||||
|
||||
/// 标题栏样式配置(动态样式)
|
||||
class DesktopTitleBarStyle {
|
||||
@@ -167,7 +166,8 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
|
||||
final isAmoled = theme.isAmoled;
|
||||
|
||||
// 根据平台选择默认样式
|
||||
final style = widget.style ??
|
||||
final style =
|
||||
widget.style ??
|
||||
(pu.isMacOS
|
||||
? const DesktopTitleBarStyle.macOS()
|
||||
: const DesktopTitleBarStyle.windows());
|
||||
@@ -180,24 +180,29 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
|
||||
? const Color(0xFFE5E5E7)
|
||||
: const Color(0xFF1D1D1F);
|
||||
|
||||
// Windows: C++ HTTRANSPARENT 方案处理拖拽
|
||||
// 子类化 Flutter 子窗口,WM_NCHITTEST 标题栏区域返回 HTTRANSPARENT,
|
||||
// Windows 转发到父窗口,window_manager_plugin.cpp 返回 HTCAPTION → 原生拖拽。
|
||||
// 无需 Dart 侧 startDragging(MethodChannel 延迟导致卡顿)。
|
||||
if (pu.isWindows) {
|
||||
return Container(
|
||||
height: style.height,
|
||||
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
|
||||
child: _buildWindowsLayout(style, foregroundColor),
|
||||
);
|
||||
}
|
||||
|
||||
// macOS: 使用 GestureDetector 处理拖拽和双击
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPanStart: (_) => windowManager.startDragging(),
|
||||
onPanDown: (_) {
|
||||
windowManager.startDragging();
|
||||
},
|
||||
onDoubleTap: _toggleMaximize,
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: style.blurRadius,
|
||||
sigmaY: style.blurRadius,
|
||||
),
|
||||
child: Container(
|
||||
height: style.height,
|
||||
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
|
||||
child: pu.isMacOS
|
||||
? _buildMacOSLayout(style, foregroundColor)
|
||||
: _buildWindowsLayout(style, foregroundColor),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
height: style.height,
|
||||
color: backgroundColor.withValues(alpha: style.backgroundOpacity),
|
||||
child: _buildMacOSLayout(style, foregroundColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -250,9 +255,7 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
|
||||
SizedBox(width: style.buttonSpacing),
|
||||
_MacOSTrafficButton(
|
||||
color: const Color(0xFF28C840),
|
||||
icon: _isMaximized
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
icon: _isMaximized ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
isHovering: _isHoveringMaximize,
|
||||
onHover: (v) => setState(() => _isHoveringMaximize = v),
|
||||
onTap: _toggleMaximize,
|
||||
@@ -333,19 +336,17 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
|
||||
iconSize: 16,
|
||||
isHovering: _isHoveringMinimize,
|
||||
onHover: (v) => setState(() => _isHoveringMinimize = v),
|
||||
onTap: () => windowManager.minimize(),
|
||||
onTap: (_) => windowManager.minimize(),
|
||||
foregroundColor: foregroundColor,
|
||||
width: 46,
|
||||
height: style.height,
|
||||
),
|
||||
_WindowsControlButton(
|
||||
icon: _isMaximized
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
icon: _isMaximized ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
iconSize: 14,
|
||||
isHovering: _isHoveringMaximize,
|
||||
onHover: (v) => setState(() => _isHoveringMaximize = v),
|
||||
onTap: _toggleMaximize,
|
||||
onTap: (btnCtx) => WindowSizePopup.show(btnCtx),
|
||||
foregroundColor: foregroundColor,
|
||||
width: 46,
|
||||
height: style.height,
|
||||
@@ -355,7 +356,7 @@ class _DesktopWindowTitleBarState extends ConsumerState<DesktopWindowTitleBar>
|
||||
iconSize: 16,
|
||||
isHovering: _isHoveringClose,
|
||||
onHover: (v) => setState(() => _isHoveringClose = v),
|
||||
onTap: () => windowManager.close(),
|
||||
onTap: (_) => windowManager.close(),
|
||||
foregroundColor: foregroundColor,
|
||||
width: 46,
|
||||
height: style.height,
|
||||
@@ -460,7 +461,9 @@ class _WindowsControlButton extends StatefulWidget {
|
||||
final double iconSize;
|
||||
final bool isHovering;
|
||||
final ValueChanged<bool> onHover;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// 点击回调,传入按钮自身的 BuildContext 用于弹出菜单定位
|
||||
final void Function(BuildContext buttonContext) onTap;
|
||||
final Color foregroundColor;
|
||||
final double width;
|
||||
final double height;
|
||||
@@ -485,7 +488,7 @@ class _WindowsControlButtonState extends State<_WindowsControlButton> {
|
||||
onEnter: (_) => widget.onHover(true),
|
||||
onExit: (_) => widget.onHover(false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onTap: () => widget.onTap(context),
|
||||
child: Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../../core/theme/app_spacing.dart';
|
||||
import '../../../../../core/theme/app_theme.dart';
|
||||
@@ -597,7 +598,7 @@ mixin ReadLaterPageUiMixin on ReadLaterPageStateAccessor {
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushNamed('/discover');
|
||||
context.go('/discover');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
/// ============================================================
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart' show Material, MaterialType, PageView, Colors;
|
||||
import 'package:flutter/material.dart'
|
||||
show Material, MaterialType, PageView, Colors;
|
||||
|
||||
import '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../core/storage/kv_storage.dart';
|
||||
@@ -70,8 +71,9 @@ class NewFeaturesDialog {
|
||||
// 1. 精确匹配(去掉 v 前缀比较)
|
||||
final cleanVer = version.startsWith('v') ? version.substring(1) : version;
|
||||
for (final e in AppUpdateLog.entries) {
|
||||
final entryVer =
|
||||
e.version.startsWith('v') ? e.version.substring(1) : e.version;
|
||||
final entryVer = e.version.startsWith('v')
|
||||
? e.version.substring(1)
|
||||
: e.version;
|
||||
if (entryVer == cleanVer) return e;
|
||||
}
|
||||
|
||||
@@ -80,8 +82,9 @@ class NewFeaturesDialog {
|
||||
if (parts.length >= 2) {
|
||||
final majorMinor = '${parts[0]}.${parts[1]}';
|
||||
for (final e in AppUpdateLog.entries) {
|
||||
final entryVer =
|
||||
e.version.startsWith('v') ? e.version.substring(1) : e.version;
|
||||
final entryVer = e.version.startsWith('v')
|
||||
? e.version.substring(1)
|
||||
: e.version;
|
||||
if (entryVer.startsWith('$majorMinor.')) return e;
|
||||
}
|
||||
}
|
||||
@@ -100,11 +103,7 @@ class NewFeaturesDialog {
|
||||
await showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return _NewFeaturesCarousel(
|
||||
entry: entry,
|
||||
t: t,
|
||||
ext: ext,
|
||||
);
|
||||
return _NewFeaturesCarousel(entry: entry, t: t, ext: ext);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -150,25 +149,31 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
final cards = <_FeatureCardData>[];
|
||||
|
||||
// 第一张卡片:版本概览
|
||||
cards.add(_FeatureCardData(
|
||||
icon: CupertinoIcons.sparkles,
|
||||
iconColor: widget.ext.accent,
|
||||
title: '${widget.entry.version} 更新',
|
||||
subtitle: widget.entry.date,
|
||||
description: widget.t.onboarding.knowNewFeatures
|
||||
.replaceAll('{0}', AppVersion.version),
|
||||
isOverview: true,
|
||||
));
|
||||
cards.add(
|
||||
_FeatureCardData(
|
||||
icon: CupertinoIcons.sparkles,
|
||||
iconColor: widget.ext.accent,
|
||||
title: '${widget.entry.version} 更新',
|
||||
subtitle: widget.entry.date,
|
||||
description: widget.t.onboarding.knowNewFeatures.replaceAll(
|
||||
'{0}',
|
||||
AppVersion.version,
|
||||
),
|
||||
isOverview: true,
|
||||
),
|
||||
);
|
||||
|
||||
// 后续卡片:每个变更点
|
||||
for (final change in widget.entry.changes) {
|
||||
final parsed = _parseChange(change);
|
||||
cards.add(_FeatureCardData(
|
||||
icon: parsed.icon,
|
||||
iconColor: parsed.color,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
));
|
||||
cards.add(
|
||||
_FeatureCardData(
|
||||
icon: parsed.icon,
|
||||
iconColor: parsed.color,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return cards;
|
||||
@@ -182,11 +187,26 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
String? emoji;
|
||||
String rest = trimmed;
|
||||
if (trimmed.isNotEmpty) {
|
||||
// 匹配常见 emoji 前缀
|
||||
final emojiMatch = RegExp(r'^([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}✨🔧🆕🎨⚡🌐📱🔒🎯✅🐛🚀💡📦🔥⭐💕🎉]+)\s*').firstMatch(trimmed);
|
||||
if (emojiMatch != null) {
|
||||
emoji = emojiMatch.group(1);
|
||||
rest = trimmed.substring(emojiMatch.end).trim();
|
||||
// 使用 code point 检测 emoji 前缀(避免正则 Unicode 转义兼容性问题)
|
||||
final runes = trimmed.runes.toList();
|
||||
int emojiEnd = 0;
|
||||
while (emojiEnd < runes.length) {
|
||||
final cp = runes[emojiEnd];
|
||||
final isEmoji =
|
||||
(cp >= 0x1F300 && cp <= 0x1FAFF) || // Emoji 主范围
|
||||
(cp >= 0x2600 && cp <= 0x27BF) || // 杂项符号(☀➿等)
|
||||
(cp >= 0x2B00 && cp <= 0x2BFF) || // 其他符号
|
||||
'✨🔧🆕🎨⚡🌐📱🔒🎯✅🐛🚀💡📦🔥⭐💕🎉'.runes.contains(cp) ||
|
||||
(cp >= 0xFE00 && cp <= 0xFE0F) || // Variation Selector
|
||||
(cp >= 0x1F900 && cp <= 0x1F9FF); // 补充符号
|
||||
if (!isEmoji) break;
|
||||
emojiEnd++;
|
||||
}
|
||||
if (emojiEnd > 0) {
|
||||
emoji = String.fromCharCodes(runes.sublist(0, emojiEnd));
|
||||
rest = trimmed
|
||||
.substring(String.fromCharCodes(runes.sublist(0, emojiEnd)).length)
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +245,10 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor);
|
||||
}
|
||||
if (emoji.contains('📱')) {
|
||||
return _IconMatch(CupertinoIcons.device_phone_portrait, ext.iconTintCyan);
|
||||
return _IconMatch(
|
||||
CupertinoIcons.device_phone_portrait,
|
||||
ext.iconTintCyan,
|
||||
);
|
||||
}
|
||||
if (emoji.contains('🚀') || emoji.contains('✨')) {
|
||||
return _IconMatch(CupertinoIcons.sparkles, ext.accent);
|
||||
@@ -255,19 +278,28 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
if (text.contains('修复') || lower.contains('fix') || lower.contains('bug')) {
|
||||
return _IconMatch(CupertinoIcons.wrench_fill, ext.warningColor);
|
||||
}
|
||||
if (text.contains('优化') || text.contains('改进') || text.contains('提升') || lower.contains('optim')) {
|
||||
if (text.contains('优化') ||
|
||||
text.contains('改进') ||
|
||||
text.contains('提升') ||
|
||||
lower.contains('optim')) {
|
||||
return _IconMatch(CupertinoIcons.paintbrush_fill, ext.iconTintPurple);
|
||||
}
|
||||
if (text.contains('性能') || lower.contains('performance')) {
|
||||
return _IconMatch(CupertinoIcons.bolt_fill, ext.iconTintYellow);
|
||||
}
|
||||
if (text.contains('多语言') || text.contains('翻译') || lower.contains('language')) {
|
||||
if (text.contains('多语言') ||
|
||||
text.contains('翻译') ||
|
||||
lower.contains('language')) {
|
||||
return _IconMatch(CupertinoIcons.globe, ext.iconTintBlue);
|
||||
}
|
||||
if (text.contains('安全') || text.contains('密保') || lower.contains('security')) {
|
||||
if (text.contains('安全') ||
|
||||
text.contains('密保') ||
|
||||
lower.contains('security')) {
|
||||
return _IconMatch(CupertinoIcons.lock_shield_fill, ext.errorColor);
|
||||
}
|
||||
if (text.contains('框架') || text.contains('架构') || lower.contains('framework')) {
|
||||
if (text.contains('框架') ||
|
||||
text.contains('架构') ||
|
||||
lower.contains('framework')) {
|
||||
return _IconMatch(CupertinoIcons.cube_box_fill, ext.iconTintMint);
|
||||
}
|
||||
|
||||
@@ -375,7 +407,11 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
}
|
||||
|
||||
/// 构建单张卡片
|
||||
Widget _buildCard(_FeatureCardData card, AppThemeExtension ext, bool isOverview) {
|
||||
Widget _buildCard(
|
||||
_FeatureCardData card,
|
||||
AppThemeExtension ext,
|
||||
bool isOverview,
|
||||
) {
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -429,7 +465,10 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
|
||||
/// 构建带光晕效果的图标
|
||||
Widget _buildIconWithGlow(
|
||||
_FeatureCardData card, AppThemeExtension ext, bool isOverview) {
|
||||
_FeatureCardData card,
|
||||
AppThemeExtension ext,
|
||||
bool isOverview,
|
||||
) {
|
||||
final iconSize = isOverview ? 64.0 : 56.0;
|
||||
final containerSize = isOverview ? 120.0 : 100.0;
|
||||
|
||||
@@ -459,11 +498,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
card.icon,
|
||||
size: iconSize,
|
||||
color: card.iconColor,
|
||||
),
|
||||
child: Icon(card.icon, size: iconSize, color: card.iconColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -480,9 +515,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: ext.bgCard,
|
||||
border: Border(
|
||||
top: BorderSide(color: ext.overlaySubtle, width: 0.5),
|
||||
),
|
||||
border: Border(top: BorderSide(color: ext.overlaySubtle, width: 0.5)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
@@ -544,9 +577,7 @@ class _NewFeaturesCarouselState extends State<_NewFeaturesCarousel> {
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Text(
|
||||
isLastPage
|
||||
? widget.t.common.gotIt
|
||||
: widget.t.common.confirm,
|
||||
isLastPage ? widget.t.common.gotIt : widget.t.common.confirm,
|
||||
style: AppTypography.body.copyWith(
|
||||
color: ext.textOnAccent,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// ============================================================
|
||||
/// ============================================================
|
||||
// 闲言APP — 应用入口
|
||||
// 创建时间: 2026-04-20
|
||||
/// 更新时间: 2026-06-05
|
||||
/// 更新时间: 2026-06-19
|
||||
/// 作用: main 函数,初始化存储 + 液态玻璃 + 异常捕获 + 启动 App
|
||||
/// 上次更新: 新增PlatformCapabilities.init()平台能力注册表初始化
|
||||
/// 上次更新: 移除 main.dart 中的 flutter_acrylic 初始化,统一由 WindowsAcrylicService 管理
|
||||
// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
@@ -65,6 +65,7 @@ void main() async {
|
||||
Future<void> _appMain() async {
|
||||
if (pu.isDesktop) {
|
||||
await windowManager.ensureInitialized();
|
||||
|
||||
const windowOptions = WindowOptions(
|
||||
minimumSize: Size(400, 600),
|
||||
title: '闲言',
|
||||
@@ -74,6 +75,10 @@ Future<void> _appMain() async {
|
||||
// macOS: 隐藏原生红黄绿按钮,由 Flutter 侧自绘
|
||||
skipTaskbar: false,
|
||||
);
|
||||
|
||||
// Windows: flutter_acrylic 初始化统一由 WindowsAcrylicService 管理
|
||||
// Window.initialize() 和 Window.setEffect() 均在 app.dart 的 _initWindowEffect 中调用
|
||||
// 不在此处调用 Window.initialize(),避免重复初始化和布局异常
|
||||
await windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.show();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// ============================================================
|
||||
// 闲言APP — 键盘适配工具
|
||||
// 创建时间: 2026-05-02
|
||||
// 更新时间: 2026-05-30
|
||||
// 更新时间: 2026-06-21
|
||||
// 作用: 解决输入法面板遮挡底部Sheet/弹窗的通用方案 + 全局键盘管理
|
||||
// 上次更新: 集成flutter_keyboard_visibility替换MediaQuery轮询检测键盘可见性
|
||||
// 上次更新: 鸿蒙端 flutter_keyboard_visibility 插件无原生实现,
|
||||
// 优雅降级为 MediaQuery 检测,避免 MissingPluginException
|
||||
// ============================================================
|
||||
|
||||
import 'dart:async';
|
||||
@@ -11,6 +12,7 @@ import 'dart:async';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu;
|
||||
|
||||
/// 键盘安全底部弹窗包装器
|
||||
///
|
||||
@@ -118,15 +120,36 @@ class KeyboardManager {
|
||||
static final KeyboardManager instance = KeyboardManager._();
|
||||
|
||||
/// 键盘可见性控制器(flutter_keyboard_visibility)
|
||||
static final KeyboardVisibilityController _keyboardVisibility =
|
||||
KeyboardVisibilityController();
|
||||
/// 鸿蒙端无原生实现,延迟初始化并捕获异常,降级为不可用
|
||||
static KeyboardVisibilityController? _keyboardVisibility;
|
||||
static bool _keyboardVisibilityAvailable = true;
|
||||
|
||||
static KeyboardVisibilityController? get _keyboardVisibilityController {
|
||||
if (!_keyboardVisibilityAvailable) return null;
|
||||
if (_keyboardVisibility != null) return _keyboardVisibility;
|
||||
try {
|
||||
_keyboardVisibility = KeyboardVisibilityController();
|
||||
return _keyboardVisibility;
|
||||
} catch (e) {
|
||||
_keyboardVisibilityAvailable = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式键盘可见性变更
|
||||
static Stream<bool> get keyboardVisibilityStream =>
|
||||
_keyboardVisibility.onChange;
|
||||
/// 鸿蒙端插件不可用时返回空流(永不触发),调用方需用 MediaQuery 兜底
|
||||
static Stream<bool> get keyboardVisibilityStream {
|
||||
if (pu.isOhos) return const Stream<bool>.empty();
|
||||
return _keyboardVisibilityController?.onChange ??
|
||||
const Stream<bool>.empty();
|
||||
}
|
||||
|
||||
/// 同步获取键盘是否可见(无需BuildContext)
|
||||
static bool get isKeyboardVisibleSync => _keyboardVisibility.isVisible;
|
||||
/// 鸿蒙端插件不可用时返回 false,调用方需用 MediaQuery 兜底
|
||||
static bool get isKeyboardVisibleSync {
|
||||
if (pu.isOhos) return false;
|
||||
return _keyboardVisibilityController?.isVisible ?? false;
|
||||
}
|
||||
|
||||
/// 页面生命周期管理
|
||||
final Set<String> _activePages = {};
|
||||
@@ -170,8 +193,10 @@ class KeyboardManager {
|
||||
}
|
||||
|
||||
/// 键盘是否可见(使用KeyboardVisibilityController,无需BuildContext轮询)
|
||||
/// 鸿蒙端降级为 MediaQuery 检测
|
||||
static bool isKeyboardVisible(BuildContext context) {
|
||||
return _keyboardVisibility.isVisible;
|
||||
if (pu.isOhos) return MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
return _keyboardVisibilityController?.isVisible ?? false;
|
||||
}
|
||||
|
||||
/// 键盘高度(仍需MediaQuery,flutter_keyboard_visibility不提供高度)
|
||||
@@ -191,8 +216,10 @@ class KeyboardSafe {
|
||||
}
|
||||
|
||||
/// 键盘是否可见(使用KeyboardVisibilityController,无需BuildContext轮询)
|
||||
/// 鸿蒙端降级为 MediaQuery 检测
|
||||
static bool isKeyboardVisible(BuildContext context) {
|
||||
return KeyboardManager._keyboardVisibility.isVisible;
|
||||
if (pu.isOhos) return MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
return KeyboardManager._keyboardVisibilityController?.isVisible ?? false;
|
||||
}
|
||||
|
||||
static Future<T?> showSheet<T>({
|
||||
@@ -261,7 +288,9 @@ class _ManagedCupertinoTextFieldState extends State<ManagedCupertinoTextField> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyboardSub = KeyboardManager.keyboardVisibilityStream.listen(_onKeyboardVisibility);
|
||||
_keyboardSub = KeyboardManager.keyboardVisibilityStream.listen(
|
||||
_onKeyboardVisibility,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
413
lib/shared/widgets/window_size_popup.dart
Normal file
413
lib/shared/widgets/window_size_popup.dart
Normal file
@@ -0,0 +1,413 @@
|
||||
/// ============================================================
|
||||
/// 闲言APP — 窗口大小预设弹出菜单
|
||||
/// 创建时间: 2026-06-22
|
||||
/// 更新时间: 2026-06-22
|
||||
/// 作用: 点击"口"按钮在按钮下方弹出 3×2 网格菜单,选择窗口预设尺寸
|
||||
/// 上次更新: 从 adaptive_nav_bar.dart 提取为共享组件,供 DesktopWindowTitleBar 和 AdaptiveNavBar 复用
|
||||
/// ============================================================
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/theme/glass_tokens.dart';
|
||||
|
||||
/// 窗口大小预设弹出菜单
|
||||
///
|
||||
/// 使用 [WindowSizePopup.show] 在指定按钮位置弹出 3×2 网格菜单。
|
||||
/// iOS 26 Liquid Glass 风格,Alert 层级毛玻璃。
|
||||
class WindowSizePopup {
|
||||
WindowSizePopup._();
|
||||
|
||||
/// 在 [buttonContext] 对应的按钮下方弹出窗口大小预设菜单
|
||||
///
|
||||
/// [buttonContext] 应为按钮自身的 BuildContext,用于计算弹出位置。
|
||||
static Future<void> show(BuildContext buttonContext) async {
|
||||
final isMaximized = await windowManager.isMaximized();
|
||||
if (!buttonContext.mounted) return;
|
||||
|
||||
// 获取按钮在屏幕中的位置,用于弹出菜单定位
|
||||
final renderBox = buttonContext.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
final buttonPos = renderBox.localToGlobal(Offset.zero);
|
||||
final buttonSize = renderBox.size;
|
||||
|
||||
// 通过 OverlayEntry 弹出自定义菜单,避免阻塞 UI 线程
|
||||
late OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => _WindowSizePopupContent(
|
||||
buttonPos: buttonPos,
|
||||
buttonSize: buttonSize,
|
||||
isMaximized: isMaximized,
|
||||
onDismiss: () => entry.remove(),
|
||||
),
|
||||
);
|
||||
Overlay.of(buttonContext, rootOverlay: true).insert(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// 窗口大小弹出菜单内容(iOS 26 Liquid Glass 风格)
|
||||
/// 3 列 × 2 行网格布局,在按钮下方定位弹出
|
||||
class _WindowSizePopupContent extends StatefulWidget {
|
||||
const _WindowSizePopupContent({
|
||||
required this.buttonPos,
|
||||
required this.buttonSize,
|
||||
required this.isMaximized,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
/// 按钮左上角的全局坐标
|
||||
final Offset buttonPos;
|
||||
/// 按钮尺寸
|
||||
final Size buttonSize;
|
||||
/// 当前窗口是否已最大化
|
||||
final bool isMaximized;
|
||||
/// 关闭菜单回调
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
State<_WindowSizePopupContent> createState() =>
|
||||
_WindowSizePopupContentState();
|
||||
}
|
||||
|
||||
class _WindowSizePopupContentState extends State<_WindowSizePopupContent>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _scale;
|
||||
late final Animation<double> _opacity;
|
||||
|
||||
/// 弹出菜单宽度
|
||||
static const double _popupWidth = 348.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
reverseDuration: const Duration(milliseconds: 120),
|
||||
vsync: this,
|
||||
);
|
||||
_scale = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic);
|
||||
_opacity = CurvedAnimation(parent: _controller, curve: Curves.easeIn);
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 关闭菜单(先播放退出动画再移除 Overlay)
|
||||
void _close() {
|
||||
_controller.reverse().then((_) {
|
||||
if (mounted) widget.onDismiss();
|
||||
});
|
||||
}
|
||||
|
||||
/// 应用窗口尺寸
|
||||
Future<void> _applySize(int? w, int? h) async {
|
||||
_close();
|
||||
if (w == null || h == null) {
|
||||
// 最大化 / 还原
|
||||
if (widget.isMaximized) {
|
||||
await windowManager.unmaximize();
|
||||
} else {
|
||||
await windowManager.maximize();
|
||||
}
|
||||
} else {
|
||||
if (await windowManager.isMaximized()) {
|
||||
await windowManager.unmaximize();
|
||||
}
|
||||
await windowManager.setSize(Size(w.toDouble(), h.toDouble()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = AppTheme.ext(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
// 计算弹出位置:右对齐按钮,出现在按钮下方
|
||||
double left = widget.buttonPos.dx + widget.buttonSize.width - _popupWidth;
|
||||
const margin = 8.0;
|
||||
if (left < margin) left = margin;
|
||||
if (left + _popupWidth > mediaQuery.size.width - margin) {
|
||||
left = mediaQuery.size.width - _popupWidth - margin;
|
||||
}
|
||||
final top = widget.buttonPos.dy + widget.buttonSize.height + 4;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// 全屏透明遮罩,点击外部关闭
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _close,
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
// 弹出菜单本体
|
||||
Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (_, child) => Opacity(
|
||||
opacity: _opacity.value,
|
||||
child: Transform.scale(
|
||||
scale: 0.92 + 0.08 * _scale.value,
|
||||
alignment: Alignment.topRight,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: _buildPopup(ext),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建弹出菜单容器
|
||||
Widget _buildPopup(AppThemeExtension ext) {
|
||||
return Container(
|
||||
width: _popupWidth,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: GlassTokens.borderColor(ext.isDark),
|
||||
width: GlassTokens.borderWidth,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: GlassTokens.shadowColor(ext.isDark),
|
||||
blurRadius: GlassTokens.shadowBlur,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: GlassTokens.alertBlur,
|
||||
sigmaY: GlassTokens.alertBlur,
|
||||
),
|
||||
child: Container(
|
||||
color: ext.glassColor.withValues(
|
||||
alpha: ext.isDark
|
||||
? GlassTokens.alertOpacityDark
|
||||
: GlassTokens.alertOpacityLight,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildHeader(ext),
|
||||
Container(
|
||||
height: 0.5,
|
||||
color: ext.dividerOnCard,
|
||||
),
|
||||
_buildGrid(ext),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 标题栏
|
||||
Widget _buildHeader(AppThemeExtension ext) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.rectangle_split_3x1,
|
||||
size: 14,
|
||||
color: ext.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'窗口大小',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ext.textSecondary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
widget.isMaximized ? '当前:最大化' : '当前:自定义',
|
||||
style: TextStyle(fontSize: 11, color: ext.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 3×2 网格
|
||||
Widget _buildGrid(AppThemeExtension ext) {
|
||||
// 预设尺寸列表:(图标, 标签, 宽, 高)
|
||||
final presets = <_SizePreset>[
|
||||
const _SizePreset(
|
||||
icon: CupertinoIcons.device_phone_portrait,
|
||||
label: '小窗',
|
||||
width: 800,
|
||||
height: 600,
|
||||
),
|
||||
const _SizePreset(
|
||||
icon: CupertinoIcons.desktopcomputer,
|
||||
label: '标准',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
),
|
||||
const _SizePreset(
|
||||
icon: CupertinoIcons.device_laptop,
|
||||
label: '宽屏',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
),
|
||||
const _SizePreset(
|
||||
icon: CupertinoIcons.tv,
|
||||
label: '大屏',
|
||||
width: 1440,
|
||||
height: 900,
|
||||
),
|
||||
const _SizePreset(
|
||||
icon: CupertinoIcons.rectangle_on_rectangle,
|
||||
label: '全高清',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
),
|
||||
_SizePreset(
|
||||
icon: widget.isMaximized
|
||||
? CupertinoIcons.arrow_down_right_square
|
||||
: CupertinoIcons.rectangle_fill,
|
||||
label: widget.isMaximized ? '还原' : '最大化',
|
||||
width: null,
|
||||
height: null,
|
||||
),
|
||||
];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
child: GridView.count(
|
||||
crossAxisCount: 3,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: AppSpacing.sm,
|
||||
crossAxisSpacing: AppSpacing.sm,
|
||||
childAspectRatio: 1.05,
|
||||
children: presets
|
||||
.map((p) => _HoverCell(
|
||||
preset: p,
|
||||
ext: ext,
|
||||
onTap: () => _applySize(p.width, p.height),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 窗口尺寸预设数据模型
|
||||
class _SizePreset {
|
||||
const _SizePreset({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final int? width;
|
||||
final int? height;
|
||||
|
||||
/// 尺寸显示文本
|
||||
String get dimensionText =>
|
||||
width != null && height != null ? '$width × $height' : '';
|
||||
}
|
||||
|
||||
/// 可悬停的尺寸选择单元格
|
||||
class _HoverCell extends StatefulWidget {
|
||||
const _HoverCell({
|
||||
required this.preset,
|
||||
required this.ext,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final _SizePreset preset;
|
||||
final AppThemeExtension ext;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_HoverCell> createState() => _HoverCellState();
|
||||
}
|
||||
|
||||
class _HoverCellState extends State<_HoverCell> {
|
||||
bool _isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = widget.ext;
|
||||
final preset = widget.preset;
|
||||
final accent = ext.accent;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovering = true),
|
||||
onExit: (_) => setState(() => _isHovering = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOut,
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovering
|
||||
? accent.withValues(alpha: 0.14)
|
||||
: ext.overlaySubtle.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: _isHovering
|
||||
? accent.withValues(alpha: 0.45)
|
||||
: const Color(0x00000000),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
preset.icon,
|
||||
size: 22,
|
||||
color: _isHovering ? accent : ext.iconSecondary,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
preset.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _isHovering ? accent : ext.textPrimary,
|
||||
),
|
||||
),
|
||||
if (preset.dimensionText.isNotEmpty) ...[
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
preset.dimensionText,
|
||||
style: TextStyle(fontSize: 10, color: ext.textHint),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user