// widgets/ds/m_sheet.dart — 统一移动底部 sheet(镜像原型 mobile-atoms .m-sheet)。 // 结构:grip(36×4) + 标题行(m-sheet-h:标题 + X) + 滚动体(m-sheet-b) + 可选底部操作条。 // max 高 86vh、顶部 r-xl 圆角、SelectionArea 可选中复制;键盘弹出自动避让。 import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_chrome.g.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/utils/dialog_util.dart'; /// 打开中的移动 sheet 关闭器栈:壳层横滑手势据此判定——有 sheet 开着时 /// 「从左往右滑」关顶层 sheet,而不是切 tab / 退二级屏(2026-07-04 拍板)。 final List _openSheetClosers = []; bool get hasOpenMSheet => _openSheetClosers.isNotEmpty; void closeTopMSheet() { if (_openSheetClosers.isNotEmpty) _openSheetClosers.last(); } /// 打开移动底部 sheet(对齐原型 mobile-shell openSheet)。 /// - [title] 为 null 时只渲染 grip,内容自带头部(详情抽屉复用场景)。 /// - [scrollable] true:内容包 SingleChildScrollView;false:内容自管布局 /// (需自带定界高度,如 SizedBox(height:…),供 Column/Expanded 正常工作)。 /// - [actions] 底部固定操作条(原型 .m-actionbar 形态:横排等分按钮)。 Future showMSheet( BuildContext context, { String? title, required WidgetBuilder builder, bool scrollable = true, double maxHeightFactor = 0.86, List? actions, }) { // 登记关闭器(壳层手势用);sheet 结束(任何方式关闭)时注销。 final nav = Navigator.of(context); void closer() { if (nav.canPop()) nav.pop(); } _openSheetClosers.add(closer); return _showMSheetInner( context, title: title, builder: builder, scrollable: scrollable, maxHeightFactor: maxHeightFactor, actions: actions, ).whenComplete(() => _openSheetClosers.remove(closer)); } Future _showMSheetInner( BuildContext context, { String? title, required WidgetBuilder builder, bool scrollable = true, double maxHeightFactor = 0.86, List? actions, }) { return showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, barrierColor: AppChrome.scrim, // .m-sheet-mask --scrim builder: (ctx) { final t = ctx.tokens; final maxH = MediaQuery.of(ctx).size.height * maxHeightFactor; final viewInsets = MediaQuery.of(ctx).viewInsets.bottom; return Padding( // 键盘弹出时整体上移(sheet 内含输入框场景) padding: EdgeInsets.only(bottom: viewInsets), child: Container( clipBehavior: Clip.antiAlias, constraints: BoxConstraints(maxHeight: maxH), decoration: BoxDecoration( color: t.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(AppDims.rXl)), ), // sheet 是独立 overlay(在 app_shell 的 SelectionArea 之外)→ 自带一层 child: SelectionArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // .grip Center( child: Container( width: 36, height: 4, margin: const EdgeInsets.only(top: 8, bottom: 2), decoration: BoxDecoration( color: t.border, borderRadius: BorderRadius.circular(AppDims.rPill), ), ), ), if (title != null) Container( padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: t.borderSubtle)), ), child: Row(children: [ Expanded( child: Text(title, style: TextStyle( fontSize: AppDims.fsTitle, fontWeight: FontWeight.w700, color: t.heading)), ), InkWell( onTap: () => Navigator.of(ctx).pop(), child: Icon(LucideIcons.x, size: 20, color: t.muted), ), ]), ), Flexible( child: scrollable ? SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), child: Builder(builder: builder), ) : Builder(builder: builder), ), if (actions != null) Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), decoration: BoxDecoration( border: Border(top: BorderSide(color: t.borderSubtle)), ), child: Row(children: [ for (var i = 0; i < actions.length; i++) ...[ if (i > 0) const SizedBox(width: 10), Expanded(child: actions[i]), ], ]), ), ], ), ), ), ); }, ); } /// 自适应弹层:窄屏走底部 sheet,宽屏走既有 showAppDialog(Dialog 卡)。 /// 供各屏的筛选 / 轻表单 / 选项选择统一调用,减少 isMobile 样板分支。 Future showAdaptiveSheet( BuildContext context, { required String title, required WidgetBuilder builder, List? actions, double desktopWidth = 480, }) { if (context.isMobile) { return showMSheet(context, title: title, builder: builder, actions: actions); } return showAppDialog( context: context, builder: (ctx) { final t = ctx.tokens; return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppDims.rLg)), child: SizedBox( width: ctx.dialogWidth(desktopWidth), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), child: Row(children: [ Expanded( child: Text(title, style: TextStyle( fontSize: AppDims.fsTitle, fontWeight: FontWeight.w700, color: t.heading)), ), InkWell( onTap: () => Navigator.of(ctx).pop(), child: Icon(LucideIcons.x, size: 18, color: t.muted), ), ]), ), Flexible( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 14, 20, 18), child: Builder(builder: builder), ), ), if (actions != null) Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 18), child: Row(children: [ for (var i = 0; i < actions.length; i++) ...[ if (i > 0) const SizedBox(width: 10), Expanded(child: actions[i]), ], ]), ), ], ), ), ); }, ); }