// widgets/ds/ds_menu.dart — 原型 .menu/.menu-item 下拉菜单(镜像 atoms.css + shell.js openMenu)。 // 定位规则照抄 openMenu:宽 = max(锚宽, minW=168),左缘夹在视口内(留 8px), // 锚下 6px 展开、下方放不下且上方够高则向上翻。 import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/theme/app_dims.g.dart'; /// 菜单项:sel → 前导 ✓(primary) + 文字 primary fw600;icon → 前导 16px 图标; /// 都没有 → 16px 占位(对齐 openMenu 的 lead 槽)。 class DsMenuItem { final T value; final String label; final IconData? icon; final bool selected; const DsMenuItem({ required this.value, required this.label, this.icon, this.selected = false, }); } /// 单选菜单:点击项返回其 value 并关闭;点外部关闭返回 null。 Future showDsMenu( BuildContext anchorContext, { required List> items, double minWidth = 168, }) { final rect = _anchorRect(anchorContext); return Navigator.of(anchorContext).push(_DsMenuRoute( anchorRect: rect, minWidth: minWidth, builder: (ctx) => _DsMenuPanel( children: [ for (final it in items) _DsMenuItemTile( item: it, onTap: () => Navigator.of(ctx).pop(it.value), ), ], ), )); } /// 多选菜单(列设置 / 多选筛选):点击项切换选中、菜单保持打开;点外部关闭。 /// [itemsBuilder] 每次切换后重建以刷新 ✓ 态;[onToggle] 通知外部改状态。 Future showDsMultiMenu( BuildContext anchorContext, { required List> Function() itemsBuilder, required ValueChanged onToggle, double minWidth = 168, }) { final rect = _anchorRect(anchorContext); return Navigator.of(anchorContext).push(_DsMenuRoute( anchorRect: rect, minWidth: minWidth, builder: (ctx) => StatefulBuilder( builder: (ctx, setState) => _DsMenuPanel( children: [ for (final it in itemsBuilder()) _DsMenuItemTile( item: it, onTap: () { onToggle(it.value); setState(() {}); }, ), ], ), ), )); } Rect _anchorRect(BuildContext anchorContext) { // 锚点矩形必须和菜单路由同坐标系:路由推到 Navigator.of(anchorContext), // 该导航器不一定占满屏幕(shell 分支导航器原点在侧栏右/顶栏下; // useRootNavigator:false 的抽屉同理),用全局坐标会整体漂移。 final box = anchorContext.findRenderObject() as RenderBox; final navBox = Navigator.of(anchorContext).context.findRenderObject() as RenderBox?; final origin = box.localToGlobal(Offset.zero, ancestor: navBox); return origin & box.size; } class _DsMenuRoute extends PopupRoute { final Rect anchorRect; final double minWidth; final WidgetBuilder builder; _DsMenuRoute({ required this.anchorRect, required this.minWidth, required this.builder, }); @override Color? get barrierColor => Colors.transparent; @override bool get barrierDismissible => true; @override String? get barrierLabel => 'menu'; @override Duration get transitionDuration => Duration.zero; @override Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { return CustomSingleChildLayout( delegate: _DsMenuLayout(anchorRect, minWidth), child: builder(context), ); } } class _DsMenuLayout extends SingleChildLayoutDelegate { final Rect anchor; final double minWidth; _DsMenuLayout(this.anchor, this.minWidth); @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { final maxW = math.max(minWidth, constraints.maxWidth - 16); return BoxConstraints( minWidth: math.min(math.max(anchor.width, minWidth), maxW), maxWidth: maxW, maxHeight: math.max(0, constraints.maxHeight - 16), ); } @override Offset getPositionForChild(Size size, Size childSize) { final left = anchor.left .clamp(8.0, math.max(8.0, size.width - childSize.width - 8)) .toDouble(); final below = anchor.bottom + 6; final above = anchor.top - childSize.height - 6; final top = (below + childSize.height > size.height && above > 6) ? above : below; return Offset(left, top); } @override bool shouldRelayout(_DsMenuLayout old) => old.anchor != anchor || old.minWidth != minWidth; } /// .menu:surface / border / r-md / sh-2(0 4px 14px shadow) / pad 6。 class _DsMenuPanel extends StatelessWidget { final List children; const _DsMenuPanel({required this.children}); @override Widget build(BuildContext context) { final t = context.tokens; return Material( color: Colors.transparent, child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), boxShadow: [ BoxShadow( color: t.shadow, offset: const Offset(0, 4), blurRadius: 14), ], ), child: SingleChildScrollView( child: IntrinsicWidth( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: children, ), ), ), ), ); } } /// .menu-item:gap9 / pad 8 10 / r-sm / fs-body,hover→bg; /// sel → 前导 ✓ + primary fw600;icon 颜色随文字(.mi-ic 继承 currentColor)。 class _DsMenuItemTile extends StatelessWidget { final DsMenuItem item; final VoidCallback onTap; const _DsMenuItemTile({required this.item, required this.onTap}); @override Widget build(BuildContext context) { final t = context.tokens; final fg = item.selected ? t.primary : t.text; return InkWell( onTap: onTap, hoverColor: t.bg, borderRadius: BorderRadius.circular(AppDims.rSm), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 16, height: 16, child: item.selected ? Icon(LucideIcons.check, size: 16, color: t.primary) : (item.icon != null ? Icon(item.icon, size: 16, color: fg) : null), ), const SizedBox(width: 9), Text(item.label, style: TextStyle( fontSize: AppDims.fsBody, color: fg, fontWeight: item.selected ? FontWeight.w600 : FontWeight.w400)), ], ), ), ); } }