// adaptive_menu.dart — 自适应方向的弹出菜单(通用组件)。 // // 行为:点触发按钮后,根据按钮在屏幕的位置自动决定菜单**向下**还是**向上**弹 // (下方空间够就向下,不够就向上;都不够取较大一侧),且**始终留间隙、不遮挡触发 // 按钮本身**。水平方向菜单右缘对齐按钮右缘并夹在屏内。点菜单外区域关闭。 // // 用 Overlay 自绘(而非 PopupMenuButton——后者不保证翻转、可能盖住按钮)。 import 'package:flutter/material.dart'; import '../pangolin_theme.dart'; /// 一个菜单项。danger=true 用危险色(如删除)。 class AdaptiveMenuItem { const AdaptiveMenuItem({ required this.icon, required this.label, required this.onSelected, this.danger = false, }); final IconData icon; final String label; final VoidCallback onSelected; final bool danger; } /// 触发按钮(图标)+ 自适应弹出菜单。 class AdaptiveMenuButton extends StatefulWidget { const AdaptiveMenuButton({ super.key, required this.items, required this.icon, this.iconColor, this.iconSize = 18, this.menuWidth = 208, this.itemHeight = 44, }); final List items; final IconData icon; final Color? iconColor; final double iconSize; final double menuWidth; final double itemHeight; @override State createState() => _AdaptiveMenuButtonState(); } class _AdaptiveMenuButtonState extends State { final _anchorKey = GlobalKey(); OverlayEntry? _entry; @override void dispose() { _close(); super.dispose(); } void _close() { _entry?.remove(); _entry = null; } void _open() { if (widget.items.isEmpty) return; final box = _anchorKey.currentContext?.findRenderObject() as RenderBox?; final overlay = Overlay.of(context); final overlayBox = overlay.context.findRenderObject() as RenderBox?; if (box == null || overlayBox == null) return; final topLeft = box.localToGlobal(Offset.zero, ancestor: overlayBox); final size = box.size; final screen = overlayBox.size; final safe = MediaQuery.of(context).padding; const gap = 6.0; final menuH = widget.items.length * widget.itemHeight + 10; final triggerTop = topLeft.dy; final triggerBottom = topLeft.dy + size.height; final spaceBelow = screen.height - safe.bottom - 8 - triggerBottom; final spaceAbove = triggerTop - safe.top - 8; // 下方放得下就向下;否则若下方比上方大也向下;否则向上。 final openDown = spaceBelow >= menuH + gap || spaceBelow >= spaceAbove; // 水平:菜单右缘对齐按钮右缘,夹在屏内。 final maxLeft = (screen.width - widget.menuWidth - 8).clamp(8.0, double.infinity); final left = (topLeft.dx + size.width - widget.menuWidth).clamp(8.0, maxLeft); // 垂直:向下→按钮底+gap(不遮挡);向上→按钮顶-gap-菜单高(不遮挡)。 final top = openDown ? triggerBottom + gap : (triggerTop - gap - menuH).clamp(safe.top + 8, double.infinity); final c = context.pangolin; _entry = OverlayEntry( builder: (_) => Stack(children: [ Positioned.fill( child: GestureDetector(behavior: HitTestBehavior.translucent, onTap: _close), ), Positioned( left: left, top: top, width: widget.menuWidth, child: _MenuCard( scheme: c, items: widget.items, itemHeight: widget.itemHeight, fromTop: openDown, onPick: (it) { _close(); it.onSelected(); }, ), ), ]), ); overlay.insert(_entry!); } @override Widget build(BuildContext context) { final c = context.pangolin; return InkResponse( key: _anchorKey, radius: 22, onTap: _open, child: Padding( padding: const EdgeInsets.all(6), child: Icon(widget.icon, size: widget.iconSize, color: widget.iconColor ?? c.fg3), ), ); } } class _MenuCard extends StatelessWidget { const _MenuCard({ required this.scheme, required this.items, required this.itemHeight, required this.fromTop, required this.onPick, }); final PangolinScheme scheme; final List items; final double itemHeight; final bool fromTop; // 向下弹时从顶部展开,向上弹时从底部展开 final ValueChanged onPick; @override Widget build(BuildContext context) { final c = scheme; return TweenAnimationBuilder( duration: const Duration(milliseconds: 130), curve: Curves.easeOutCubic, tween: Tween(begin: 0, end: 1), builder: (_, v, child) => Opacity( opacity: v.clamp(0.0, 1.0), child: Transform.scale( scale: 0.96 + 0.04 * v, alignment: fromTop ? Alignment.topRight : Alignment.bottomRight, child: child, ), ), child: Material( color: c.surface, elevation: 6, shadowColor: PangolinColors.sand950.withValues(alpha: 0.24), // 暖近黑阴影(§5;非冷黑 Colors.black26) clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(PangolinRadius.md), side: BorderSide(color: c.border), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 5), for (final it in items) InkWell( onTap: () => onPick(it), child: SizedBox( height: itemHeight, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14), child: Row(children: [ Icon(it.icon, size: 16, color: it.danger ? c.danger : c.fg2), const SizedBox(width: 11), Text(it.label, style: PangolinText.sm.copyWith(color: it.danger ? c.danger : c.fg1)), ]), ), ), ), const SizedBox(height: 5), ], ), ), ); } }