Files
xianyan/lib/shared/widgets/window_size_popup.dart
Developer f7520b17b2 win提交
2026-06-22 03:50:59 +08:00

414 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言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),
),
],
],
),
),
),
);
}
}