414 lines
12 KiB
Dart
414 lines
12 KiB
Dart
/// ============================================================
|
||
/// 闲言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),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|